From 7cfcb20d2b50b0d18801a6c0af2e401c0e060894 Mon Sep 17 00:00:00 2001 From: "grokkybara[bot]" <304785771+grokkybara[bot]@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:48:28 +0100 Subject: [PATCH] Synced from monorepo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes: - Gate session-lifecycle heap steady state with a dhat soak - Unbreak merge lifecycle e2e after default model → grok-4.5 - Scan home-scope rules dirs at /rules - Complete text-input paste and terminal parity - Gate project roles and personas - Use canonical editing in dialogs - Use canonical editing in search bars - Reject ambiguous MCP tool IDs - Harden Git operands for plugins - Simplify queue drain API - Pass RFC 9207 iss through MCP OAuth token exchange - Show leader roster when local agents map is empty - Use canonical editing in Persona views - Remove marketplace default-skills auto-install and purge old installs - Use canonical editing in extension forms - Add canonical dashboard text editing - Use canonical editing in settings - Add /summarize as a /recap alias - Restore previous agent when exiting dashboard - Use tool_choice auto for compaction - Settings toggle for snap-prompt-to-top on send - Update default models to grok-4.5 - Source login shell once for local bash (env + alias/function snapshot) - Template hardcoded param names in server-native tool descriptions - Fix System-Reminder XML tag injection in CLAUDE.md via agents_md - Fix remote workspace-server hardcoding LSP trust (repo code execution risk) - Clear orphaned tool-call updates at turn end - Suppress task wake after cancel - Send x-grok-client-identifier on direct API tool calls - Harden dashboard peek lease transitions - Host /btw side panel in live region (minimal mode) - Bound scroll presentation latency - Highlight multi-line constructs correctly in diffs and the file viewer - Block web_fetch non-public IPs; local opt-in is explicit-host only - Seed coding_data_retention_opt_out=false for OAuth e2es in pty-harness - Follow up clipboard delivery feedback - Use canonical editing in pickers - Route TextArea through canonical editor - Persistent "watching" status row; quieter turn markers - Gate sensitive edit targets - Expose agent registry counts and gate session churn on them - Default coding data sharing to opt-out until server preference applies - Wire chat attachment ids through gateway prompts - On auth refresh failure, issue retry - Forward preview provenance and computer lifecycle state - Document independent privacy controls and scope /privacy output - Strip SamplingError Display prefix on rate-limit UI copy - Stop dumping Cloudflare HTML into Retry failed - Disable in-place prompt edit (scroll jank on enter) - Strip forced ANSI color from gh pr view JSON - Plumb bash tool description onto ToolUsageCard wire --- Cargo.lock | 86 +- Cargo.toml | 1 + SOURCE_REV | 2 +- crates/codegen/xai-grok-agent/Cargo.toml | 1 + .../xai-grok-agent/src/plugins/git_install.rs | 390 +++-- .../src/plugins/install_registry.rs | 50 +- .../xai-grok-agent/src/prompt/agents_md.rs | 678 +++++++- .../codegen/xai-grok-config-types/src/lib.rs | 4 + crates/codegen/xai-grok-mcp/src/oauth.rs | 236 ++- crates/codegen/xai-grok-mcp/src/servers.rs | 147 +- .../xai-grok-models/default_models.json | 42 +- crates/codegen/xai-grok-pager-bin/Cargo.toml | 2 +- crates/codegen/xai-grok-pager-bin/src/main.rs | 67 +- .../xai-grok-pager-minimal/src/commit.rs | 36 + .../codegen/xai-grok-pager-minimal/src/lib.rs | 2 +- .../xai-grok-pager-minimal/src/live.rs | 156 +- .../xai-grok-pager-minimal/src/overlay.rs | 87 +- .../xai-grok-pager-minimal/src/panel.rs | 71 +- .../xai-grok-pager-pty-harness/src/flows.rs | 13 +- .../src/appearance/cache.rs | 43 + .../src/clipboard/mod.rs | 92 +- .../src/clipboard/trust.rs | 558 ++++--- .../xai-grok-pager-render/src/render/draw.rs | 410 +++-- crates/codegen/xai-grok-pager/Cargo.toml | 2 +- .../docs/user-guide/02-authentication.md | 27 + .../docs/user-guide/04-slash-commands.md | 14 +- .../docs/user-guide/05-configuration.md | 43 +- .../docs/user-guide/12-project-rules.md | 12 +- .../docs/user-guide/20-background-tasks.md | 12 + .../docs/user-guide/24-monitoring-usage.md | 18 +- .../xai-grok-pager/docs/user-guide/README.md | 2 + crates/codegen/xai-grok-pager/src/acp/mod.rs | 1 - .../codegen/xai-grok-pager/src/acp/tracker.rs | 13 + .../src/app/acp_handler/background.rs | 28 +- .../xai-grok-pager/src/app/acp_handler/mod.rs | 17 +- .../src/app/acp_handler/prompt_origin.rs | 50 +- .../src/app/acp_handler/queue.rs | 7 +- .../app/acp_handler/session_notification.rs | 36 +- .../src/app/acp_handler/tests/interjection.rs | 229 ++- .../src/app/acp_handler/tests/mod.rs | 33 +- .../app/acp_handler/tests/session_events.rs | 12 +- .../app/acp_handler/tests/turn_completion.rs | 572 ++----- .../codegen/xai-grok-pager/src/app/actions.rs | 18 +- .../src/app/agent_view/input.rs | 295 +++- .../src/app/agent_view/links.rs | 33 +- .../xai-grok-pager/src/app/agent_view/mod.rs | 46 +- .../src/app/agent_view/modals.rs | 127 +- .../src/app/agent_view/panes.rs | 105 +- .../src/app/agent_view/paste.rs | 17 +- .../src/app/agent_view/queue.rs | 169 +- .../src/app/agent_view/render.rs | 79 +- .../src/app/agent_view/selection.rs | 8 +- .../src/app/agent_view/session.rs | 62 +- .../src/app/agent_view/viewer.rs | 1 + .../xai-grok-pager/src/app/app_view.rs | 570 +++++-- .../xai-grok-pager/src/app/csi_filter.rs | 106 +- .../xai-grok-pager/src/app/dispatch/auth.rs | 21 +- .../src/app/dispatch/billing.rs | 13 +- .../src/app/dispatch/dashboard.rs | 82 +- .../xai-grok-pager/src/app/dispatch/mod.rs | 8 +- .../xai-grok-pager/src/app/dispatch/modes.rs | 6 +- .../xai-grok-pager/src/app/dispatch/notes.rs | 52 +- .../xai-grok-pager/src/app/dispatch/prompt.rs | 47 +- .../xai-grok-pager/src/app/dispatch/queue.rs | 302 ++-- .../xai-grok-pager/src/app/dispatch/router.rs | 43 +- .../src/app/dispatch/session/foreign.rs | 7 +- .../src/app/dispatch/session/lifecycle.rs | 27 +- .../src/app/dispatch/session/load.rs | 28 +- .../src/app/dispatch/settings/setters.rs | 22 + .../src/app/dispatch/settings/ui.rs | 10 +- .../xai-grok-pager/src/app/dispatch/status.rs | 9 + .../src/app/dispatch/task_result.rs | 18 +- .../src/app/dispatch/tests/billing.rs | 1 + .../src/app/dispatch/tests/dashboard.rs | 268 ++- .../src/app/dispatch/tests/mod.rs | 6 +- .../src/app/dispatch/tests/notes.rs | 227 +++ .../src/app/dispatch/tests/prompt.rs | 4 +- .../src/app/dispatch/tests/router.rs | 35 +- .../src/app/dispatch/tests/session/foreign.rs | 2 +- .../src/app/dispatch/tests/session/load.rs | 116 +- .../src/app/dispatch/tests/settings.rs | 40 +- .../src/app/dispatch/tests/status.rs | 15 + .../src/app/dispatch/tests/task_result.rs | 66 +- .../xai-grok-pager/src/app/dispatch/turn.rs | 21 +- .../xai-grok-pager/src/app/effects/helpers.rs | 12 +- .../xai-grok-pager/src/app/effects/mod.rs | 12 +- .../xai-grok-pager/src/app/effects/tests.rs | 41 +- .../xai-grok-pager/src/app/event_loop.rs | 1323 +++++++++++---- .../xai-grok-pager/src/app/inline_edit.rs | 9 + crates/codegen/xai-grok-pager/src/app/mod.rs | 137 +- .../codegen/xai-grok-pager/src/app/modals.rs | 324 +++- .../xai-grok-pager/src/app/queue_edit.rs | 13 +- .../xai-grok-pager/src/app/signal_handler.rs | 85 +- .../xai-grok-pager/src/app/turn_completion.rs | 40 +- .../src/app/turn_completion/tests.rs | 67 +- .../xai-grok-pager/src/app/xt_filter.rs | 209 ++- .../src/bin/scrollback_search_playground.rs | 60 +- .../codegen/xai-grok-pager/src/diagnostics.rs | 101 +- crates/codegen/xai-grok-pager/src/headless.rs | 6 +- .../xai-grok-pager/src/input/line_editor.rs | 304 ++++ .../codegen/xai-grok-pager/src/input/mod.rs | 1 + .../codegen/xai-grok-pager/src/minimal/api.rs | 243 ++- .../xai-grok-pager/src/scrollback/block.rs | 2 +- .../src/scrollback/blocks/mod.rs | 2 +- .../src/scrollback/blocks/session_event.rs | 169 +- .../src/scrollback/blocks/tool/edit.rs | 88 +- .../src/scrollback/blocks/tool/execute.rs | 2 +- .../xai-grok-pager/src/scrollback/search.rs | 255 ++- .../src/scrollback/state/mod.rs | 79 +- .../src/scrollback/state/nav.rs | 80 + .../xai-grok-pager/src/settings/defs.rs | 33 +- .../xai-grok-pager/src/settings/registry.rs | 25 +- .../xai-grok-pager/src/slash/commands/btw.rs | 11 + .../xai-grok-pager/src/slash/commands/mod.rs | 7 + .../src/slash/commands/recap.rs | 6 +- .../xai-grok-pager/src/views/agents_modal.rs | 908 +++++++--- .../xai-grok-pager/src/views/block_viewer.rs | 9 + .../src/views/dashboard/render.rs | 460 ++++-- .../xai-grok-pager/src/views/dashboard/row.rs | 6 +- .../src/views/dashboard/state.rs | 487 ++++-- .../src/views/extensions_modal.rs | 1055 +++++------- .../src/views/file_search/line_viewer.rs | 42 +- .../src/views/list_pane/state/methods.rs | 23 + .../src/views/list_pane/state/mod.rs | 28 + .../xai-grok-pager/src/views/memory_modal.rs | 268 ++- .../codegen/xai-grok-pager/src/views/modal.rs | 4 +- .../src/views/new_worktree_dialog.rs | 133 +- .../src/views/persona_detail.rs | 245 +-- .../src/views/persona_detail/tests.rs | 189 +++ .../xai-grok-pager/src/views/picker.rs | 1103 +++++++++---- .../xai-grok-pager/src/views/queue_pane.rs | 16 + .../src/views/session_picker.rs | 118 +- .../src/views/settings_modal/input.rs | 538 +++--- .../src/views/settings_modal/mod.rs | 2 +- .../src/views/settings_modal/render.rs | 278 ++-- .../src/views/settings_modal/state.rs | 290 +++- .../src/views/settings_modal/tests.rs | 587 +++---- .../src/views/shortcuts_help.rs | 104 +- .../src/views/subagent_catalog_pane.rs | 4 + .../xai-grok-pager/src/views/tasks_pane.rs | 4 + .../xai-grok-pager/src/views/todo_pane.rs | 4 + .../xai-grok-pager/src/views/turn_status.rs | 267 ++- .../xai-grok-pager/src/views/welcome/mod.rs | 271 ++- ...ake_cancel_preserves_queued_user_prompt.rs | 206 +++ .../endline_park_two_static_markers.rs | 33 +- ...p.rs => endline_wakeups_are_markerless.rs} | 121 +- .../tests/pty_e2e/iterm_readline_editing.rs | 150 ++ .../tests/pty_e2e/page_flip_on_send_pty.rs | 91 + ...efforts_menu_renders_and_remaps_on_wire.rs | 4 +- .../reparked_wait_repushes_buried_marker.rs | 37 +- .../spinner_reappears_after_wait_resumes.rs | 20 +- .../xai-grok-pager/tests/pty_e2e_config_ui.rs | 2 + .../tests/pty_e2e_persistence.rs | 4 +- .../tests/pty_e2e_scroll_selection.rs | 2 + .../xai-grok-pager/tests/settings_e2e.rs | 639 ++++--- .../xai-grok-plugin-marketplace/src/git.rs | 100 +- .../src/installer.rs | 318 ++-- .../src/scanner.rs | 70 +- crates/codegen/xai-grok-sampler/src/client.rs | 165 +- .../tests/cf_edge_error_message.rs | 120 ++ .../xai-grok-sampling-types/src/error.rs | 115 +- .../xai-grok-sampling-types/src/lib.rs | 2 +- .../codegen/xai-grok-shared/src/ui_config.rs | 23 + crates/codegen/xai-grok-shell/CHANGELOG.md | 62 +- crates/codegen/xai-grok-shell/Cargo.toml | 4 +- .../xai-grok-shell/changelogs/0.2.103.json | 67 + .../xai-grok-shell/changelogs/0.2.103.md | 21 + .../xai-grok-shell/changelogs/0.2.104.json | 27 + .../xai-grok-shell/changelogs/0.2.104.md | 13 + .../xai-grok-shell/changelogs/0.2.105.json | 67 + .../xai-grok-shell/changelogs/0.2.105.md | 24 + .../xai-grok-shell/src/agent/config.rs | 177 +- .../codegen/xai-grok-shell/src/agent/init.rs | 2 + .../src/agent/mvp_agent/acp_agent.rs | 1 + .../src/agent/mvp_agent/agent_ops.rs | 21 +- .../xai-grok-shell/src/agent/mvp_agent/mod.rs | 19 +- .../src/agent/mvp_agent/session_lifecycle.rs | 43 + .../agent/mvp_agent/subagent_coordinator.rs | 39 +- .../src/agent/mvp_agent/tests.rs | 177 ++ .../src/agent/mvp_agent/tests/dhat_soak.rs | 101 ++ .../src/agent/subagent/coordinator_query.rs | 4 + .../src/agent/subagent/handle_request.rs | 6 +- .../xai-grok-shell/src/agent/subagent/mod.rs | 70 +- .../src/agent/subagent/tests/mod.rs | 39 +- .../xai-grok-shell/src/auth/device_code.rs | 2 +- .../xai-grok-shell/src/auth/external_auth.rs | 2 +- .../codegen/xai-grok-shell/src/auth/meta.rs | 22 +- crates/codegen/xai-grok-shell/src/auth/mod.rs | 4 +- .../codegen/xai-grok-shell/src/auth/model.rs | 35 +- .../xai-grok-shell/src/auth/oidc/protocol.rs | 4 +- .../src/auth/refresh/external_refresher.rs | 18 +- crates/codegen/xai-grok-shell/src/bundle.rs | 5 +- .../codegen/xai-grok-shell/src/config/mod.rs | 57 +- .../xai-grok-shell/src/config/tests.rs | 392 ++++- .../xai-grok-shell/src/extensions/auth.rs | 5 +- .../xai-grok-shell/src/extensions/bundle.rs | 127 +- .../xai-grok-shell/src/extensions/debug.rs | 8 + .../src/extensions/marketplace.rs | 456 +++-- .../src/extensions/notification.rs | 16 +- .../xai-grok-shell/src/extensions/pr.rs | 25 +- .../codegen/xai-grok-shell/src/inspect/mod.rs | 154 +- .../xai-grok-shell/src/leader/server.rs | 1184 +++++++++---- crates/codegen/xai-grok-shell/src/lib.rs | 3 + .../xai-grok-shell/src/sampling/error.rs | 112 +- .../xai-grok-shell/src/session/acp_session.rs | 16 +- .../src/session/acp_session_impl/goal.rs | 1 + .../session/acp_session_impl/interjection.rs | 1 + .../session/acp_session_impl/model_switch.rs | 6 + .../acp_session_impl/notification_drain.rs | 201 ++- .../session/acp_session_impl/prompt_build.rs | 178 +- .../session/acp_session_impl/prompt_queue.rs | 35 +- .../src/session/acp_session_impl/reminders.rs | 16 +- .../src/session/acp_session_impl/run_loop.rs | 128 +- .../session/acp_session_impl/sampler_turn.rs | 42 +- .../src/session/acp_session_impl/spawn.rs | 148 +- .../session/acp_session_impl/tasks_cancel.rs | 83 +- .../session/acp_session_impl/tool_calls.rs | 60 +- .../src/session/acp_session_impl/turn.rs | 20 +- .../auth_error_no_retry_tests.rs | 95 ++ .../auto_wake_suppression_tests.rs | 600 ++++++- .../cancel_running_task_tests.rs | 193 +++ .../goal/goal_backoff_tests.rs | 2 + .../goal/goal_classifier_e2e_tests.rs | 1 + .../acp_session_tests/idle_resume_tests.rs | 1 + .../inline_auto_compact_flow_tests.rs | 3 + .../laziness/laziness_integration_tests.rs | 1 + .../acp_session_tests/memory_config_tests.rs | 1 + .../prompt_queue_actor_tests.rs | 11 + .../recap_display_only_tests.rs | 2 + .../replay_buffer_send_update_tests.rs | 1 + .../rewrite_zero_turn_prefix_tests.rs | 51 + .../src/session/acp_session_tests/support.rs | 26 +- .../turn_completion_emit_tests.rs | 1 + .../xai-grok-shell/src/session/commands.rs | 28 +- .../xai-grok-shell/src/session/compaction.rs | 3 + .../src/session/compaction_config.rs | 1 + .../helpers/full_replace_compaction.rs | 4 + .../src/session/helpers/session_compact.rs | 178 +- .../codegen/xai-grok-shell/src/session/mod.rs | 2 +- .../src/test_support/lsp_runtime.rs | 3 +- .../xai-grok-shell/src/tools/config.rs | 16 + .../src/tools/notification_bridge.rs | 569 +++++-- .../xai-grok-shell/src/tools/tool_context.rs | 17 +- .../src/util/config/resolve/compaction.rs | 77 + .../src/util/config/resolve/toolset.rs | 131 ++ .../src/util/config/settings_writes.rs | 4 + crates/codegen/xai-grok-shell/src/util/mod.rs | 59 + .../tests/test_built_binary_e2e.rs | 36 +- .../tests/test_mcp_permission_persistence.rs | 1 + .../tests/test_registry_churn.rs | 295 ++++ crates/codegen/xai-grok-tools/src/bridge.rs | 43 +- .../xai-grok-tools/src/computer/local/mod.rs | 2 + .../src/computer/local/shell_state.rs | 22 +- .../src/computer/local/static_shell.rs | 311 ++++ .../src/computer/local/terminal.rs | 560 ++++++- .../xai-grok-tools/src/computer/types.rs | 2 + .../implementations/codex/grep_files/tool.rs | 3 +- .../implementations/grok_build/bash/mod.rs | 40 +- .../implementations/grok_build/grep/mod.rs | 37 +- .../grok_build/kill_task/mod.rs | 30 + .../grok_build/kill_task/terminal_command.rs | 32 +- .../grok_build/read_file/mod.rs | 2 +- .../grok_build/task_output/mod.rs | 66 + .../task_output/terminal_command.rs | 4 +- .../grok_build/web_fetch/client.rs | 95 +- .../grok_build/web_fetch/config.rs | 10 + .../grok_build/web_fetch/ssrf.rs | 392 +++-- .../grok_build_hashline/grep.rs | 4 +- .../grok_build_hashline/read_file.rs | 2 +- .../src/implementations/opencode/bash/mod.rs | 33 +- .../src/implementations/opencode/edit/mod.rs | 34 +- .../src/implementations/opencode/grep/mod.rs | 6 +- .../src/implementations/opencode/read/mod.rs | 2 +- .../src/reminders/task_completion.rs | 261 ++- .../xai-grok-tools/src/types/resources.rs | 11 + crates/codegen/xai-grok-version/Cargo.toml | 2 +- .../src/bin/workspace_server.rs | 21 +- .../xai-grok-workspace/src/discovery.rs | 17 +- .../xai-grok-workspace/src/folder_trust.rs | 91 +- .../codegen/xai-grok-workspace/src/handle.rs | 3 + crates/codegen/xai-grok-workspace/src/mcp.rs | 72 +- .../src/permission/manager.rs | 342 +++- .../src/permission/prompter.rs | 108 +- .../src/permission/shell_access.rs | 197 ++- .../src/permission/state.rs | 289 +++- .../src/permission/types.rs | 6 + .../src/session/tool_config.rs | 9 +- .../xai-ratatui-textarea/src/editor.rs | 51 +- .../xai-ratatui-textarea/src/editor_keys.rs | 44 +- .../src/editor_tests/keys.rs | 27 +- .../xai-ratatui-textarea/src/textarea.rs | 1470 +++++++++-------- crates/common/xai-tool-types/src/task.rs | 91 +- 292 files changed, 23379 insertions(+), 9273 deletions(-) create mode 100644 crates/codegen/xai-grok-pager/src/input/line_editor.rs create mode 100644 crates/codegen/xai-grok-pager/src/views/persona_detail/tests.rs rename crates/codegen/xai-grok-pager/tests/pty_e2e/{endline_wake_markers_close_each_wakeup.rs => endline_wakeups_are_markerless.rs} (59%) create mode 100644 crates/codegen/xai-grok-pager/tests/pty_e2e/iterm_readline_editing.rs create mode 100644 crates/codegen/xai-grok-pager/tests/pty_e2e/page_flip_on_send_pty.rs create mode 100644 crates/codegen/xai-grok-sampler/tests/cf_edge_error_message.rs create mode 100644 crates/codegen/xai-grok-shell/changelogs/0.2.103.json create mode 100644 crates/codegen/xai-grok-shell/changelogs/0.2.103.md create mode 100644 crates/codegen/xai-grok-shell/changelogs/0.2.104.json create mode 100644 crates/codegen/xai-grok-shell/changelogs/0.2.104.md create mode 100644 crates/codegen/xai-grok-shell/changelogs/0.2.105.json create mode 100644 crates/codegen/xai-grok-shell/changelogs/0.2.105.md create mode 100644 crates/codegen/xai-grok-shell/src/agent/mvp_agent/tests/dhat_soak.rs create mode 100644 crates/codegen/xai-grok-shell/tests/test_registry_churn.rs create mode 100644 crates/codegen/xai-grok-tools/src/computer/local/static_shell.rs diff --git a/Cargo.lock b/Cargo.lock index a299b14..bcb9a0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1257,11 +1257,11 @@ dependencies = [ "bitflags 2.13.0", "cexpr", "clang-sys", - "itertools 0.10.5", + "itertools 0.13.0", "proc-macro2", "quote", "regex", - "rustc-hash", + "rustc-hash 2.1.1", "shlex", "syn 2.0.117", ] @@ -1753,7 +1753,7 @@ version = "4.5.49" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "syn 2.0.117", @@ -2625,6 +2625,22 @@ version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" +[[package]] +name = "dhat" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98cd11d84628e233de0ce467de10b8633f4ddaecafadefc86e13b84b8739b827" +dependencies = [ + "backtrace", + "lazy_static", + "mintex", + "parking_lot", + "rustc-hash 1.1.0", + "serde", + "serde_json", + "thousands", +] + [[package]] name = "diff" version = "0.1.13" @@ -4811,12 +4827,6 @@ dependencies = [ "stable_deref_trait", ] -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - [[package]] name = "heck" version = "0.5.0" @@ -5102,7 +5112,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core 0.62.2", ] [[package]] @@ -6164,6 +6174,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mintex" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c505b3e17ed6b70a7ed2e67fbb2c560ee327353556120d6e72f5232b6880d536" + [[package]] name = "mio" version = "1.2.1" @@ -7172,7 +7188,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ed4d5c6ae95e08ac768883c8401cf0e8deb4e6e1d6a4e1fd3d2ec4f0ec63200" dependencies = [ - "heck 0.5.0", + "heck", "itertools 0.14.0", "prost", "prost-types", @@ -7872,7 +7888,7 @@ version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac6c3320f9abac597dcbc668774ef006702672474aad53c6d596b62e487b40b1" dependencies = [ - "heck 0.4.1", + "heck", "itertools 0.14.0", "log", "multimap", @@ -8043,7 +8059,7 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash", + "rustc-hash 2.1.1", "rustls", "socket2", "thiserror 2.0.18", @@ -8064,7 +8080,7 @@ dependencies = [ "lru-slab", "rand 0.9.2", "ring", - "rustc-hash", + "rustc-hash 2.1.1", "rustls", "rustls-pki-types", "slab", @@ -8692,6 +8708,12 @@ version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.1" @@ -9943,7 +9965,7 @@ version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "rustversion", @@ -9956,7 +9978,7 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "syn 2.0.117", @@ -9968,7 +9990,7 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "syn 2.0.117", @@ -9981,7 +10003,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb6895a12ac5599bb6057362f00e8a3cf1daab4df33f553a55690a44e4fed8d0" dependencies = [ "kurbo 0.12.0", - "rustc-hash", + "rustc-hash 2.1.1", "skrifa", "write-fonts", ] @@ -10299,6 +10321,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "thousands" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bf63baf9f5039dadc247375c29eb13706706cfde997d0330d05aa63a77d8820" + [[package]] name = "thread_local" version = "1.1.9" @@ -11115,7 +11143,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7b026f540b148b81043c720889dbb942b08659aa8a43f624ac4f04dbfc1861" dependencies = [ - "heck 0.5.0", + "heck", "log", "proc-macro2", "quote", @@ -12570,7 +12598,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", - "heck 0.5.0", + "heck", "wit-parser", ] @@ -12581,7 +12609,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", - "heck 0.5.0", + "heck", "indexmap", "prettyplease", "syn 2.0.117", @@ -12787,7 +12815,7 @@ dependencies = [ "once_cell", "petgraph 0.6.5", "rayon", - "rustc-hash", + "rustc-hash 2.1.1", "serde", "serde_json", "smallvec", @@ -13022,6 +13050,7 @@ dependencies = [ "xai-grok-tools", "xai-token-estimation", "xai-tool-types", + "xai-tty-utils", "zeroize", ] @@ -13276,7 +13305,7 @@ dependencies = [ [[package]] name = "xai-grok-pager" -version = "0.2.102" +version = "0.2.105" dependencies = [ "agent-client-protocol", "ansi-to-tui", @@ -13365,7 +13394,7 @@ dependencies = [ [[package]] name = "xai-grok-pager-bin" -version = "0.2.102" +version = "0.2.105" dependencies = [ "anyhow", "clap", @@ -13627,7 +13656,7 @@ dependencies = [ [[package]] name = "xai-grok-shell" -version = "0.2.102" +version = "0.2.105" dependencies = [ "agent-client-protocol", "anyhow", @@ -13644,6 +13673,7 @@ dependencies = [ "clap", "criterion", "dashmap", + "dhat", "dirs 6.0.0", "dunce", "filetime", @@ -14018,7 +14048,7 @@ dependencies = [ [[package]] name = "xai-grok-version" -version = "0.2.102" +version = "0.2.105" dependencies = [ "semver", ] @@ -14080,7 +14110,7 @@ dependencies = [ "regex", "reqwest 0.12.24", "rusqlite", - "rustc-hash", + "rustc-hash 2.1.1", "rustls", "serde", "serde_json", @@ -14178,7 +14208,7 @@ dependencies = [ "chrono", "dunce", "gix", - "rustc-hash", + "rustc-hash 2.1.1", "serde", "serde_json", "similar", diff --git a/Cargo.toml b/Cargo.toml index 8184403..d7c9052 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -125,6 +125,7 @@ crossterm = "0.28" cryptify = "3.2" dashmap = "6" derive_more = { version = "2", features = ["add", "add_assign", "debug", "deref", "deref_mut", "display", "from", "from_str", "into", "into_iterator", "try_into"] } +dhat = "0.3" dirs = "5.0" documented = "0.9" dunce = "1" diff --git a/SOURCE_REV b/SOURCE_REV index 8dc4cfe..3306655 100644 --- a/SOURCE_REV +++ b/SOURCE_REV @@ -1 +1 @@ -124d85bc5dc6e7805560215fcc6d5413944920e1 +f9736c7b86f8e1c0e99e20ebbbd1195cd0c147e3 diff --git a/crates/codegen/xai-grok-agent/Cargo.toml b/crates/codegen/xai-grok-agent/Cargo.toml index 8f2d08c..f41a8e5 100644 --- a/crates/codegen/xai-grok-agent/Cargo.toml +++ b/crates/codegen/xai-grok-agent/Cargo.toml @@ -10,6 +10,7 @@ dunce = { workspace = true } xai-grok-hooks = { path = "../xai-grok-hooks" } xai-grok-sampling-types = { path = "../xai-grok-sampling-types" } xai-grok-tools = { path = "../xai-grok-tools" } +xai-tty-utils = { workspace = true } xai-token-estimation = { workspace = true } minijinja = { version = "2", features = ["custom_syntax"] } git2 = { version = "0.20", default-features = false, features = ["vendored-libgit2"] } diff --git a/crates/codegen/xai-grok-agent/src/plugins/git_install.rs b/crates/codegen/xai-grok-agent/src/plugins/git_install.rs index 9ad48ad..047f4af 100644 --- a/crates/codegen/xai-grok-agent/src/plugins/git_install.rs +++ b/crates/codegen/xai-grok-agent/src/plugins/git_install.rs @@ -6,7 +6,6 @@ //! are re-copied at session spawn / reload by [`super::local_refresh`]. use std::path::{Path, PathBuf}; -use std::process::Command; use super::install_registry::{ InstallError, InstallKind, InstallRegistry, InstalledRepo, RepoPlugin, @@ -16,7 +15,7 @@ use super::manifest::{ManifestLoadResult, load_manifest, name_from_dirname}; /// Source of a plugin installation. #[derive(Debug, Clone)] pub enum InstallSource { - /// Remote git repo — will be cloned. + /// Remote git repo or Git-supported local repository path — will be cloned. Git { url: String, git_ref: Option, @@ -36,6 +35,7 @@ pub struct InstallResult { pub repo_path: PathBuf, pub plugins: Vec, pub commit: Option, + kind: InstallKind, } /// A plugin discovered within an installed source. @@ -129,6 +129,46 @@ pub fn is_full_commit_sha(s: &str) -> bool { (s.len() == 40 || s.len() == 64) && s.bytes().all(|b| b.is_ascii_hexdigit()) } +fn validate_git_operand<'a>(value: &'a str, kind: &str) -> Result<&'a str, String> { + let value = value.trim(); + if value.is_empty() { + return Err(format!("empty git {kind}")); + } + if value.contains('\0') { + return Err(format!("git {kind} contains NUL")); + } + if value.starts_with('-') { + return Err(format!("git {kind} may not begin with '-'")); + } + Ok(value) +} + +/// Validate and trim a Git repository URL or path used as a CLI operand. +pub fn validate_git_url(url: &str) -> Result<&str, String> { + validate_git_operand(url, "URL") +} + +/// Validate and trim a Git ref used as a CLI operand. +pub fn validate_git_ref(git_ref: &str) -> Result<&str, String> { + validate_git_operand(git_ref, "ref") +} + +/// Validate and trim a full Git commit object ID. +pub fn validate_git_sha(sha: &str) -> Result<&str, String> { + let sha = sha.trim(); + if sha.contains('\0') { + return Err("git commit SHA contains NUL".into()); + } + if sha.starts_with('-') { + return Err("git commit SHA may not begin with '-'".into()); + } + if is_full_commit_sha(sha) { + Ok(sha) + } else { + Err("git commit SHA must be 40 or 64 hexadecimal characters".into()) + } +} + /// The require-sha gate every remote plugin fetch goes through: policy on + no /// full-hex pin → typed refusal. Local-directory installs are exempt (the /// operator controls that disk; nothing is fetched). @@ -138,7 +178,10 @@ pub fn ensure_pinned( plugin: &str, url: &str, ) -> Result<(), InstallError> { - if !require_sha || sha.map(str::trim).is_some_and(is_full_commit_sha) { + if !require_sha { + return Ok(()); + } + if sha.map(str::trim).is_some_and(is_full_commit_sha) { return Ok(()); } tracing::warn!( @@ -152,14 +195,14 @@ pub fn ensure_pinned( }) } -/// Prefer an explicit full-sha pin; if only `git_ref` is a full commit sha, -/// hoist it into the sha slot so the verified clone path is used. Catalog pins +/// Prefer an explicit supplied SHA; if only `git_ref` is a full commit SHA, +/// hoist it into the SHA slot so the verified clone path is used. Catalog pins /// published as `ref` still need this. pub fn hoist_pin_slots<'a>( git_ref: Option<&'a str>, git_sha: Option<&'a str>, ) -> (Option<&'a str>, Option<&'a str>) { - match git_sha.map(str::trim).filter(|s| !s.is_empty()) { + match git_sha.map(str::trim) { Some(s) => (git_ref, Some(s)), None => match git_ref.map(str::trim).filter(|s| is_full_commit_sha(s)) { Some(s) => (None, Some(s)), @@ -221,23 +264,7 @@ pub fn install_from_source_with_label( require_sha: bool, plugin_label: Option<&str>, ) -> Result { - let source = &match source { - InstallSource::Git { - url, - git_ref, - git_sha, - subdir, - } => { - let (r, s) = hoist_pin_slots(git_ref.as_deref(), git_sha.as_deref()); - InstallSource::Git { - url: url.clone(), - git_ref: r.map(str::to_owned), - git_sha: s.map(str::to_owned), - subdir: subdir.clone(), - } - } - other => other.clone(), - }; + let source = &normalize_install_source(source)?; if let InstallSource::Git { url, git_sha, .. } = source { let label = plugin_label.unwrap_or(url.as_str()); ensure_pinned(require_sha, git_sha.as_deref(), label, url)?; @@ -258,7 +285,7 @@ pub fn install_from_source_with_label( let repo_path = install_dir.join(&repo_key); - let (_kind, commit) = match source { + let (kind, commit) = match source { InstallSource::Git { url, git_ref, @@ -269,7 +296,7 @@ pub fn install_from_source_with_label( let commit = read_head_commit(&repo_path); let kind = InstallKind::Git { url: url.clone(), - git_ref: git_ref.clone(), + git_ref: git_sha.clone().or_else(|| git_ref.clone()), commit: commit.clone().unwrap_or_default(), subdir: subdir.clone(), }; @@ -316,9 +343,59 @@ pub fn install_from_source_with_label( repo_path, plugins, commit, + kind, }) } +fn normalize_install_source(source: &InstallSource) -> Result { + match source { + InstallSource::Git { + url, + git_ref, + git_sha, + subdir, + } => { + let (git_ref, git_sha) = hoist_pin_slots(git_ref.as_deref(), git_sha.as_deref()); + let (url, git_ref, git_sha) = clone_operands(url, git_ref, git_sha)?; + Ok(InstallSource::Git { + url: url.to_owned(), + git_ref: git_ref.map(str::to_owned), + git_sha: git_sha.map(str::to_owned), + subdir: subdir.clone(), + }) + } + local @ InstallSource::Local { .. } => Ok(local.clone()), + } +} + +/// Argv for `git remote add` with options terminated before free operands. +pub fn remote_add_args(url: &str) -> [&str; 5] { + ["remote", "add", "--", "origin", url] +} + +/// Argv for shallow `git fetch` of a SHA with options terminated before free operands. +pub fn fetch_sha_args(sha: &str) -> [&str; 6] { + ["fetch", "--depth", "1", "--", "origin", sha] +} + +/// Validate/normalize URL + optional ref/SHA for pre-trust clone paths. +pub fn clone_operands<'a>( + url: &'a str, + git_ref: Option<&'a str>, + git_sha: Option<&'a str>, +) -> Result<(&'a str, Option<&'a str>, Option<&'a str>), InstallError> { + let url = validate_git_url(url).map_err(|detail| InstallError::InstallFailed { detail })?; + let git_ref = git_ref + .map(validate_git_ref) + .transpose() + .map_err(|detail| InstallError::InstallFailed { detail })?; + let git_sha = git_sha + .map(validate_git_sha) + .transpose() + .map_err(|detail| InstallError::InstallFailed { detail })?; + Ok((url, git_ref, git_sha)) +} + /// Clone a git repo using the `git` CLI (supports shallow clone, SSH, etc.; /// optionally SHA-pinned via `git_sha`). fn clone_repo( @@ -327,26 +404,25 @@ fn clone_repo( git_sha: Option<&str>, target: &Path, ) -> Result<(), InstallError> { + let (url, git_ref, git_sha) = clone_operands(url, git_ref, git_sha)?; if let Some(sha) = git_sha { if git_ref.is_some() { - tracing::debug!(?git_ref, sha, "git_sha takes precedence over git_ref"); + tracing::debug!(git_ref, sha, "git_sha takes precedence over git_ref"); } return clone_repo_at_sha(url, sha, target); } - let mut cmd = Command::new("git"); - xai_grok_tools::util::detach_std_command(&mut cmd); + // Match marketplace cache: BatchMode SSH, empty ASKPASS, skip LFS smudge. + let mut cmd = xai_tty_utils::git_command(); cmd.arg("clone").arg("--depth").arg("1"); - cmd.stdin(std::process::Stdio::null()); - cmd.envs(xai_grok_tools::util::pager_env()); if let Some(r) = git_ref { cmd.arg("--branch").arg(r); } - cmd.arg(url).arg(target); + cmd.arg("--").arg(url).arg(target); - tracing::info!(url = url, target = %target.display(), "cloning plugin repo"); + tracing::info!(url, target = %target.display(), "cloning plugin repo"); let output = cmd.output().map_err(|e| InstallError::InstallFailed { detail: format!("failed to run git clone: {e}"), @@ -368,13 +444,10 @@ fn clone_repo( } fn clone_repo_at_sha(url: &str, sha: &str, target: &Path) -> Result<(), InstallError> { - if sha.is_empty() { - return Err(InstallError::InstallFailed { - detail: "empty SHA provided for pinned clone".into(), - }); - } + let url = validate_git_url(url).map_err(|detail| InstallError::InstallFailed { detail })?; + let sha = validate_git_sha(sha).map_err(|detail| InstallError::InstallFailed { detail })?; - tracing::info!(url = url, sha = sha, target = %target.display(), "cloning plugin repo at SHA"); + tracing::info!(url, sha, target = %target.display(), "cloning plugin repo at SHA"); std::fs::create_dir_all(target).map_err(|e| InstallError::Io { path: target.to_path_buf(), @@ -387,8 +460,8 @@ fn clone_repo_at_sha(url: &str, sha: &str, target: &Path) -> Result<(), InstallE }; run_git_in(target, &["init", "--quiet"]).map_err(wrap_fail)?; - run_git_in(target, &["remote", "add", "origin", url]).map_err(wrap_fail)?; - run_git_in(target, &["fetch", "--depth", "1", "origin", sha]) + run_git_in(target, &remote_add_args(url)).map_err(wrap_fail)?; + run_git_in(target, &fetch_sha_args(sha)) .map_err(|d| wrap_fail(format!("fetch-by-sha failed: {d}")))?; run_git_in(target, &["checkout", "--quiet", "FETCH_HEAD"]).map_err(wrap_fail)?; @@ -414,12 +487,9 @@ fn run_git_in(cwd: &Path, args: &[&str]) -> Result<(), String> { } fn run_git_in_capture(cwd: &Path, args: &[&str]) -> Result { - let mut cmd = Command::new("git"); - xai_grok_tools::util::detach_std_command(&mut cmd); - cmd.args(args) - .current_dir(cwd) - .stdin(std::process::Stdio::null()) - .envs(xai_grok_tools::util::pager_env()); + // Same auth/LFS/SSH suppression as marketplace cache clones. + let mut cmd = xai_tty_utils::git_command(); + cmd.args(args).current_dir(cwd); let output = cmd .output() .map_err(|e| format!("failed to run git {}: {e}", args.first().unwrap_or(&"")))?; @@ -581,28 +651,11 @@ fn try_load_plugin(dir: &Path, subdir: Option<&str>) -> Option } } -/// Build an `InstalledRepo` from an install result and the original source. -pub fn build_installed_repo(result: &InstallResult, source: &InstallSource) -> InstalledRepo { - let kind = match source { - InstallSource::Git { - url, - git_ref, - git_sha, - subdir, - } => InstallKind::Git { - url: url.clone(), - git_ref: git_sha.clone().or_else(|| git_ref.clone()), - commit: result.commit.clone().unwrap_or_default(), - subdir: subdir.clone(), - }, - InstallSource::Local { path, subdir } => InstallKind::Local { - source_path: path.clone(), - subdir: subdir.clone(), - }, - }; +/// Build an `InstalledRepo` from the normalized install result. +pub fn build_installed_repo(result: &InstallResult, _: &InstallSource) -> InstalledRepo { let now = chrono::Utc::now().to_rfc3339(); InstalledRepo { - kind, + kind: result.kind.clone(), installed_at: now.clone(), updated_at: now, path: result.repo_path.clone(), @@ -696,12 +749,8 @@ pub fn update_repo( }); } - let mut cmd = Command::new("git"); - xai_grok_tools::util::detach_std_command(&mut cmd); - cmd.args(["pull", "--ff-only"]) - .current_dir(repo_path) - .stdin(std::process::Stdio::null()) - .envs(xai_grok_tools::util::pager_env()); + let mut cmd = xai_tty_utils::git_command(); + cmd.args(["pull", "--ff-only"]).current_dir(repo_path); let output = cmd.output().map_err(|e| InstallError::InstallFailed { detail: format!("failed to run git pull: {e}"), })?; @@ -762,6 +811,7 @@ pub(super) fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> #[cfg(test)] mod tests { use super::*; + use std::process::Command; #[test] fn repo_key_distinct_per_git_subdir_and_bare_unchanged() { @@ -1121,6 +1171,25 @@ mod tests { (tmp, sha) } + #[test] + fn sha_git_args_terminate_options_before_operands() { + assert_eq!( + remote_add_args("repo"), + ["remote", "add", "--", "origin", "repo"] + ); + assert_eq!( + fetch_sha_args("0123456789abcdef0123456789abcdef01234567"), + [ + "fetch", + "--depth", + "1", + "--", + "origin", + "0123456789abcdef0123456789abcdef01234567", + ] + ); + } + #[test] fn clone_at_correct_sha_succeeds() { if !git_available() { @@ -1164,26 +1233,22 @@ mod tests { } #[test] - fn clone_at_sha_handles_short_sha_via_mismatch() { - if !git_available() { - eprintln!("skipping: `git` binary not available in test sandbox"); - return; - } - let (repo, sha) = make_local_repo(); - let short = &sha[..7]; - let dest = tempfile::tempdir().unwrap(); - let url = format!("file://{}", repo.path().display()); - - let err = clone_repo_at_sha(&url, short, dest.path()) - .expect_err("short sha should fail verification"); - - match err { - InstallError::ShaMismatch { expected, actual } => { - assert_eq!(expected, short); - assert_eq!(actual, sha); - } - InstallError::InstallFailed { .. } => {} - other => panic!("expected ShaMismatch or InstallFailed, got: {other:?}"), + fn clone_at_sha_rejects_malformed_pin_before_target_creation() { + let root = tempfile::tempdir().unwrap(); + let bad_shas = [ + "deadbee", + "--upload-pack=cmd", + "gggggggggggggggggggggggggggggggggggggggg", + ]; + for (index, bad) in bad_shas.into_iter().enumerate() { + let target = root.path().join(index.to_string()); + let err = clone_repo_at_sha("file:///unused", bad, &target) + .expect_err("malformed SHA must be rejected"); + assert!(matches!(err, InstallError::InstallFailed { .. })); + assert!( + !target.exists(), + "validation must precede filesystem mutation" + ); } } @@ -1313,27 +1378,55 @@ mod tests { } #[test] - fn ensure_pinned_accepts_only_full_hex_shas() { + fn git_operand_validators_preserve_supported_inputs() { + for url in [ + "https://example.com/repo.git", + "ssh://git@example.com/repo.git", + "git@example.com:repo.git", + "file:///tmp/repo.git", + "/tmp/repo.git", + "./repo.git", + "../repo.git", + "ext::helper-specific-address", + ] { + assert_eq!(validate_git_url(&format!(" {url} ")).unwrap(), url); + } + for git_ref in [ + "main", + "feature/topic", + "refs/tags/v1.2.3", + "release@{yesterday}", + ] { + assert_eq!(validate_git_ref(&format!(" {git_ref} ")).unwrap(), git_ref); + } + for bad in ["", " ", "--upload-pack=cmd", "bad\0value"] { + assert!(validate_git_url(bad).is_err(), "URL {bad:?} must fail"); + assert!(validate_git_ref(bad).is_err(), "ref {bad:?} must fail"); + } + } + + #[test] + fn supplied_sha_is_always_full_hex() { let sha1 = "a".repeat(40); - let sha256 = "b".repeat(64); + let sha256 = "B".repeat(64); + assert_eq!(validate_git_sha(&format!(" {sha1} ")).unwrap(), sha1); + assert_eq!(validate_git_sha(&sha256).unwrap(), sha256); assert!(ensure_pinned(false, None, "p", "u").is_ok()); assert!(ensure_pinned(true, Some(&sha1), "p", "u").is_ok()); - assert!(ensure_pinned(true, Some(&sha256), "p", "u").is_ok()); + let nonhex = "g".repeat(40); for bad in [ - None, - Some("main"), - Some("deadbeef"), - Some(""), - Some("v1.2.3"), + "", + "deadbeef", + nonhex.as_str(), + "--upload-pack=cmd", + "bad\0sha", ] { - assert!( - matches!( - ensure_pinned(true, bad, "p", "u"), - Err(InstallError::UnpinnedRemoteRefused { .. }) - ), - "{bad:?} must be refused" - ); + assert!(validate_git_sha(bad).is_err(), "SHA {bad:?} must fail"); } + assert!(matches!( + ensure_pinned(true, None, "p", "u"), + Err(InstallError::UnpinnedRemoteRefused { .. }) + )); } #[test] @@ -1350,11 +1443,90 @@ mod tests { assert_eq!(hoist_pin_slots(Some("main"), None), (Some("main"), None)); assert_eq!( hoist_pin_slots(Some(sha.as_str()), Some(" ")), - (None, Some(sha.as_str())), - "blank sha is treated as absent so a full-sha ref can still hoist" + (Some(sha.as_str()), Some("")), + "a supplied blank SHA remains a SHA field and must fail validation" ); } + #[test] + fn normalized_git_kind_stays_pinned_in_durable_metadata() { + for (git_ref, git_sha, expected_pin) in [ + (Some(" v1.2.3 "), None, "v1.2.3".to_string()), + (None, Some(format!(" {} ", "a".repeat(40))), "a".repeat(40)), + ] { + let source = InstallSource::Git { + url: " https://example.com/repo.git ".into(), + git_ref: git_ref.map(str::to_owned), + git_sha, + subdir: None, + }; + let normalized = normalize_install_source(&source).unwrap(); + let (url, git_ref) = match normalized { + InstallSource::Git { + url, + git_ref, + git_sha, + .. + } => (url, git_sha.or(git_ref)), + InstallSource::Local { .. } => unreachable!(), + }; + let repo_key = InstallRegistry::repo_key(&url); + let result = InstallResult { + repo_key: repo_key.clone(), + repo_path: PathBuf::from("/unused"), + plugins: Vec::new(), + commit: Some("a".repeat(40)), + kind: InstallKind::Git { + url, + git_ref, + commit: "a".repeat(40), + subdir: None, + }, + }; + let repo = build_installed_repo(&result, &source); + + assert_eq!( + repo_key, + InstallRegistry::repo_key("https://example.com/repo.git") + ); + match &repo.kind { + InstallKind::Git { url, git_ref, .. } => { + assert_eq!(url, "https://example.com/repo.git"); + assert_eq!(git_ref.as_deref(), Some(expected_pin.as_str())); + } + InstallKind::Local { .. } => panic!("expected Git"), + } + assert!(matches!( + update_repo(&repo_key, &repo, true), + Ok(UpdateStatus::Pinned { ref_name }) if ref_name == expected_pin + )); + } + } + + #[test] + fn install_from_source_rejects_malformed_operands_before_install_dir_creation() { + let root = tempfile::tempdir().unwrap(); + let install_dir = root.path().join("installed-plugins"); + let registry = InstallRegistry::empty(install_dir.clone()); + for (url, git_ref, git_sha) in [ + ("--upload-pack=cmd", None, None), + ("file:///unused", Some("--upload-pack=cmd"), None), + ("file:///unused", None, Some("deadbeef")), + ] { + let source = InstallSource::Git { + url: url.into(), + git_ref: git_ref.map(str::to_owned), + git_sha: git_sha.map(str::to_owned), + subdir: None, + }; + assert!(matches!( + install_from_source(&source, ®istry, false), + Err(InstallError::InstallFailed { .. }) + )); + assert!(!install_dir.exists()); + } + } + #[test] fn install_from_source_gates_and_hoists_sha_pins() { let install = tempfile::tempdir().unwrap(); diff --git a/crates/codegen/xai-grok-agent/src/plugins/install_registry.rs b/crates/codegen/xai-grok-agent/src/plugins/install_registry.rs index 84d61c2..c3d7d6d 100644 --- a/crates/codegen/xai-grok-agent/src/plugins/install_registry.rs +++ b/crates/codegen/xai-grok-agent/src/plugins/install_registry.rs @@ -104,36 +104,46 @@ impl InstallRegistry { /// /// If the registry file doesn't exist, returns an empty registry. pub fn load() -> Self { - let install_dir = Self::resolve_install_dir(); - let registry_path = install_dir.join("registry.json"); + Self::load_from(Self::resolve_install_dir()) + } - match std::fs::read_to_string(®istry_path) { - Ok(content) => match serde_json::from_str::(&content) { - Ok(mut reg) => { - reg.install_dir = install_dir; - reg - } - Err(e) => { - tracing::warn!( - path = %registry_path.display(), - error = %e, - "failed to parse install registry; starting fresh" - ); - Self::empty(install_dir) - } - }, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Self::empty(install_dir), + /// Load the registry from an explicit install directory. + /// + /// Missing file → empty registry. Read/parse errors → empty registry after a warning. + pub fn load_from(install_dir: PathBuf) -> Self { + match Self::try_load_from(install_dir.clone()) { + Ok(reg) => reg, Err(e) => { tracing::warn!( - path = %registry_path.display(), + path = %install_dir.join("registry.json").display(), error = %e, - "failed to read install registry; starting fresh" + "failed to load install registry; starting fresh" ); Self::empty(install_dir) } } } + /// Fallible load: missing `registry.json` is empty; read/parse errors are `Err`. + pub fn try_load_from(install_dir: PathBuf) -> Result { + let registry_path = install_dir.join("registry.json"); + match std::fs::read_to_string(®istry_path) { + Ok(content) => { + let mut reg: InstallRegistry = + serde_json::from_str(&content).map_err(|e| InstallError::Json { + detail: e.to_string(), + })?; + reg.install_dir = install_dir; + Ok(reg) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::empty(install_dir)), + Err(e) => Err(InstallError::Io { + path: registry_path, + source: e, + }), + } + } + /// Create an empty registry for the given install directory. pub fn empty(install_dir: PathBuf) -> Self { Self { diff --git a/crates/codegen/xai-grok-agent/src/prompt/agents_md.rs b/crates/codegen/xai-grok-agent/src/prompt/agents_md.rs index cbbfdc9..8e7705d 100644 --- a/crates/codegen/xai-grok-agent/src/prompt/agents_md.rs +++ b/crates/codegen/xai-grok-agent/src/prompt/agents_md.rs @@ -1,7 +1,10 @@ //! AGENTS.md / Claude.md / rules directory discovery and loading. //! //! Searches from cwd to repo root, plus `~/.grok/`. Also discovers -//! `*.md` files in `.grok/rules/` and `.claude/rules/` directories. +//! `*.md` files in rules directories: vendor-prefixed `.grok/rules/`, +//! `.claude/rules/`, and `.cursor/rules/` in project directories, and a +//! plain `rules/` directly under the vendor-qualified home-scope roots +//! (`~/.grok/rules/`, `~/.claude/rules/`, `~/.cursor/rules/`). use std::path::{Path, PathBuf}; @@ -63,6 +66,81 @@ fn find_rules_files(dir: &Path, rules_subdirs: &[&str]) -> Vec { results } +/// Canonicalize a path for discovery deduplication, falling back to the +/// original path when canonicalization fails. +fn canonical_for_dedup(path: &Path) -> PathBuf { + dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) +} + +struct DiscoveryRoot { + path: PathBuf, + canonical_path: PathBuf, + scan_named_files: bool, + rules_subdirs: Vec<&'static str>, +} + +fn add_discovery_root( + roots: &mut Vec, + path: PathBuf, + scan_named_files: bool, + rules_subdirs: &[&'static str], +) { + let canonical_path = canonical_for_dedup(&path); + if let Some(root) = roots + .iter_mut() + .find(|root| root.canonical_path == canonical_path && root.rules_subdirs == rules_subdirs) + { + root.scan_named_files |= scan_named_files; + return; + } + + roots.push(DiscoveryRoot { + path, + canonical_path, + scan_named_files, + rules_subdirs: rules_subdirs.to_vec(), + }); +} + +struct DiscoveredCandidate { + path: PathBuf, + is_rule: bool, + is_project: bool, +} + +fn add_discovered_candidate( + candidates: &mut Vec, + seen_canonical: &mut std::collections::HashMap, + path: PathBuf, + is_rule: bool, + is_project: bool, +) { + let canonical_path = canonical_for_dedup(&path); + if let Some(index) = seen_canonical.get(&canonical_path).copied() { + candidates[index].is_rule |= is_rule; + if is_project && !candidates[index].is_project { + let mut candidate = candidates.remove(index); + candidate.path = path; + candidate.is_project = true; + for candidate_index in seen_canonical.values_mut() { + if *candidate_index > index { + *candidate_index -= 1; + } + } + seen_canonical.insert(canonical_path, candidates.len()); + candidates.push(candidate); + } + return; + } + + seen_canonical.insert(canonical_path, candidates.len()); + candidates.push(DiscoveredCandidate { + path, + is_rule, + is_project, + }); +} + /// Read Agents.md from ~/.grok/, git repo root, and session cwd. /// Returns a list of AgentConfigFile with their file names, full paths, and contents. /// @@ -83,100 +161,146 @@ async fn read_agents_config_with_options( working_directory: &str, workspace_user_dir: Option<&Path>, compat: CompatConfig, +) -> Vec { + read_agents_config_with_roots( + working_directory, + workspace_user_dir, + compat, + xai_grok_tools::util::grok_home::grok_home(), + dirs::home_dir(), + ) + .await +} + +const HOME_RULES_DIRS: &[&str] = &["rules"]; + +async fn read_agents_config_with_roots( + working_directory: &str, + workspace_user_dir: Option<&Path>, + compat: CompatConfig, + grok_home: PathBuf, + home_dir: Option, ) -> Vec { let cwd = PathBuf::from(working_directory); - let global_dir = xai_grok_tools::util::grok_home::grok_home(); let git_root = git2::Repository::discover(&cwd) .ok() - .and_then(|repo| repo.workdir().map(|p| p.to_path_buf())); - + .and_then(|repo| repo.workdir().map(Path::to_path_buf)); let gitignore = build_gitignore(git_root.as_deref()); + let agent_filenames = compat.agent_filenames(); + let project_rules_dirs = compat.rules_dirs(); - // Always include grok_home (~/.grok/) first, then ~/.claude/ and ~/.cursor/ - // for compat — each gated by the resolved `agents` compat cell. - let mut dirs = vec![global_dir]; - if let Some(home) = dirs::home_dir() { - for compat_dir in compat.agents_home_dirs() { - let dir = home.join(compat_dir); - if dir.is_dir() { - dirs.push(dir); - } + let mut home_roots = Vec::new(); + add_discovery_root(&mut home_roots, grok_home, true, HOME_RULES_DIRS); + if let Some(home) = home_dir { + if compat.claude.agents || compat.claude.rules { + add_discovery_root( + &mut home_roots, + home.join(".claude"), + compat.claude.agents, + if compat.claude.rules { + HOME_RULES_DIRS + } else { + &[] + }, + ); + } + if compat.cursor.agents || compat.cursor.rules { + add_discovery_root( + &mut home_roots, + home.join(".cursor"), + compat.cursor.agents, + if compat.cursor.rules { + HOME_RULES_DIRS + } else { + &[] + }, + ); } } - // Walk from cwd up to git root to pick up agent files in intermediate directories + let mut project_roots = Vec::new(); if let Some(ref root) = git_root { let mut current = Some(cwd.as_path()); - let mut chain: Vec = Vec::new(); + let mut chain = Vec::new(); while let Some(dir) = current { - let dir_buf = dir.to_path_buf(); - if !chain.contains(&dir_buf) { - chain.push(dir_buf); + if !chain.iter().any(|existing| existing == dir) { + chain.push(dir.to_path_buf()); } if dir == root.as_path() { break; } current = dir.parent(); } - // CRITICAL: Reverse to get root → CWD order (deeper files come later) chain.reverse(); - // Inject optional workspace user dir if not already in the chain. - // Insert after repo root (index 0 after reverse) so it's higher priority - // than repo root AGENTS.md but lower priority than intermediate dirs and cwd. if let Some(user_dir) = workspace_user_dir { - let user_dir_canonical = - dunce::canonicalize(user_dir).unwrap_or_else(|_| user_dir.to_path_buf()); - let already_in_chain = chain.iter().any(|d| { - dunce::canonicalize(d).unwrap_or_else(|_| d.clone()) == user_dir_canonical - }); - if !already_in_chain { - // chain[0] is repo root after reverse; insert right after it. - let insert_pos = 1.min(chain.len()); - chain.insert(insert_pos, user_dir.to_path_buf()); + let user_dir_canonical = canonical_for_dedup(user_dir); + if !chain + .iter() + .any(|dir| canonical_for_dedup(dir) == user_dir_canonical) + { + chain.insert(1.min(chain.len()), user_dir.to_path_buf()); } } - dirs.extend(chain); - } else if !dirs.contains(&cwd) { - dirs.push(cwd.clone()); + for dir in chain { + add_discovery_root(&mut project_roots, dir, true, &project_rules_dirs); + } + } else { + add_discovery_root(&mut project_roots, cwd, true, &project_rules_dirs); } - // Compute the gated lists once (constant across all scanned dirs) so the - // per-directory scan below doesn't re-allocate them. - let agent_filenames = compat.agent_filenames(); - let rules_dirs = compat.rules_dirs(); - let files: Vec = dirs + let roots = home_roots .into_iter() - .flat_map(|dir| { - let mut combined = find_agent_files(&dir, &agent_filenames); - combined.extend(find_rules_files(&dir, &rules_dirs)); - combined - }) - .filter(|path| !is_ignored(path, gitignore.as_ref(), git_root.as_deref())) - .collect(); + .map(|root| (root, false)) + .chain(project_roots.into_iter().map(|root| (root, true))); + let mut candidates = Vec::new(); + let mut seen_candidates = std::collections::HashMap::new(); + for (root, is_project) in roots { + if root.scan_named_files { + for path in find_agent_files(&root.path, &agent_filenames) { + if !is_ignored(&path, gitignore.as_ref(), git_root.as_deref()) { + add_discovered_candidate( + &mut candidates, + &mut seen_candidates, + path, + false, + is_project, + ); + } + } + } + for path in find_rules_files(&root.path, &root.rules_subdirs) { + if !is_ignored(&path, gitignore.as_ref(), git_root.as_deref()) { + add_discovered_candidate( + &mut candidates, + &mut seen_candidates, + path, + true, + is_project, + ); + } + } + } - // Deduplicate by canonical path to handle case-insensitive filesystems - // and symlink-resolved tmpdir paths. - let mut seen_canonical = std::collections::HashSet::new(); - - files + candidates .into_iter() - .filter(|path| { - let canonical = dunce::canonicalize(path).unwrap_or_else(|_| path.clone()); - seen_canonical.insert(canonical) - }) - .filter_map(|file_path| { - let content = std::fs::read_to_string(&file_path).ok()?; - let file_name = file_path + .filter_map(|candidate| { + let content = std::fs::read_to_string(&candidate.path).ok()?; + let content = if candidate.is_rule { + xai_grok_tools::implementations::skills::skill::extract_skill_body(&content) + } else { + content + }; + let file_name = candidate + .path .file_name() - .and_then(|f| f.to_str()) + .and_then(|file_name| file_name.to_str()) .unwrap_or("AGENTS.md") .to_string(); - let full_path = file_path.display().to_string(); Some(AgentConfigFile { file_name, - file_path: full_path, + file_path: candidate.path.display().to_string(), content, }) }) @@ -194,6 +318,21 @@ pub fn format_agents_md_section(configs: &[AgentConfigFile]) -> Option { pub const LEGACY_AGENTS_MD_REMINDER_PREFIX: &str = "\n\n\nAs you answer the user's questions, you can use the following context"; +/// Open/close `system-reminder` (Grok) or `system_reminder` (Cursor/IDE), case-insensitive. +/// Shared with unit tests so CI fails if the pattern is ever invalid or too narrow. +const SYSTEM_REMINDER_TAG_PATTERN: &str = r"(?i)<(\s*/?\s*system[-_]reminder)"; + +/// Literal pattern only — compile failure is a programmer bug, not a runtime input error. +static SYSTEM_REMINDER_TAG_RE: std::sync::LazyLock = + std::sync::LazyLock::new(|| regex::Regex::new(SYSTEM_REMINDER_TAG_PATTERN).unwrap()); + +/// HTML-escape leading `<` so untrusted AGENTS.md cannot break out of / forge harness framing. +fn neutralize_reminder_tags(content: &str) -> String { + SYSTEM_REMINDER_TAG_RE + .replace_all(content, "<$1") + .into_owned() +} + fn render_agents_md(configs: &[AgentConfigFile]) -> Option { if configs.is_empty() { return None; @@ -206,20 +345,11 @@ fn render_agents_md(configs: &[AgentConfigFile]) -> Option { ); for config in configs { - section.push_str(&format!("\n## From: {}\n", config.file_path)); - - // Strip YAML frontmatter from rules files (e.g. .claude/rules/*.md, - // .grok/rules/*.md) so globs/paths metadata doesn't leak into the - // system prompt as raw YAML. - let is_rules_file = config.file_path.contains("/.grok/rules/") - || config.file_path.contains("/.claude/rules/"); - let content = if is_rules_file { - xai_grok_tools::implementations::skills::skill::extract_skill_body(&config.content) - } else { - config.content.clone() - }; - - section.push_str(&content); + section.push_str(&format!( + "\n## From: {}\n", + neutralize_reminder_tags(&config.file_path) + )); + section.push_str(&neutralize_reminder_tags(&config.content)); section.push('\n'); } @@ -477,6 +607,328 @@ mod tests { assert!(configs.iter().any(|c| c.content.contains("outside git"))); } + #[tokio::test] + async fn home_and_project_rules_have_stable_order_without_doubled_paths() { + let tmp = tempfile::tempdir().unwrap(); + let grok_home = tmp.path().join("custom-grok-home"); + let home = tmp.path().join("home"); + let repo = tmp.path().join("repo"); + fs::create_dir_all(grok_home.join("rules")).unwrap(); + fs::create_dir_all(home.join(".claude/rules")).unwrap(); + fs::create_dir_all(home.join(".cursor/rules")).unwrap(); + fs::create_dir_all(repo.join(".grok/rules")).unwrap(); + fs::create_dir_all(repo.join(".claude/rules")).unwrap(); + fs::create_dir_all(repo.join(".cursor/rules")).unwrap(); + init_git_repo(&repo); + + for (path, content) in [ + (grok_home.join("rules/b.md"), "grok-b"), + (grok_home.join("rules/a.md"), "grok-a"), + (home.join(".claude/rules/a.md"), "claude-a"), + (home.join(".cursor/rules/a.md"), "cursor-a"), + (repo.join("AGENTS.md"), "repo-named"), + (repo.join(".grok/rules/a.md"), "repo-grok"), + (repo.join(".claude/rules/a.md"), "repo-claude"), + (repo.join(".cursor/rules/a.md"), "repo-cursor"), + ] { + fs::write(path, content).unwrap(); + } + for path in [ + grok_home.join(".grok/rules/doubled.md"), + home.join(".claude/.claude/rules/doubled.md"), + home.join(".cursor/.cursor/rules/doubled.md"), + ] { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, "doubled").unwrap(); + } + + let configs = read_agents_config_with_roots( + repo.to_str().unwrap(), + None, + CompatConfig::default(), + grok_home, + Some(home), + ) + .await; + let contents: Vec<&str> = configs + .iter() + .map(|config| config.content.as_str()) + .collect(); + assert_eq!( + contents, + vec![ + "grok-a", + "grok-b", + "claude-a", + "cursor-a", + "repo-named", + "repo-grok", + "repo-claude", + "repo-cursor", + ] + ); + assert!( + configs + .iter() + .all(|config| !config.file_path.contains("doubled")) + ); + } + + #[tokio::test] + async fn vendor_home_agents_and_rules_cells_are_independent() { + let tmp = tempfile::tempdir().unwrap(); + let grok_home = tmp.path().join("grok-home"); + let home = tmp.path().join("home"); + let cwd = tmp.path().join("project"); + fs::create_dir_all(&grok_home).unwrap(); + fs::create_dir_all(&cwd).unwrap(); + for vendor in [".claude", ".cursor"] { + let vendor_home = home.join(vendor); + fs::create_dir_all(vendor_home.join("rules")).unwrap(); + fs::write(vendor_home.join("AGENTS.md"), format!("{vendor}-named")).unwrap(); + fs::write(vendor_home.join("rules/rule.md"), format!("{vendor}-rule")).unwrap(); + } + + let mut rules_only = CompatConfig::default(); + rules_only.claude.agents = false; + rules_only.cursor.agents = false; + let configs = read_agents_config_with_roots( + cwd.to_str().unwrap(), + None, + rules_only, + grok_home.clone(), + Some(home.clone()), + ) + .await; + for vendor in [".claude", ".cursor"] { + assert!( + configs + .iter() + .any(|config| config.content == format!("{vendor}-rule")) + ); + assert!( + !configs + .iter() + .any(|config| config.content == format!("{vendor}-named")) + ); + } + + let mut agents_only = CompatConfig::default(); + agents_only.claude.rules = false; + agents_only.cursor.rules = false; + let configs = read_agents_config_with_roots( + cwd.to_str().unwrap(), + None, + agents_only, + grok_home, + Some(home), + ) + .await; + for vendor in [".claude", ".cursor"] { + assert!( + configs + .iter() + .any(|config| config.content == format!("{vendor}-named")) + ); + assert!( + !configs + .iter() + .any(|config| config.content == format!("{vendor}-rule")) + ); + } + } + + #[tokio::test] + async fn nested_grok_home_keeps_project_role_in_repo_order() { + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path().join("repo"); + let nested = repo.join("nested"); + fs::create_dir_all(nested.join("rules")).unwrap(); + fs::create_dir_all(nested.join(".grok/rules")).unwrap(); + init_git_repo(&repo); + fs::write(nested.join("rules/home.md"), "nested-home-rule").unwrap(); + fs::write(repo.join("AGENTS.md"), "repo-named").unwrap(); + fs::write(nested.join("AGENTS.md"), "nested-named").unwrap(); + fs::write(nested.join(".grok/rules/project.md"), "nested-project-rule").unwrap(); + + let configs = read_agents_config_with_roots( + nested.to_str().unwrap(), + None, + CompatConfig::default(), + nested.clone(), + None, + ) + .await; + assert_eq!( + configs + .iter() + .map(|config| config.content.as_str()) + .collect::>(), + vec![ + "nested-home-rule", + "repo-named", + "nested-named", + "nested-project-rule", + ] + ); + } + + #[tokio::test] + async fn overlapping_grok_home_and_project_root_merges_roles() { + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path().join("repo"); + fs::create_dir_all(repo.join("rules")).unwrap(); + fs::create_dir_all(repo.join(".grok/rules")).unwrap(); + fs::create_dir_all(repo.join(".claude/rules")).unwrap(); + init_git_repo(&repo); + fs::write(repo.join("rules/home.md"), "home-rule").unwrap(); + fs::write(repo.join(".grok/rules/project.md"), "project-grok-rule").unwrap(); + fs::write(repo.join(".claude/rules/project.md"), "project-claude-rule").unwrap(); + fs::create_dir_all(repo.join(".grok/.grok/rules")).unwrap(); + fs::write(repo.join(".grok/.grok/rules/doubled.md"), "doubled").unwrap(); + + let configs = read_agents_config_with_roots( + repo.to_str().unwrap(), + None, + CompatConfig::default(), + repo.clone(), + None, + ) + .await; + for expected in ["home-rule", "project-grok-rule", "project-claude-rule"] { + assert_eq!( + configs + .iter() + .filter(|config| config.content == expected) + .count(), + 1, + "{expected} should be discovered exactly once: {configs:?}" + ); + } + assert!(configs.iter().all(|config| config.content != "doubled")); + } + + #[tokio::test] + async fn vendor_home_repo_overlap_keeps_project_named_role() { + let tmp = tempfile::tempdir().unwrap(); + let grok_home = tmp.path().join("grok-home"); + let home = tmp.path().join("home"); + let repo = home.join(".claude"); + fs::create_dir_all(&grok_home).unwrap(); + fs::create_dir_all(repo.join("rules")).unwrap(); + fs::create_dir_all(repo.join(".claude/rules")).unwrap(); + init_git_repo(&repo); + fs::write(repo.join("rules/home.md"), "claude-home-rule").unwrap(); + fs::write(repo.join("AGENTS.md"), "project-named").unwrap(); + fs::write(repo.join(".claude/rules/project.md"), "project-rule").unwrap(); + + let mut compat = CompatConfig::default(); + compat.claude.agents = false; + let configs = read_agents_config_with_roots( + repo.to_str().unwrap(), + None, + compat, + grok_home, + Some(home), + ) + .await; + assert_eq!( + configs + .iter() + .map(|config| config.content.as_str()) + .collect::>(), + vec!["claude-home-rule", "project-named", "project-rule"] + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn canonical_named_rule_collision_is_normalized_once() { + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path().join("repo"); + fs::create_dir_all(repo.join("rules")).unwrap(); + init_git_repo(&repo); + fs::write( + repo.join("AGENTS.md"), + "---\nglobs: ['*.rs']\n---\ncanonical-collision-body", + ) + .unwrap(); + std::os::unix::fs::symlink("../AGENTS.md", repo.join("rules/alias.md")).unwrap(); + + let configs = read_agents_config_with_roots( + repo.to_str().unwrap(), + None, + CompatConfig::default(), + repo.clone(), + None, + ) + .await; + assert_eq!(configs.len(), 1); + assert_eq!( + canonical_for_dedup(Path::new(&configs[0].file_path)), + canonical_for_dedup(&repo.join("AGENTS.md")) + ); + assert!(configs[0].file_name.eq_ignore_ascii_case("AGENTS.md")); + assert_eq!(configs[0].content, "canonical-collision-body"); + } + + #[tokio::test] + async fn rule_frontmatter_is_stripped_but_named_frontmatter_is_preserved() { + let tmp = tempfile::tempdir().unwrap(); + let grok_home = tmp.path().join("custom-grok-home"); + let home = tmp.path().join("home"); + let repo = tmp.path().join("repo"); + fs::create_dir_all(grok_home.join("rules")).unwrap(); + fs::create_dir_all(home.join(".claude/rules")).unwrap(); + fs::create_dir_all(home.join(".cursor/rules")).unwrap(); + fs::create_dir_all(repo.join(".grok/rules")).unwrap(); + fs::create_dir_all(repo.join(".claude/rules")).unwrap(); + fs::create_dir_all(repo.join(".cursor/rules")).unwrap(); + init_git_repo(&repo); + + let frontmatter = |body: &str| format!("---\nglobs: ['*.rs']\n---\n{body}"); + for (path, body) in [ + (grok_home.join("rules/global.md"), "custom-home-body"), + (home.join(".claude/rules/global.md"), "claude-body"), + (home.join(".cursor/rules/global.md"), "cursor-body"), + (repo.join(".grok/rules/project.md"), "grok-project-body"), + (repo.join(".claude/rules/project.md"), "claude-project-body"), + (repo.join(".cursor/rules/project.md"), "cursor-project-body"), + ] { + fs::write(path, frontmatter(body)).unwrap(); + } + fs::write(repo.join("AGENTS.md"), frontmatter("named-body")).unwrap(); + + let configs = read_agents_config_with_roots( + repo.to_str().unwrap(), + None, + CompatConfig::default(), + grok_home, + Some(home), + ) + .await; + for body in [ + "custom-home-body", + "claude-body", + "cursor-body", + "grok-project-body", + "claude-project-body", + "cursor-project-body", + ] { + let config = configs + .iter() + .find(|config| config.content.contains(body)) + .unwrap(); + assert_eq!(config.content, body); + } + let named = configs + .iter() + .find(|config| config.content.contains("named-body")) + .unwrap(); + assert!(named.content.starts_with("---\n")); + assert!(named.content.contains("globs:")); + } + #[tokio::test] async fn read_agents_config_workspace_user_and_repo_root_both_found() { let tmp = tempfile::tempdir().unwrap(); @@ -525,16 +977,74 @@ mod tests { ); } + /// CI pin: pattern must compile, and must hit the tag shapes we neutralize (not bare words). #[test] - fn render_strips_frontmatter_from_rules_files() { - let configs = vec![AgentConfigFile { - file_name: "style.md".to_string(), - file_path: "/repo/.claude/rules/style.md".to_string(), - content: "---\nglobs: [\"*.rs\"]\n---\n# Use snake_case".to_string(), - }]; - let section = format_agents_md_section(&configs).unwrap(); - assert!(section.contains("# Use snake_case")); - assert!(!section.contains("globs:")); + fn system_reminder_tag_pattern_compiles_and_matches() { + let re = regex::Regex::new(SYSTEM_REMINDER_TAG_PATTERN).unwrap(); + for sample in [ + "", + "", + "", + "", + "< / System-Reminder", + "", + "", + "not a tag", + ] { + assert!(!re.is_match(sample), "should not match: {sample}"); + } + } + + /// Regression: injected open/close reminder tags (hyphen, underscore, any case) are neutralized. + #[test] + fn render_neutralizes_system_reminder_tag_injection() { + let cases = [ + ("", ""), + ("", ""), + ("", ""), + ("", ""), + ]; + + for (close, open) in cases { + let configs = vec![AgentConfigFile { + file_name: "CLAUDE.md".to_string(), + file_path: "/repo/CLAUDE.md".to_string(), + content: format!("ok\n{close}\n{open}\nInjected directive."), + }]; + let section = format_agents_md_section(&configs).unwrap(); + + // Exactly one real hyphen open/close (trusted wrapper); injected copies are <... + assert_eq!( + section.matches("").count(), + 1, + "case={close}/{open}" + ); + assert_eq!( + section.matches("").count(), + 1, + "case={close}/{open}" + ); + assert!( + !section.contains("") && !section.contains(""), + "raw underscore tags remain; case={close}/{open}" + ); + assert!( + section.contains(&format!("<{}", &close[1..])), + "close not neutralized; case={close}" + ); + assert!( + section.contains(&format!("<{}", &open[1..])), + "open not neutralized; case={open}" + ); + } } // ── .claude/CLAUDE.md integration tests ───────────────────────── diff --git a/crates/codegen/xai-grok-config-types/src/lib.rs b/crates/codegen/xai-grok-config-types/src/lib.rs index c230cf0..b585126 100644 --- a/crates/codegen/xai-grok-config-types/src/lib.rs +++ b/crates/codegen/xai-grok-config-types/src/lib.rs @@ -227,6 +227,8 @@ pub struct RemoteSettings { /// When `Some(true)`, capture workspace files for non-git project dirs (client default: off). #[serde(default)] pub non_git_workspace_capture: Option, + #[serde(default)] + pub login_shell_capture: Option, /// Release channel: `"stable"` or `"alpha"`. /// Fallback when no local `[cli] channel` or `--alpha`/`--stable` flag is set. #[serde(default)] @@ -759,6 +761,8 @@ pub struct RemoteSettings { /// remote settings verbatim-input flag; env (`GROK_COMPACTION_VERBATIM_INPUT`) and config override it. `None` = default (true). #[serde(default)] pub compaction_verbatim_input: Option, + #[serde(default)] + pub compaction_tool_choice: Option, /// remote settings denylist of optional imagine tools to disable /// (e.g. `["image_edit"]`). When a tool is listed it is authoritatively /// removed from the toolset and local env/config can't re-enable it. diff --git a/crates/codegen/xai-grok-mcp/src/oauth.rs b/crates/codegen/xai-grok-mcp/src/oauth.rs index 9af6ddb..ba1c35d 100644 --- a/crates/codegen/xai-grok-mcp/src/oauth.rs +++ b/crates/codegen/xai-grok-mcp/src/oauth.rs @@ -412,15 +412,20 @@ async fn run_browser_auth_flow( tokio::select! { result = callback_rx => { callback_server.abort(); - let (code, csrf_state) = result + let callback = result .map_err(|_| "Callback channel dropped".to_string())? .map_err(|e| format!("OAuth callback failed: {e}"))?; // 6. Exchange code for tokens (auto-persists via CredentialStore). + // Pass RFC 9207 `iss` when present (required if the AS advertises it). let mgr = auth_manager.lock().await; - mgr.exchange_code_for_token(&code, &csrf_state) - .await - .map_err(|e| format!("Token exchange failed: {e}"))?; + mgr.exchange_code_for_token_with_issuer( + &callback.code, + &callback.state, + callback.issuer.as_deref(), + ) + .await + .map_err(|e| format!("Token exchange failed: {e}"))?; tracing::info!(server = server_name, "MCP OAuth authentication successful"); } @@ -444,22 +449,54 @@ fn html_escape(s: &str) -> String { .replace('\'', "'") } -/// Start a loopback HTTP server for the OAuth callback. -/// -/// Returns the server task handle (for cleanup) and a oneshot receiver -/// that resolves with `(code, state)` when the callback arrives. -/// -/// The caller is responsible for aborting the server handle. +#[derive(Debug, Clone, PartialEq, Eq)] +struct OAuthCallbackPayload { + code: String, + state: String, + /// RFC 9207 `iss` (optional; required when the AS advertises support). + issuer: Option, +} + +fn parse_oauth_callback_params( + params: &HashMap, +) -> Result { + if let Some(error) = params.get("error") { + let desc = params + .get("error_description") + .cloned() + .unwrap_or_else(|| "Unknown error".to_string()); + return Err(format!("OAuth error: {error} - {desc}")); + } + let code = params + .get("code") + .filter(|s| !s.is_empty()) + .cloned() + .ok_or_else(|| "Missing authorization code".to_string())?; + let state = params + .get("state") + .filter(|s| !s.is_empty()) + .cloned() + .ok_or_else(|| "Missing state parameter".to_string())?; + let issuer = params.get("iss").cloned(); + Ok(OAuthCallbackPayload { + code, + state, + issuer, + }) +} + +/// Loopback OAuth callback server. Returns (server task, oneshot for payload). +/// Caller must abort the server task. #[allow(clippy::type_complexity)] fn start_oauth_callback_server( listener: tokio::net::TcpListener, ) -> ( tokio::task::JoinHandle<()>, - oneshot::Receiver>, + oneshot::Receiver>, ) { use axum::{Router, extract::Query, response::Html, routing::get}; - let (tx, rx) = oneshot::channel::>(); + let (tx, rx) = oneshot::channel::>(); let tx = Arc::new(tokio::sync::Mutex::new(Some(tx))); let handler = { @@ -467,19 +504,7 @@ fn start_oauth_callback_server( move |Query(params): Query>| { let tx = tx.clone(); async move { - let result = if let Some(error) = params.get("error") { - let desc = params - .get("error_description") - .cloned() - .unwrap_or_else(|| "Unknown error".to_string()); - Err(format!("OAuth error: {error} - {desc}")) - } else { - match (params.get("code"), params.get("state")) { - (Some(code), Some(state)) => Ok((code.clone(), state.clone())), - (None, _) => Err("Missing authorization code".to_string()), - (_, None) => Err("Missing state parameter".to_string()), - } - }; + let result = parse_oauth_callback_params(¶ms); let html = match &result { Ok(_) => { @@ -520,3 +545,164 @@ fn start_oauth_callback_server( (server, rx) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::rmcp::transport::auth::{ + AuthorizationManager, AuthorizationMetadata, OAuthClientConfig, + }; + + const TEST_ISSUER: &str = "https://auth.example.com"; + + fn params(pairs: &[(&str, &str)]) -> HashMap { + pairs + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect() + } + + #[test] + fn callback_parses_code_state_and_rfc9207_iss() { + let p = params(&[ + ("code", "auth-code"), + ("state", "csrf"), + ("iss", TEST_ISSUER), + ]); + let got = parse_oauth_callback_params(&p).unwrap(); + assert_eq!(got.code, "auth-code"); + assert_eq!(got.state, "csrf"); + assert_eq!(got.issuer.as_deref(), Some(TEST_ISSUER)); + } + + #[test] + fn callback_issuer_optional_for_legacy_servers() { + let p = params(&[("code", "c"), ("state", "s")]); + let got = parse_oauth_callback_params(&p).unwrap(); + assert!(got.issuer.is_none()); + } + + #[test] + fn callback_requires_code_and_state() { + assert!(parse_oauth_callback_params(¶ms(&[("state", "s")])).is_err()); + assert!(parse_oauth_callback_params(¶ms(&[("code", "c")])).is_err()); + } + + #[test] + fn callback_surfaces_oauth_error() { + let p = params(&[ + ("error", "access_denied"), + ("error_description", "user said no"), + ]); + let err = parse_oauth_callback_params(&p).unwrap_err(); + assert!(err.contains("access_denied")); + assert!(err.contains("user said no")); + } + + fn require_iss_metadata(token_endpoint: String) -> AuthorizationMetadata { + // non_exhaustive: build via Default. + let mut meta = AuthorizationMetadata::default(); + meta.authorization_endpoint = "https://auth.example.com/authorize".to_string(); + meta.token_endpoint = token_endpoint; + meta.issuer = Some(TEST_ISSUER.to_string()); + meta.additional_fields.insert( + "authorization_response_iss_parameter_supported".to_string(), + serde_json::json!(true), + ); + meta + } + + async fn manager_ready_for_exchange(token_endpoint: String) -> (AuthorizationManager, String) { + let mut mgr = AuthorizationManager::new("http://localhost/mcp") + .await + .unwrap(); + mgr.set_metadata(require_iss_metadata(token_endpoint)); + mgr.configure_client( + OAuthClientConfig::new("grok-test-client", "http://127.0.0.1:0/callback") + .with_application_type("native"), + ) + .unwrap(); + let auth_url = mgr.get_authorization_url(&[]).await.unwrap(); + let state = url::Url::parse(&auth_url) + .unwrap() + .query_pairs() + .find(|(k, _)| k == "state") + .expect("auth URL must include state") + .1 + .into_owned(); + (mgr, state) + } + + async fn start_mock_token_endpoint() -> String { + use axum::{Router, body::Body, http::Response, routing::post}; + let app = Router::new().route( + "/token", + post(|| async { + Response::builder() + .status(200) + .header("content-type", "application/json") + .body(Body::from( + r#"{"access_token":"at-ok","token_type":"Bearer","expires_in":3600,"refresh_token":"rt-ok"}"#, + )) + .unwrap() + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + format!("http://{addr}/token") + } + + #[tokio::test] + async fn after_fix_passes_iss_and_token_exchange_succeeds() { + let token_ep = start_mock_token_endpoint().await; + let (mgr, state) = manager_ready_for_exchange(token_ep).await; + + let callback = parse_oauth_callback_params(¶ms(&[ + ("code", "auth-code"), + ("state", &state), + ("iss", TEST_ISSUER), + ])) + .unwrap(); + + let token = mgr + .exchange_code_for_token_with_issuer( + &callback.code, + &callback.state, + callback.issuer.as_deref(), + ) + .await + .expect("with_issuer must succeed when callback iss matches AS"); + + use oauth2::TokenResponse as _; + assert_eq!(token.access_token().secret(), "at-ok"); + } + + #[tokio::test] + async fn callback_http_server_forwards_iss_query_param() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let (server, rx) = start_oauth_callback_server(listener); + + let url = format!( + "http://{addr}/callback?code=c1&state=s1&iss={}", + urlencoding_encode(TEST_ISSUER) + ); + let resp = reqwest::get(&url).await.unwrap(); + assert!(resp.status().is_success()); + let body = resp.text().await.unwrap(); + assert!(body.contains("Authorization Complete")); + + let payload = rx.await.unwrap().unwrap(); + assert_eq!(payload.code, "c1"); + assert_eq!(payload.state, "s1"); + assert_eq!(payload.issuer.as_deref(), Some(TEST_ISSUER)); + server.abort(); + } + + fn urlencoding_encode(s: &str) -> String { + s.replace(':', "%3A").replace('/', "%2F") + } +} diff --git a/crates/codegen/xai-grok-mcp/src/servers.rs b/crates/codegen/xai-grok-mcp/src/servers.rs index 3935af1..c0ebe1f 100644 --- a/crates/codegen/xai-grok-mcp/src/servers.rs +++ b/crates/codegen/xai-grok-mcp/src/servers.rs @@ -1044,15 +1044,31 @@ pub fn parse_mcp_meta_config( /// here so existing call sites continue to work. pub use xai_grok_telemetry::enums::McpInitStrategy; -/// Parse MCP tool name in format "server__tool" -/// Returns (server_name, tool_name) if valid MCP tool, None otherwise -pub fn parse_mcp_tool_name(name: &str) -> Option<(String, String)> { - let parts: Vec<&str> = name.splitn(2, MCP_TOOL_NAME_DELIMITER).collect(); - if parts.len() == 2 { - Some((parts[0].to_string(), parts[1].to_string())) - } else { - None +/// Parse a non-empty `server__tool` ID with one overlap-aware delimiter and +/// valid [`xai_tool_protocol::ToolId`] syntax. +pub fn parse_mcp_qualified_name(name: &str) -> Option<(xai_tool_protocol::ToolId, &str, &str)> { + let delimiter = MCP_TOOL_NAME_DELIMITER.as_bytes(); + // Byte windows preserve both overlapping `__` boundaries in `___`. + let mut boundaries = name + .as_bytes() + .windows(delimiter.len()) + .enumerate() + .filter_map(|(index, window)| (window == delimiter).then_some(index)); + let boundary = boundaries.next()?; + if boundaries.next().is_some() { + return None; } + let (server, tool_with_delimiter) = name.split_at(boundary); + let tool = &tool_with_delimiter[MCP_TOOL_NAME_DELIMITER.len()..]; + if server.is_empty() || tool.is_empty() { + return None; + } + Some((xai_tool_protocol::ToolId::new(name).ok()?, server, tool)) +} + +/// Parse an MCP tool name in `server__tool` format into owned segments. +pub fn parse_mcp_tool_name(name: &str) -> Option<(String, String)> { + parse_mcp_qualified_name(name).map(|(_, server, tool)| (server.to_owned(), tool.to_owned())) } #[derive(Debug, thiserror::Error)] @@ -1237,36 +1253,24 @@ impl McpTool { /// Convert into the data needed for `ToolBridge::register_erased()`. /// - /// Returns `None` if the tool name is invalid (doesn't match LLM API requirements). - /// Invalid tools are logged and skipped — fix the upstream connector. - /// - /// Also rejects qualified names that contain the delimiter - /// (`MCP_TOOL_NAME_DELIMITER`) more than once. The underlying tool-name - /// regex permits underscores in each segment, so a server like - /// `"foo__bar"`, a tool like `"my__thing"`, or even a `"foo_"`/`"_bar"` - /// pair (which concatenates to `"foo___bar"` — two valid `__` - /// positions) would produce a qualified name that downstream - /// `split_once("__")` consumers would split at the wrong boundary. - /// The "exactly one delimiter" check covers all three cases with a - /// single rule. + /// Invalid or ambiguous qualified IDs and provider-invalid names are logged + /// and skipped; the upstream connector must provide non-empty `server` and + /// `tool` segments separated by exactly one `__` boundary. pub fn into_registration(self) -> Option { - // Qualify MCP tool name with server name: "server__tool" let qualified_name = format!( "{}{}{}", self.server_name, MCP_TOOL_NAME_DELIMITER, self.name ); - // Reject ambiguous qualified names — see doc-comment above. - if qualified_name.matches(MCP_TOOL_NAME_DELIMITER).count() != 1 { + if parse_mcp_qualified_name(&qualified_name).is_none() { tracing::error!( server = %self.server_name, tool = %self.name, qualified = %qualified_name, - "Skipping MCP tool: qualified name contains '{MCP_TOOL_NAME_DELIMITER}' more than once (server, tool, or their boundary collides with the reserved delimiter)" + "Skipping MCP tool with invalid or ambiguous qualified name" ); return None; } - if let Err(reason) = validate_tool_name(&qualified_name) { tracing::error!( tool_name = %qualified_name, @@ -5720,28 +5724,83 @@ mod tests { } #[test] - fn into_registration_accepts_well_formed_segments() { - // Positive guard: the count check rejects `__`-anywhere-but-the-delimiter - // names but must not reject legitimate ones. If the rejection rule is - // ever tightened too far, this test breaks before any of the negative - // cases below. - let tool = make_mcp_tool("linear", "list_issues"); - let reg = tool.into_registration().expect("should register"); - assert_eq!(reg.name, "linear__list_issues"); + fn qualified_mcp_name_parser_accepts_structurally_valid_tool_ids() { + for (name, expected) in [ + ("linear__list_issues", ("linear", "list_issues")), + ("123__lookup", ("123", "lookup")), + ("server:scope__tool", ("server:scope", "tool")), + ] { + let (id, server, tool) = parse_mcp_qualified_name(name).expect("valid qualified ID"); + assert_eq!(id.as_str(), name); + assert_eq!((server, tool), expected); + assert_eq!( + parse_mcp_tool_name(name), + Some((expected.0.to_owned(), expected.1.to_owned())) + ); + } } #[test] - fn into_registration_rejects_boundary_ambiguity() { - // `"foo_"` + `"__"` + `"_bar"` => `"foo___bar"` has two valid - // `__` positions (indices 3 and 4), so `split_once("__")` would - // misparse it as `("foo", "_bar")` and silently auto-allow a - // future legitimate `"foo"` server. The naïve per-segment check - // (each side individually has no `__`) misses this — the count - // check catches it. Same rule also rejects "__-in-segment" cases - // (`"weird__server"` + `"list"`, `"linear"` + `"my__weird__tool"`) - // which are covered by the same `count() != 1` line of code. - let tool = make_mcp_tool("foo_", "_bar"); - assert!(tool.into_registration().is_none()); + fn qualified_mcp_name_parser_rejects_malformed_names() { + for name in [ + "server__part__tool", + "server__tool__part", + "foo___bar", + "foo____bar", + "__tool", + "server__", + "server", + "", + "server__bad.tool", + ] { + assert!( + parse_mcp_qualified_name(name).is_none(), + "unexpectedly accepted {name:?}" + ); + } + } + + #[test] + fn into_registration_validates_qualified_name() { + let registration = make_mcp_tool("linear", "list_issues") + .into_registration() + .expect("should register"); + assert_eq!(registration.name, "linear__list_issues"); + + for (server, tool) in [ + ("server__part", "tool"), + ("server", "tool__part"), + ("foo_", "bar"), + ("foo", "_bar"), + ("foo_", "_bar"), + ("", "tool"), + ("server", ""), + ] { + assert!( + make_mcp_tool(server, tool).into_registration().is_none(), + "unexpectedly registered {server:?} and {tool:?}" + ); + } + } + + #[test] + fn into_registration_preserves_provider_name_policy() { + for qualified in ["123__lookup", "server:scope__tool"] { + assert!(parse_mcp_qualified_name(qualified).is_some()); + let (server, tool) = qualified.split_once("__").unwrap(); + assert!(make_mcp_tool(server, tool).into_registration().is_none()); + } + + let server_61 = format!("a{}", "b".repeat(60)); + let server_62 = format!("a{}", "b".repeat(61)); + let valid_64 = format!("{server_61}__b"); + let invalid_65 = format!("{server_62}__b"); + assert_eq!(valid_64.len(), 64); + assert_eq!(invalid_65.len(), 65); + assert!(parse_mcp_qualified_name(&valid_64).is_some()); + assert!(parse_mcp_qualified_name(&invalid_65).is_some()); + assert!(make_mcp_tool(&server_61, "b").into_registration().is_some()); + assert!(make_mcp_tool(&server_62, "b").into_registration().is_none()); } // ── is_retriable_transport_error tests ─────────────────────────── diff --git a/crates/codegen/xai-grok-models/default_models.json b/crates/codegen/xai-grok-models/default_models.json index 74e9ba7..7fcbeab 100644 --- a/crates/codegen/xai-grok-models/default_models.json +++ b/crates/codegen/xai-grok-models/default_models.json @@ -1,18 +1,42 @@ { - "default": "grok-build", + "default": "grok-4.5", "web_search": "grok-4.20-multi-agent", - "image_description": "grok-build", - "session_summary": "grok-build", + "image_description": "grok-4.5", + "session_summary": "grok-4.5", "models": [ { - "model": "grok-build", - "name": "Grok Build", - "description": "Best for advanced coding tasks", + "id": "grok-4.5", + "model": "grok-4.5", + "name": "Grok 4.5", + "description": "SpaceXAI's new frontier model", "context_window": 500000, - "temperature": 0.7, - "top_p": 0.95, "api_backend": "responses", - "supported_in_api": false + "supports_backend_search": false, + "system_prompt_label": "Grok 4.5", + "show_model_fingerprint": true, + "supports_reasoning_effort": true, + "reasoning_effort": "high", + "auto_compact_threshold_percent": 80, + "compaction_at_tokens": true, + "compactions_remaining": 1, + "reasoning_efforts": [ + { + "value": "high", + "label": "High Effort", + "description": "Highest implementation quality with extensive reasoning", + "default": true + }, + { + "value": "medium", + "label": "Medium Effort", + "description": "Balanced effort with standard implementation and testing" + }, + { + "value": "low", + "label": "Low Effort", + "description": "Quick, fast implementations" + } + ] } ] } diff --git a/crates/codegen/xai-grok-pager-bin/Cargo.toml b/crates/codegen/xai-grok-pager-bin/Cargo.toml index 06e359a..a1ddc08 100644 --- a/crates/codegen/xai-grok-pager-bin/Cargo.toml +++ b/crates/codegen/xai-grok-pager-bin/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "xai-grok-pager-bin" -version = "0.2.102" +version = "0.2.105" edition.workspace = true license = "Apache-2.0" authors = ["xAI"] diff --git a/crates/codegen/xai-grok-pager-bin/src/main.rs b/crates/codegen/xai-grok-pager-bin/src/main.rs index de17b9a..8f28eed 100644 --- a/crates/codegen/xai-grok-pager-bin/src/main.rs +++ b/crates/codegen/xai-grok-pager-bin/src/main.rs @@ -950,6 +950,41 @@ fn shutdown_and_flush_telemetry(exit_code: i32) -> ! { xai_grok_telemetry::debug_log::flush(); std::process::exit(exit_code); } +async fn forward_stdio_line_to_leader( + line: Vec, + leader_tx: &tokio::sync::Mutex>, + replay_state: &std::sync::Mutex, + cancel: &CancellationToken, +) { + let line = String::from_utf8_lossy(&line); + let mut trimmed = line.trim_end_matches(['\r', '\n']).to_string(); + if trimmed.is_empty() { + return; + } + if trimmed.contains("\"initialize\"") + || trimmed.contains("\"session/load\"") + || trimmed.contains("\"session/new\"") + { + cache_outgoing_acp_state(&trimmed, replay_state); + } + let send_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(300); + loop { + { + let tx = leader_tx.lock().await; + match tx.send(trimmed) { + Ok(()) => break, + Err(tokio::sync::mpsc::error::SendError(v)) => trimmed = v, + } + } + if cancel.is_cancelled() || tokio::time::Instant::now() >= send_deadline { + tracing::error!( + "stdio bridge: dropping client message after reconnect retries were exhausted" + ); + break; + } + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + } +} /// Emitted by both leader guards (server mode and leader-connect) so the two sites /// can't drift. const PLUGIN_DIR_LEADER_WARNING: &str = "grok: --plugin-dir is ignored in leader mode; run with --no-leader to \ @@ -967,16 +1002,11 @@ async fn run_agent_command( #[cfg(unix)] { use tokio::signal::unix::{SignalKind, signal}; + use xai_grok_pager::app::signal_handler::next_signal_code; let mut term = signal(SignalKind::terminate()).ok(); let mut hup = signal(SignalKind::hangup()).ok(); - tokio::select! { - _ = tokio::signal::ctrl_c() => { shutdown_and_flush_telemetry(130); } _ = - async { if let Some(sig) = term.as_mut() { let _ = sig.recv(). await; } - else { std::future::pending::< () > (). await; } } => { - shutdown_and_flush_telemetry(143); } _ = async { if let Some(sig) = hup - .as_mut() { let _ = sig.recv(). await; } else { std::future::pending::< - () > (). await; } } => { shutdown_and_flush_telemetry(129); } - } + let code = next_signal_code(&mut term, &mut hup).await; + shutdown_and_flush_telemetry(code); } #[cfg(not(unix))] { @@ -1061,7 +1091,6 @@ async fn run_agent_command( agent_config.resolve_runtime_fields(&xai_grok_shell::agent::config::RuntimeResolutionContext { raw_config: &raw_config, remote_settings: remote_settings.as_ref(), - cwd: None, is_headless: !is_leader, cli_subagents: None, cli_web_search_model: None, @@ -1164,23 +1193,9 @@ async fn run_agent_command( tokio::select! { biased; _ = cancel_stdin.cancelled() => break, maybe_line = stdin_lines.recv() => { let Some(line) = maybe_line else { - break }; let line = String::from_utf8_lossy(& line); let - trimmed = line.trim_end_matches(['\r', '\n']).to_string(); if - trimmed.is_empty() { continue; } if trimmed - .contains("\"initialize\"") || trimmed - .contains("\"session/load\"") || trimmed - .contains("\"session/new\"") { cache_outgoing_acp_state(& - trimmed, & replay_state_stdin); } let send_deadline = - tokio::time::Instant::now() + - std::time::Duration::from_secs(300); loop { { let tx = - leader_tx_stdin.lock(). await; if tx.send(trimmed.clone()) - .is_ok() { break; } } if cancel_stdin.is_cancelled() || - tokio::time::Instant::now() >= send_deadline { - tracing::error!("stdio bridge: dropping client message after \ - reconnect retries were exhausted"); - break; } - tokio::time::sleep(std::time::Duration::from_millis(250)). - await; } } + break }; forward_stdio_line_to_leader(line, & + leader_tx_stdin, & replay_state_stdin, & cancel_stdin,). + await; } } } }); diff --git a/crates/codegen/xai-grok-pager-minimal/src/commit.rs b/crates/codegen/xai-grok-pager-minimal/src/commit.rs index 6cd738e..e6af70f 100644 --- a/crates/codegen/xai-grok-pager-minimal/src/commit.rs +++ b/crates/codegen/xai-grok-pager-minimal/src/commit.rs @@ -884,6 +884,42 @@ mod tests { assert_eq!(commit_collect(&mut s), vec![1]); } + #[test] + fn btw_block_emits_once_across_repeated_frontier_passes() { + let mut s = ScrollbackState::new(); + s.push(ScrollbackEntry::new(RenderBlock::Btw( + xai_grok_pager::scrollback::blocks::BtwBlock::new( + "original question", + "original answer", + ), + ))); + + let mut emitted = Vec::new(); + assert_eq!( + commit_leading_run(&mut s, false, |state, i| { + let RenderBlock::Btw(block) = &state.get(i).unwrap().block else { + panic!("expected Btw block") + }; + assert_eq!(block.question, "original question"); + assert_eq!(block.content().text(), "original answer"); + emitted.push(i); + true + }), + 1 + ); + assert!(minimal_api::is_committed(&s, s.get(0).unwrap())); + + assert_eq!( + commit_leading_run(&mut s, false, |_, i| { + emitted.push(i); + true + }), + 0 + ); + assert_eq!(emitted, vec![0]); + assert!(!scan_frontier(&s, false).will_commit); + } + #[test] fn commit_leading_run_advances_frontier_and_marks_committed_once() { let mut s = ScrollbackState::new(); diff --git a/crates/codegen/xai-grok-pager-minimal/src/lib.rs b/crates/codegen/xai-grok-pager-minimal/src/lib.rs index 89963ea..27f1e58 100644 --- a/crates/codegen/xai-grok-pager-minimal/src/lib.rs +++ b/crates/codegen/xai-grok-pager-minimal/src/lib.rs @@ -9,7 +9,7 @@ //! //! - [`commit`] — committed-frontier logic, display policy, and the per-frame //! commit-to-scrollback pass. -//! - [`live`] — the pinned live region (tail + status + prompt). +//! - [`live`] — the pinned live region (tail + todos + `/btw` + status + prompt). //! - [`todo`] — the persistent todo panel shown above the prompt. //! - [`auth`] — the in-region sign-in flow shown before a session exists. //! - [`overlay`] — the inline-overlay host (prompt-anchored dropdowns; grows / diff --git a/crates/codegen/xai-grok-pager-minimal/src/live.rs b/crates/codegen/xai-grok-pager-minimal/src/live.rs index 9a6767c..b552c46 100644 --- a/crates/codegen/xai-grok-pager-minimal/src/live.rs +++ b/crates/codegen/xai-grok-pager-minimal/src/live.rs @@ -1,11 +1,12 @@ //! Minimal-mode live region: the small pinned viewport holding the running-turn -//! tail (model B), a one-line status indicator, and the always-focused prompt. +//! tail (model B), optional todos / `/btw` panels, a one-line status indicator, +//! and the always-focused prompt. //! -//! Layout (top → bottom): live tail · status · prompt. The tail shows the -//! bottom of the uncommitted run (streaming message / running tool) so output -//! is visible as it generates; finished blocks scroll up into native scrollback -//! via [`super::commit`]. When idle the tail is empty and only status + prompt -//! show. +//! Layout (top → bottom): live tail · todos · `/btw` · status · prompt · +//! overlay/info. The tail shows the bottom of the uncommitted run (streaming +//! message / running tool) so output is visible as it generates; finished blocks +//! scroll up into native scrollback via [`super::commit`]. When idle the tail is +//! empty and only status + prompt (+ optional panels) show. use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::{Color, Style}; @@ -42,6 +43,22 @@ fn inset_left(area: Rect, inset: u16) -> Rect { ..area } } +/// Drop cached `/btw` geometry so minimal input cannot scroll an invisible +/// panel after a modal host path skipped painting it. +fn clear_btw_geometry(agent: &mut xai_grok_pager::app::agent_view::AgentView) { + agent.last_btw_selection_model = + xai_grok_pager::scrollback::text_selection::ResolvedSelectionModel::default(); + agent.last_btw_area = Rect::default(); +} +/// Keep a paintable `/btw` area only when it is wholly inside the frame buffer. +fn paintable_btw_area(frame_area: Rect, area: Rect) -> Option { + (minimal_api::minimal_btw_geometry_is_paintable(area) + && area.x >= frame_area.x + && area.y >= frame_area.y + && area.x.saturating_add(area.width) <= frame_area.x.saturating_add(frame_area.width) + && area.y.saturating_add(area.height) <= frame_area.y.saturating_add(frame_area.height)) + .then_some(area) +} /// The prompt style used by the minimal live region. /// /// Shared with [`super::overlay::sync_viewport`] so viewport sizing measures the @@ -102,6 +119,11 @@ pub fn draw_live(app: &mut AppView, terminal: &mut PagerTerminal) { let row_inset = live_left_inset(appearance); let layout_cfg = &appearance.scrollback.layout; let term_h = terminal.last_known_area().height; + if let Some(id) = agent_id + && let Some(agent) = agents.get_mut(&id) + { + clear_btw_geometry(agent); + } xai_grok_pager::render::draw::draw_frame(terminal, cursor, |frame, _link_spans| { let area = frame.area(); if area.height == 0 || area.width < 4 { @@ -113,6 +135,7 @@ pub fn draw_live(app: &mut AppView, terminal: &mut PagerTerminal) { crate::auth::render_auth(frame.buffer_mut(), area, &theme, &auth_hint); return (None, None); }; + agent.active_pane = xai_grok_pager::app::agent_view::AgentPane::Prompt; let status_activity = minimal_advance_phase_timer(agent); let show_todos = crate::todo::todo_panel_visible(agent, force_todos); let queued = agent.session.pending_prompts.len() + agent.shared_queue.len(); @@ -212,19 +235,30 @@ pub fn draw_live(app: &mut AppView, terminal: &mut PagerTerminal) { .min(avail) .max(1); let rest = avail.saturating_sub(prompt_h); - let todos_cap = if force_todos { - rest + let raw_btw = if minimal_api::minimal_btw_surface_available(agent) { + xai_grok_pager::views::btw_overlay::btw_panel_height( + agent.btw_state.as_ref(), + area.width, + ) } else { - rest.min(crate::todo::MAX_TODO_ROWS) + 0 + }; + let btw_desired = minimal_api::minimal_btw_visible_height(raw_btw, area.width, rest); + let after_btw = rest.saturating_sub(btw_desired); + let todos_cap = if force_todos { + after_btw + } else { + after_btw.min(crate::todo::MAX_TODO_ROWS) }; let todo_lines = if show_todos { crate::todo::todo_panel_lines(agent, todos_cap, force_todos) } else { Vec::new() }; - let todos_h = (todo_lines.len() as u16).min(rest); - let tail_h = rest.saturating_sub(todos_h); - let tick = (now_millis() / 100) as u64; + let todos_h = (todo_lines.len() as u16).min(after_btw); + let btw_h = btw_desired; + let tail_h = rest.saturating_sub(todos_h + btw_h); + let tick = agent.scrollback.animation_tick(); if tail_h > 0 { let tail_area = Rect { x: area.x, @@ -260,10 +294,34 @@ pub fn draw_live(app: &mut AppView, terminal: &mut PagerTerminal) { &todo_lines, ); } + let btw_area = paintable_btw_area( + area, + Rect { + x: area.x, + y: area.y.saturating_add(tail_h).saturating_add(todos_h), + width: area.width, + height: btw_h, + }, + ); + if let (Some(btw), Some(btw_area)) = (agent.btw_state.as_ref(), btw_area) { + let focused = minimal_api::btw_focused(agent); + xai_grok_pager::views::btw_overlay::render_btw_panel( + frame.buffer_mut(), + btw, + btw_area, + tick, + focused, + None, + &mut agent.last_btw_selection_model, + None, + &[], + ); + agent.last_btw_area = btw_area; + } let status_area = inset_left( Rect { x: area.x, - y: area.y + tail_h + todos_h, + y: area.y + tail_h + todos_h + btw_h, width: area.width, height: status_h, }, @@ -279,7 +337,7 @@ pub fn draw_live(app: &mut AppView, terminal: &mut PagerTerminal) { ); let prompt_area = Rect { x: area.x, - y: area.y + tail_h + todos_h + status_h, + y: area.y + tail_h + todos_h + btw_h + status_h, width: area.width, height: prompt_h, }; @@ -421,29 +479,6 @@ fn draw_tail( } } } -/// Count idle-surviving "watchers" — running monitors, active scheduled -/// `/loop` tasks, and running (background) subagents — so the shared turn-status -/// widget can show the persistent "watching · N monitors · M loops · K -/// subagents" cue while the agent is idle. Mirrors the full-TUI computation in -/// `AgentView::draw` (which minimal bypasses). -fn minimal_watchers(agent: &xai_grok_pager::app::agent_view::AgentView) -> turn_status::Watchers { - turn_status::Watchers { - monitors: agent - .session - .bg_tasks - .values() - .filter(|t| { - t.is_monitor && t.status == xai_grok_pager::app::agent::BgTaskStatus::Running - }) - .count(), - loops: agent.session.scheduled_tasks.len(), - subagents: agent - .subagent_sessions - .values() - .filter(|s| s.is_running()) - .count(), - } -} /// Resolve the current turn activity and advance the phase timer when it /// changes. The full TUI runs this inside its own `draw` (reset /// `activity_started_at` on every phase transition); minimal has a separate @@ -464,11 +499,12 @@ fn minimal_advance_phase_timer( /// Reuses the full-TUI [`turn_status::render_turn_status`] widget so minimal /// surfaces the same rich activity detail (`Run …` / `Thinking…` / /// `Waiting on subagent…` / `Retrying (attempt N)…` / `Cancelling…`), the -/// per-phase + turn timers, and the idle "watching · …" cue (running monitors / -/// loops / background subagents) — instead of collapsing everything to -/// "working…". Keyboard-only, so the mouse `[stop]` / `[↓]` buttons are -/// suppressed (`None`), and `flat_background` keeps the row transparent like the -/// rest of the live region. When the widget would draw nothing (plain idle, no +/// per-phase + turn timers, and the "watching · …" cue (running commands / +/// monitors / loops / background subagents, shown while idle or parked) — +/// instead of collapsing everything to "working…". Keyboard-only, so the +/// mouse `[stop]` / `[↓]` buttons are suppressed (`None`), and +/// `flat_background` keeps the row transparent like the rest of the live +/// region. When the widget would draw nothing (plain idle or parked, no /// watchers) a small `minimal · /help` hint is shown instead. fn render_minimal_status( buf: &mut Buffer, @@ -492,16 +528,16 @@ fn render_minimal_status( ); return; } - let watchers = minimal_watchers(agent); + let watchers = minimal_api::watchers(agent); let drain_blocked = minimal_api::drain_blocked(agent); - if minimal_api::renders_parked(agent) - || !turn_status::should_show( - &agent.session.state, - drain_blocked, - minimal_api::mcp_init_progress(agent), - watchers, - ) - { + let parked = minimal_api::renders_parked(agent); + if !turn_status::should_show( + &agent.session.state, + drain_blocked, + minimal_api::mcp_init_progress(agent), + watchers, + parked, + ) { render_idle_hint(buf, area, theme); return; } @@ -528,6 +564,7 @@ fn render_minimal_status( is_pending_user_input, goal_verifying, watchers, + parked, true, minimal_api::held_queue_count(agent), minimal_api::held_queue_top_sendable(agent), @@ -707,6 +744,21 @@ mod tests { minimal_api::test_agent_view(Some("s1"), std::path::PathBuf::from("/tmp")) } #[test] + fn btw_area_must_be_fully_paintable() { + let frame = Rect::new(0, 0, 80, 20); + assert_eq!( + paintable_btw_area(frame, Rect::new(0, 4, 80, 3)), + Some(Rect::new(0, 4, 80, 3)) + ); + assert!(!minimal_api::minimal_btw_size_is_paintable(11, 3)); + assert!(minimal_api::minimal_btw_size_is_paintable(12, 3)); + assert!(!minimal_api::minimal_btw_size_is_paintable(80, 2)); + assert!(paintable_btw_area(frame, Rect::new(0, 4, 11, 3)).is_none()); + assert!(paintable_btw_area(frame, Rect::new(0, 4, 80, 2)).is_none()); + assert!(paintable_btw_area(frame, Rect::new(0, 19, 80, 3)).is_none()); + assert!(paintable_btw_area(frame, Rect::new(79, 4, 2, 3)).is_none()); + } + #[test] fn tail_height_uses_owning_session_cwd_for_tool_paths() { use xai_grok_pager::app::agent::AgentState; use xai_grok_pager::scrollback::RenderBlock; @@ -826,7 +878,7 @@ mod tests { tag: "loop".to_string(), }, ); - assert_eq!(minimal_watchers(&a).loops, 1); + assert_eq!(minimal_api::watchers(&a).loops, 1); let mut buf = Buffer::empty(area); render_minimal_status(&mut buf, area, &a, &None, None, &theme); let text = read(&buf); diff --git a/crates/codegen/xai-grok-pager-minimal/src/overlay.rs b/crates/codegen/xai-grok-pager-minimal/src/overlay.rs index 070fdb5..194ec78 100644 --- a/crates/codegen/xai-grok-pager-minimal/src/overlay.rs +++ b/crates/codegen/xai-grok-pager-minimal/src/overlay.rs @@ -293,27 +293,50 @@ fn compute_target(app: &mut AppView, term_h: u16, width: u16) -> u16 { .max(1); // Size the viewport to exactly its content — tail (uncommitted streaming - // output) + todo panel + status + overlay + prompt — so the prompt sits - // directly after the conversation with no gap, whether idle or mid-turn. - // When a turn is "thinking" the tail is empty, so the prompt stays right - // under the content instead of floating below a fixed empty region; as - // output streams the tail grows and the viewport grows downward with it. - // The region is not bottom-pinned, so the rest of the screen below stays - // empty (the app "owns" the window from the top down). + // output) + todo panel + /btw panel + status + overlay + prompt — so the + // prompt sits directly after the conversation with no gap, whether idle or + // mid-turn. When a turn is "thinking" the tail is empty, so the prompt + // stays right under the content instead of floating below a fixed empty + // region; as output streams the tail grows and the viewport grows downward + // with it. The region is not bottom-pinned, so the rest of the screen below + // stays empty (the app "owns" the window from the top down). let tail_h = super::live::tail_height(agent, width, &commit_app); let todos_h = super::todo::todo_panel_height(agent, force_todos); // Below the prompt sits either the dropdown overlay or the 1-row info bar // (model · context usage · turn time/tokens); reserve at least the info row // when no dropdown is open so it isn't clipped / doesn't scroll content. let below_h = overlay_h.max(1); - content_target(tail_h, todos_h, below_h, prompt_h, ceiling) + // `/btw` is a non-blocking side panel above the status/prompt (same place + // as the full TUI). Height is measured at full viewport width so wrap + // matches `live::draw_live`. Only reserve rows the shared minimal paint + // policy accepts, otherwise a narrow or short terminal leaves a blank strip. + let raw_btw = if minimal_api::minimal_btw_surface_available(agent) { + xai_grok_pager::views::btw_overlay::btw_panel_height(agent.btw_state.as_ref(), width) + } else { + 0 + }; + let chrome = 1u16 // status row + .saturating_add(below_h) + .saturating_add(prompt_h); + let available = ceiling.saturating_sub(chrome); + let btw_h = minimal_api::minimal_btw_visible_height(raw_btw, width, available); + content_target(tail_h, todos_h, btw_h, below_h, prompt_h, ceiling) } -/// Live-viewport height sized to exactly its content: tail + todo panel + status -/// row + overlay + prompt. Floored at 2 (status + prompt), capped at the screen. -fn content_target(tail_h: u16, todos_h: u16, overlay_h: u16, prompt_h: u16, ceiling: u16) -> u16 { +/// Live-viewport height sized to exactly its content: tail + todo panel + /btw +/// panel + status row + overlay + prompt. Floored at 2 (status + prompt), capped +/// at the screen. +fn content_target( + tail_h: u16, + todos_h: u16, + btw_h: u16, + overlay_h: u16, + prompt_h: u16, + ceiling: u16, +) -> u16 { tail_h .saturating_add(todos_h) + .saturating_add(btw_h) .saturating_add(1) // status row .saturating_add(overlay_h) .saturating_add(prompt_h) @@ -990,18 +1013,24 @@ mod tests { #[test] fn content_target_fits_content_with_no_gap() { - // Viewport = tail + todos + status(1) + overlay + prompt — no base + // Viewport = tail + todos + btw + status(1) + overlay + prompt — no base // floor, so the prompt sits right after the conversation. Idle (tail 0, // empty prompt) is just status + prompt. - assert_eq!(content_target(0, 0, 0, 1, 40), 2); // status + 1-row prompt - assert_eq!(content_target(0, 3, 0, 1, 40), 5); // + 3 todo rows - assert_eq!(content_target(0, 3, 5, 2, 40), 11); // + overlay(5) + 2-row prompt + assert_eq!(content_target(0, 0, 0, 0, 1, 40), 2); // status + 1-row prompt + assert_eq!(content_target(0, 3, 0, 0, 1, 40), 5); // + 3 todo rows + assert_eq!(content_target(0, 3, 0, 5, 2, 40), 11); // + overlay(5) + 2-row prompt + // /btw Loading/Error is 3 rows; Done grows with the answer. + assert_eq!(content_target(0, 0, 3, 0, 1, 40), 5); // + btw(3) + // Production idle always reserves ≥1 below the prompt (info bar). + assert_eq!(content_target(0, 0, 3, 1, 1, 40), 6); // btw+status+info+prompt + // todos + btw stack without collapsing either. + assert_eq!(content_target(0, 3, 3, 0, 1, 40), 8); // The streaming tail grows the viewport (no fixed empty gap while // "thinking": tail 0 → just status + prompt). - assert_eq!(content_target(6, 0, 0, 1, 40), 8); // tail(6) + status + prompt + assert_eq!(content_target(6, 0, 0, 0, 1, 40), 8); // tail(6) + status + prompt // Floored at 2 (status + prompt) and capped at the screen ceiling. - assert_eq!(content_target(0, 0, 0, 0, 40), 2); - assert_eq!(content_target(50, 0, 0, 0, 20), 20); + assert_eq!(content_target(0, 0, 0, 0, 0, 40), 2); + assert_eq!(content_target(50, 0, 0, 0, 0, 20), 20); } #[test] @@ -1036,7 +1065,25 @@ mod tests { fn content_target_clamps_to_screen() { // Content taller than the screen clamps to the ceiling (then the tail // scrolls / clips); a tiny terminal still yields at least the floor. - assert_eq!(content_target(30, 0, 0, 1, 24), 24); - assert_eq!(content_target(5, 0, 0, 1, 2), 2); + assert_eq!(content_target(30, 0, 0, 0, 1, 24), 24); + assert_eq!(content_target(5, 0, 0, 0, 1, 2), 2); + // A tall /btw Done answer still clamps rather than overflowing. + assert_eq!(content_target(0, 0, 20, 0, 1, 10), 10); + } + + #[test] + fn btw_height_policy_matches_draw_live_boundaries() { + let visible = minimal_api::minimal_btw_visible_height; + assert_eq!(visible(3, 80, 40), 3); + assert_eq!(visible(12, 80, 40), 12); + assert_eq!(visible(20, 80, 10), 10); + assert_eq!(visible(0, 80, 40), 0); + // Width 11/12 and available rows 2/3 are the production boundary. + assert_eq!(visible(3, 11, 40), 0); + assert_eq!(visible(3, 12, 40), 3); + assert_eq!(visible(3, 80, 2), 0); + assert_eq!(visible(3, 80, 3), 3); + assert_eq!(content_target(0, 0, visible(3, 11, 40), 1, 1, 40), 3); + assert_eq!(content_target(0, 0, visible(3, 80, 2), 1, 1, 5), 3); } } diff --git a/crates/codegen/xai-grok-pager-minimal/src/panel.rs b/crates/codegen/xai-grok-pager-minimal/src/panel.rs index 0a77f4b..4a29163 100644 --- a/crates/codegen/xai-grok-pager-minimal/src/panel.rs +++ b/crates/codegen/xai-grok-pager-minimal/src/panel.rs @@ -172,7 +172,7 @@ fn resume_body_rows(agent: &AgentView, width: u16) -> u16 { let entries_data = entries.as_deref().unwrap_or(&[]); let content_width = width.saturating_sub(2); let filtered = - minimal_api::filter_session_entries(entries.as_deref(), &state.query, *source_filter); + minimal_api::filter_session_entries(entries.as_deref(), state.query(), *source_filter); let built = minimal_api::build_session_entry_data(entries_data, &filtered, state, content_width); let fields_vecs: Vec> = built @@ -217,7 +217,7 @@ fn render_resume( let entries_data = entries.as_deref().unwrap_or(&[]); let content_width = area.width.saturating_sub(2); let filtered = - minimal_api::filter_session_entries(entries.as_deref(), &state.query, *source_filter); + minimal_api::filter_session_entries(entries.as_deref(), state.query(), *source_filter); let built = minimal_api::build_session_entry_data(entries_data, &filtered, state, content_width); let fields_vecs: Vec> = built @@ -241,16 +241,17 @@ fn render_resume( render_title(buf, title_row, theme, "Resume session"); // Focus-aware search bar (cursor only when search is focused). - picker::render_search_bar( + minimal_api::render_picker_search_bar( buf, - search_row.x + 1, - search_row.y, - search_row.width.saturating_sub(1), + Rect::new( + search_row.x + 1, + search_row.y, + search_row.width.saturating_sub(1), + 1, + ), theme, - &state.query, - state.search_active, + state, true, - state.query_cursor, None, ); render_divider(buf, divider_row, theme); @@ -293,7 +294,7 @@ fn mcps_body_rows(agent: &AgentView) -> u16 { }; let rows = minimal_api::build_mcp_picker_rows( servers, - &s.picker_state.query, + s.picker_state.query(), s.mcps_filter, &s.mcps_collapsed_sections, &s.mcps_tools_expanded, @@ -324,14 +325,14 @@ fn render_mcps( let loading; { let s = minimal_api::extensions_modal(agent)?; - let searching = !s.picker_state.query.is_empty(); + let searching = !s.picker_state.query().is_empty(); loading = matches!(s.mcps_data, TabDataState::Loading); match &s.mcps_data { TabDataState::Loaded(servers) => { let (row_labels, row_group_keys, row_data_indices) = minimal_api::build_mcp_picker_rows( servers, - &s.picker_state.query, + s.picker_state.query(), s.mcps_filter, &s.mcps_collapsed_sections, &s.mcps_tools_expanded, @@ -677,6 +678,52 @@ mod tests { ); } + #[test] + fn resume_search_uses_picker_grapheme_viewport_at_narrow_width() { + let grapheme = "👩🏽\u{200d}💻"; + let combining = "e\u{301}"; + let mut agent = with_resume(vec![session_entry("match")]); + let Some(ActiveModal::SessionPicker { state, .. }) = &mut agent.active_modal else { + panic!("expected session picker"); + }; + state.set_query(format!("a{grapheme}{combining}")); + state.search_active = true; + + let theme = Theme::current(); + let area = Rect::new(0, 0, 14, 5); + let mut actual = Buffer::empty(area); + render(&mut actual, area, &mut agent, ListPanel::Resume, &theme); + + let Some(ActiveModal::SessionPicker { state, .. }) = &agent.active_modal else { + panic!("expected session picker"); + }; + let mut expected = Buffer::empty(area); + minimal_api::render_picker_search_bar( + &mut expected, + Rect::new(1, 1, 13, 1), + &theme, + state, + true, + None, + ); + for x in 1..14 { + let actual_cell = actual.cell((x, 1)).expect("actual search cell"); + let expected_cell = expected.cell((x, 1)).expect("expected search cell"); + assert_eq!(actual_cell.symbol(), expected_cell.symbol(), "column {x}"); + assert_eq!(actual_cell.style(), expected_cell.style(), "column {x}"); + } + let text = buffer_text(&actual); + assert!(text.contains(grapheme), "ZWJ grapheme was split: {text:?}"); + assert!( + text.contains(combining), + "combining grapheme was split: {text:?}" + ); + assert_eq!( + actual.cell((13, 1)).expect("cursor cell").bg, + theme.text_primary + ); + } + #[test] fn mcps_panel_height_is_chrome_plus_rows() { // One section header + 2 server rows = 3 body rows; + 4 chrome = 7. diff --git a/crates/codegen/xai-grok-pager-pty-harness/src/flows.rs b/crates/codegen/xai-grok-pager-pty-harness/src/flows.rs index 16212bf..c76b3d7 100644 --- a/crates/codegen/xai-grok-pager-pty-harness/src/flows.rs +++ b/crates/codegen/xai-grok-pager-pty-harness/src/flows.rs @@ -70,9 +70,13 @@ pub fn inference_request_count(content: &ContentController) -> usize { /// Seed a fake xAI OAuth entry into the isolated home's `auth.json` so the /// shell has session auth (the harness's `XAI_API_KEY` is ApiKey/BYOK mode /// and never enters the auth manager). Load-bearing details: the scope key -/// must be `::`, `auth_mode` must be `oidc`, and -/// `expires_at` must be far-future so no network refresh is attempted; the -/// mock server accepts any bearer. Pair with [`oauth_env_for_pager`]. +/// must be `::`, `auth_mode` must be `oidc`, +/// `expires_at` must be far-future so no network refresh is attempted, and +/// `coding_data_retention_opt_out` must be `false` so collection/upload-path +/// e2es (e.g. storage park-on-401) still enqueue traces — missing that field +/// now deserializes as opted-out via +/// `default_coding_data_retention_opt_out()`. The mock server accepts any +/// bearer. Pair with [`oauth_env_for_pager`]. pub fn seed_fake_oauth(content: &ContentController, user: &str) { let grok_home = content.home().join(".grok"); std::fs::create_dir_all(&grok_home).expect("create temp .grok"); @@ -89,7 +93,8 @@ pub fn seed_fake_oauth(content: &ContentController, user: &str) { "expires_at": "2030-01-01T00:00:00Z", "refresh_token": "pty-test-refresh-token", "oidc_issuer": "https://auth.x.ai", - "oidc_client_id": "b1a00492-073a-47ea-816f-4c329264a828" + "oidc_client_id": "b1a00492-073a-47ea-816f-4c329264a828", + "coding_data_retention_opt_out": false }} }}"# ), diff --git a/crates/codegen/xai-grok-pager-render/src/appearance/cache.rs b/crates/codegen/xai-grok-pager-render/src/appearance/cache.rs index 99ff2d0..ac27cf4 100644 --- a/crates/codegen/xai-grok-pager-render/src/appearance/cache.rs +++ b/crates/codegen/xai-grok-pager-render/src/appearance/cache.rs @@ -30,6 +30,7 @@ const TIMESTAMPS_DEFAULT: bool = true; /// [`UiConfig::SHOW_TIMELINE_DEFAULT`]; aliased here for the `Cell::new` /// const context and the effective-config fallback read. const TIMELINE_DEFAULT: bool = UiConfig::SHOW_TIMELINE_DEFAULT; +const PAGE_FLIP_ON_SEND_DEFAULT: bool = UiConfig::PAGE_FLIP_ON_SEND_DEFAULT; const SIMPLE_MODE_DEFAULT: bool = true; /// Vim-mode scrollback default — matches the previous on-disk default. const VIM_MODE_DEFAULT: bool = false; @@ -135,6 +136,34 @@ pub fn set_show_timeline(enabled: bool) { TIMELINE_LOADED.with(|l| l.set(true)); } +// -- Page-flip on send --------------------------------------------------------- + +thread_local! { + static PAGE_FLIP_ON_SEND_CURRENT: Cell = const { Cell::new(PAGE_FLIP_ON_SEND_DEFAULT) }; + static PAGE_FLIP_ON_SEND_LOADED: Cell = const { Cell::new(false) }; +} + +/// Cached `page_flip_on_send`, seeding from `[ui]` on first call. +pub fn load_page_flip_on_send() -> bool { + PAGE_FLIP_ON_SEND_LOADED.with(|loaded| { + if !loaded.get() { + PAGE_FLIP_ON_SEND_CURRENT.with(|c| { + c.set(load_bool_from_effective_config( + "page_flip_on_send", + PAGE_FLIP_ON_SEND_DEFAULT, + )) + }); + loaded.set(true); + } + }); + PAGE_FLIP_ON_SEND_CURRENT.with(|c| c.get()) +} + +pub fn set_page_flip_on_send(enabled: bool) { + PAGE_FLIP_ON_SEND_CURRENT.with(|c| c.set(enabled)); + PAGE_FLIP_ON_SEND_LOADED.with(|l| l.set(true)); +} + // -- Simple mode -------------------------------------------------------------- thread_local! { @@ -545,6 +574,7 @@ pub fn prime(ui: &UiConfig) { set(ui.compact_mode); set_timestamps(ui.show_timestamps.unwrap_or(TIMESTAMPS_DEFAULT)); set_show_timeline(ui.show_timeline_enabled()); + set_page_flip_on_send(ui.page_flip_on_send_enabled()); set_simple_mode(ui.simple_mode.unwrap_or(SIMPLE_MODE_DEFAULT)); set_keep_text_selection(text_selection_from_ui(ui)); // Layered-config keys (not the `UiConfig` arg) — seed so the first frame @@ -656,6 +686,7 @@ mod tests { assert_eq!(COMPACT_DEFAULT, ui.compact_mode); assert_eq!(TIMESTAMPS_DEFAULT, ui.show_timestamps.unwrap_or(true)); assert_eq!(TIMELINE_DEFAULT, ui.show_timeline_enabled()); + assert_eq!(PAGE_FLIP_ON_SEND_DEFAULT, ui.page_flip_on_send_enabled()); assert_eq!(SIMPLE_MODE_DEFAULT, ui.simple_mode.unwrap_or(true)); assert_eq!(VIM_MODE_DEFAULT, ui.vim_mode.unwrap_or(false)); assert_eq!( @@ -726,6 +757,18 @@ mod tests { .unwrap(); } + #[test] + fn set_then_load_round_trips_page_flip_on_send() { + std::thread::spawn(|| { + set_page_flip_on_send(true); + assert!(load_page_flip_on_send()); + set_page_flip_on_send(false); + assert!(!load_page_flip_on_send()); + }) + .join() + .unwrap(); + } + #[test] fn set_then_load_round_trips_simple_mode() { std::thread::spawn(|| { diff --git a/crates/codegen/xai-grok-pager-render/src/clipboard/mod.rs b/crates/codegen/xai-grok-pager-render/src/clipboard/mod.rs index 7517b10..24a06f3 100644 --- a/crates/codegen/xai-grok-pager-render/src/clipboard/mod.rs +++ b/crates/codegen/xai-grok-pager-render/src/clipboard/mod.rs @@ -8,7 +8,8 @@ mod trust; pub use trust::{ - ClipboardDelivery, NativeClipboardPreflight, expected_delivery, native_clipboard_preflight, + ClipboardDelivery, ClipboardEnvironment, NativeClipboardPreflight, Osc52Capability, + expected_delivery, native_clipboard_preflight, }; pub use xai_ratatui_textarea::{ClipboardProvider, InternalClipboard}; @@ -188,7 +189,7 @@ impl SystemClipboard { /// Full write route classified by the environment-based delivery policy. pub fn try_set(text: &str) -> ClipboardDelivery { let legs = clipboard_write_with_route(text, clipboard_route()); - decision_for_legs(&legs, text).delivery + decision_for_legs(&legs, text).delivery() } } @@ -301,6 +302,20 @@ pub(crate) enum ClipboardFeedback { } impl ClipboardFeedback { + pub(crate) fn delivery(self) -> ClipboardDelivery { + match self { + Self::Copied + | Self::CopiedTmux + | Self::CopiedOscContainer + | Self::CopiedOscRemote + | Self::VsCodeSshNonAscii => ClipboardDelivery::Confirmed, + Self::UnverifiedOscRemote | Self::UnverifiedOscContainer => { + ClipboardDelivery::Unverified + } + Self::FailedRemote | Self::Failed => ClipboardDelivery::Failed, + } + } + /// User-facing toast message for this kind. fn message(self) -> &'static str { match self { @@ -333,32 +348,30 @@ impl ClipboardFeedback { } } - fn to_result(self, delivery: ClipboardDelivery) -> CopyResult { + fn to_result(self) -> CopyResult { CopyResult { message: self.message(), ticks: self.ticks(), - delivery, + delivery: self.delivery(), } } } -fn decision_for_legs(legs: &ClipboardWriteLegs, text: &str) -> trust::ClipboardDecision { - let remote = is_remote(); - let container = is_container_no_display(); - let mut decision = trust::resolve_copy_decision( - legs, - text, - crate::terminal::terminal_context().brand, - crate::host::HostOs::current(), - crate::host::DisplayServer::current(), - remote, - container, - osc52_sink_active(), - ); - if decision.delivery == ClipboardDelivery::Failed && (remote || container) { - decision.feedback = ClipboardFeedback::FailedRemote; +fn clipboard_environment(legs: &ClipboardWriteLegs) -> ClipboardEnvironment { + ClipboardEnvironment { + brand: crate::terminal::terminal_context().brand, + host_os: crate::host::HostOs::current(), + display_server: crate::host::DisplayServer::current(), + remote: is_remote(), + container: is_container_no_display(), + osc52_sink: osc52_sink_active(), + wayland_data_control: legs.data_control, + wl_copy_available: legs.wl_copy_ok, } - decision +} + +fn decision_for_legs(legs: &ClipboardWriteLegs, text: &str) -> ClipboardFeedback { + trust::resolve_copy_decision(legs, text, clipboard_environment(legs)) } /// Write text and return a toast; emits `grok-shell-clipboard_copy` when enabled. @@ -366,17 +379,17 @@ pub fn copy_text(text: &str) -> CopyResult { let started = std::time::Instant::now(); let route = clipboard_route(); let legs = clipboard_write_with_route(text, route); - let decision = decision_for_legs(&legs, text); - if decision.delivery.is_failed() { + let feedback = decision_for_legs(&legs, text); + if feedback.delivery().is_failed() { tracing::warn!( len = text.len(), display_server = %crate::host::DisplayServer::current(), "clipboard write failed on all trusted backends" ); } - let result = decision.feedback.to_result(decision.delivery); - let toast_kind: &'static str = decision.feedback.into(); - log_clipboard_copy_event(text, route, &legs, decision, toast_kind, started); + let result = feedback.to_result(); + let toast_kind: &'static str = feedback.into(); + log_clipboard_copy_event(text, route, &legs, feedback, toast_kind, started); result } @@ -384,7 +397,7 @@ fn log_clipboard_copy_event( text: &str, route: &ClipboardRoute, legs: &ClipboardWriteLegs, - decision: trust::ClipboardDecision, + feedback: ClipboardFeedback, toast_kind: &'static str, started: std::time::Instant, ) { @@ -406,10 +419,10 @@ fn log_clipboard_copy_event( data_control: legs.data_control, tmux_ok: legs.tmux_ok, osc52_ok: legs.osc52_ok, - delivery: decision.delivery.telemetry_label(), + delivery: feedback.delivery().telemetry_label(), osc52_sink: osc52_sink_active(), container_no_display: is_container_no_display(), - reported_success: decision.delivery.reported_success(), + reported_success: feedback.delivery().reported_success(), toast_kind, duration_ms: started.elapsed().as_millis() as u64, }); @@ -1755,7 +1768,8 @@ mod tests { ), ]; for (feedback, delivery, message, telemetry, ticks) in cases { - let result = feedback.to_result(delivery); + let result = feedback.to_result(); + assert_eq!(feedback.delivery(), delivery); assert_eq!(feedback.message(), message); assert_eq!(Into::<&'static str>::into(feedback), telemetry); assert_eq!(result.message, message); @@ -1763,24 +1777,4 @@ mod tests { assert_eq!(result.delivery, delivery); } } - - #[test] - fn clipboard_fallbacks_are_short_and_actionable() { - for feedback in [ - ClipboardFeedback::UnverifiedOscRemote, - ClipboardFeedback::UnverifiedOscContainer, - ClipboardFeedback::FailedRemote, - ClipboardFeedback::Failed, - ] { - assert!(feedback.message().chars().count() + 4 < 80, "{feedback:?}"); - assert!(!feedback.message().contains("Shift"), "{feedback:?}"); - assert!(!feedback.message().contains("Fn"), "{feedback:?}"); - assert!(feedback.message().contains("/minimal"), "{feedback:?}"); - } - assert!( - ClipboardFeedback::UnverifiedOscRemote - .message() - .contains("grok wrap") - ); - } } diff --git a/crates/codegen/xai-grok-pager-render/src/clipboard/trust.rs b/crates/codegen/xai-grok-pager-render/src/clipboard/trust.rs index ca03dcd..1a57520 100644 --- a/crates/codegen/xai-grok-pager-render/src/clipboard/trust.rs +++ b/crates/codegen/xai-grok-pager-render/src/clipboard/trust.rs @@ -21,12 +21,16 @@ pub enum ClipboardDelivery { } impl ClipboardDelivery { + pub fn is_confirmed(self) -> bool { + self == Self::Confirmed + } + pub fn is_failed(self) -> bool { self == Self::Failed } pub fn reported_success(self) -> bool { - !self.is_failed() + matches!(self, Self::Confirmed | Self::Unverified) } pub fn telemetry_label(self) -> &'static str { @@ -34,6 +38,53 @@ impl ClipboardDelivery { } } +/// Clipboard-relevant facts about the terminal and host environment. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +#[doc(hidden)] +pub struct ClipboardEnvironment { + pub brand: TerminalName, + pub host_os: HostOs, + pub display_server: DisplayServer, + pub remote: bool, + pub container: bool, + pub osc52_sink: bool, + pub wayland_data_control: bool, + pub wl_copy_available: bool, +} + +/// The terminal's advertised OSC 52 clipboard capability. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +#[doc(hidden)] +pub enum Osc52Capability { + Supported, + Unsupported, + Unknown, +} + +impl Osc52Capability { + #[doc(hidden)] + pub fn label(self) -> &'static str { + match self { + Self::Supported => "supported", + Self::Unsupported => "unsupported", + Self::Unknown => "unknown", + } + } +} + +impl ClipboardEnvironment { + #[doc(hidden)] + pub fn osc52_capability(self) -> Osc52Capability { + if self.osc52_sink || self.brand.supports_osc52_clipboard() { + Osc52Capability::Supported + } else if self.brand == TerminalName::Unknown { + Osc52Capability::Unknown + } else { + Osc52Capability::Unsupported + } + } +} + /// Native clipboard route evidence available before a copy is attempted. #[derive(Debug, Clone, Copy, Eq, PartialEq)] pub enum NativeClipboardPreflight { @@ -50,23 +101,18 @@ fn trusted_wayland_native(wl_copy: bool, arboard: bool, data_control: bool) -> b /// Classify the configured native route without claiming that a write succeeded. pub fn native_clipboard_preflight( route_native: bool, - host_os: HostOs, - display_server: DisplayServer, - remote: bool, - container: bool, - wayland_data_control: bool, - wl_copy_available: bool, + environment: ClipboardEnvironment, ) -> NativeClipboardPreflight { if !route_native { return NativeClipboardPreflight::Disabled; } - if remote || container { + if environment.remote || environment.container { return NativeClipboardPreflight::RemoteOnly; } - match host_os { - HostOs::Linux => match display_server { + match environment.host_os { + HostOs::Linux => match environment.display_server { DisplayServer::Wayland - if trusted_wayland_native(wl_copy_available, true, wayland_data_control) => + if environment.wl_copy_available || environment.wayland_data_control => { NativeClipboardPreflight::LocalAvailable } @@ -81,25 +127,15 @@ pub fn native_clipboard_preflight( } } -#[derive(Debug, Clone, Copy, Eq, PartialEq)] -pub(crate) struct ClipboardDecision { - pub(crate) delivery: ClipboardDelivery, - pub(crate) feedback: ClipboardFeedback, -} - -/// Classify one emitted OSC 52 write using the existing environment policy. -pub(crate) fn osc52_delivery( - brand: TerminalName, - remote: bool, - container: bool, - osc52_sink: bool, -) -> ClipboardDelivery { - if osc52_sink || brand.supports_osc52_clipboard() { - ClipboardDelivery::Confirmed - } else if brand == TerminalName::Unknown && (remote || container) { - ClipboardDelivery::Unverified - } else { - ClipboardDelivery::Failed +/// Classify one emitted OSC 52 write. +/// Unknown SSH/container boundaries strip brand markers, so missing capability evidence is Unverified rather than Failed. +pub(crate) fn osc52_delivery(environment: ClipboardEnvironment) -> ClipboardDelivery { + match environment.osc52_capability() { + Osc52Capability::Supported => ClipboardDelivery::Confirmed, + Osc52Capability::Unknown if environment.remote || environment.container => { + ClipboardDelivery::Unverified + } + Osc52Capability::Unknown | Osc52Capability::Unsupported => ClipboardDelivery::Failed, } } @@ -108,15 +144,12 @@ pub fn expected_delivery( native: NativeClipboardPreflight, route_tmux: bool, route_osc52: bool, - brand: TerminalName, - remote: bool, - container: bool, - osc52_sink: bool, + environment: ClipboardEnvironment, ) -> ClipboardDelivery { if native == NativeClipboardPreflight::LocalAvailable { return ClipboardDelivery::Confirmed; } - let osc52 = route_osc52.then(|| osc52_delivery(brand, remote, container, osc52_sink)); + let osc52 = route_osc52.then(|| osc52_delivery(environment)); if osc52 == Some(ClipboardDelivery::Confirmed) || route_tmux { return ClipboardDelivery::Confirmed; } @@ -127,18 +160,12 @@ pub fn expected_delivery( } /// True when native legs wrote the local OS clipboard rather than a remote host. -pub(crate) fn trusted_native( - legs: &ClipboardWriteLegs, - host_os: HostOs, - display_server: DisplayServer, - remote: bool, - container: bool, -) -> bool { - if remote || container || !legs.route_native { +pub(crate) fn trusted_native(legs: &ClipboardWriteLegs, environment: ClipboardEnvironment) -> bool { + if environment.remote || environment.container || !legs.route_native { return false; } - match host_os { - HostOs::Linux => match display_server { + match environment.host_os { + HostOs::Linux => match environment.display_server { DisplayServer::Wayland => { trusted_wayland_native(legs.wl_copy_ok, legs.arboard_ok, legs.data_control) } @@ -148,51 +175,46 @@ pub(crate) fn trusted_native( } } -/// Resolve the user-visible branch and delivery classification together. -#[allow(clippy::too_many_arguments)] +/// Resolve the user-visible feedback; each feedback variant owns its delivery state. pub(crate) fn resolve_copy_decision( legs: &ClipboardWriteLegs, text: &str, - brand: TerminalName, - host_os: HostOs, - display_server: DisplayServer, - remote: bool, - container: bool, - osc52_sink: bool, -) -> ClipboardDecision { - let decision = |delivery, feedback| ClipboardDecision { delivery, feedback }; - if trusted_native(legs, host_os, display_server, remote, container) { - return decision(ClipboardDelivery::Confirmed, ClipboardFeedback::Copied); + environment: ClipboardEnvironment, +) -> ClipboardFeedback { + if trusted_native(legs, environment) { + return ClipboardFeedback::Copied; } if legs.osc52_ok { - match osc52_delivery(brand, remote, container, osc52_sink) { + match osc52_delivery(environment) { ClipboardDelivery::Confirmed => { - let feedback = if remote && brand.is_vscode_family() && !text.is_ascii() { - ClipboardFeedback::VsCodeSshNonAscii - } else if container { - ClipboardFeedback::CopiedOscContainer - } else if remote { - ClipboardFeedback::CopiedOscRemote - } else { - ClipboardFeedback::Copied - }; - return decision(ClipboardDelivery::Confirmed, feedback); + if environment.container { + return ClipboardFeedback::CopiedOscContainer; + } + if environment.remote && environment.brand.is_vscode_family() && !text.is_ascii() { + return ClipboardFeedback::VsCodeSshNonAscii; + } + if environment.remote { + return ClipboardFeedback::CopiedOscRemote; + } + return ClipboardFeedback::Copied; } ClipboardDelivery::Unverified if !legs.tmux_ok => { - let feedback = if remote { - ClipboardFeedback::UnverifiedOscRemote - } else { - ClipboardFeedback::UnverifiedOscContainer - }; - return decision(ClipboardDelivery::Unverified, feedback); + if environment.container { + return ClipboardFeedback::UnverifiedOscContainer; + } + return ClipboardFeedback::UnverifiedOscRemote; } ClipboardDelivery::Unverified | ClipboardDelivery::Failed => {} } } if legs.tmux_ok { - return decision(ClipboardDelivery::Confirmed, ClipboardFeedback::CopiedTmux); + return ClipboardFeedback::CopiedTmux; + } + if environment.remote || environment.container { + ClipboardFeedback::FailedRemote + } else { + ClipboardFeedback::Failed } - decision(ClipboardDelivery::Failed, ClipboardFeedback::Failed) } #[cfg(test)] @@ -221,243 +243,260 @@ mod tests { } } - #[allow(clippy::too_many_arguments)] - fn resolve( - legs: &ClipboardWriteLegs, - text: &str, - brand: TerminalName, - host_os: HostOs, - display_server: DisplayServer, - remote: bool, - container: bool, - osc52_sink: bool, - ) -> ClipboardDecision { - resolve_copy_decision( - legs, - text, + fn environment(brand: TerminalName) -> ClipboardEnvironment { + ClipboardEnvironment { brand, - host_os, - display_server, - remote, - container, - osc52_sink, - ) + host_os: HostOs::Linux, + display_server: DisplayServer::Unknown, + remote: false, + container: false, + osc52_sink: false, + wayland_data_control: false, + wl_copy_available: false, + } } #[test] fn telemetry_projection_labels_and_historical_boolean_are_pinned() { - for (delivery, label, reported_success) in [ - (ClipboardDelivery::Confirmed, "confirmed", true), - (ClipboardDelivery::Unverified, "unverified", true), - (ClipboardDelivery::Failed, "failed", false), + for (delivery, label, confirmed, failed, reported_success) in [ + (ClipboardDelivery::Confirmed, "confirmed", true, false, true), + ( + ClipboardDelivery::Unverified, + "unverified", + false, + false, + true, + ), + (ClipboardDelivery::Failed, "failed", false, true, false), ] { assert_eq!(delivery.telemetry_label(), label); + assert_eq!(delivery.is_confirmed(), confirmed); + assert_eq!(delivery.is_failed(), failed); assert_eq!(delivery.reported_success(), reported_success); } } #[test] fn local_trusted_native_is_confirmed() { - let decision = resolve( + let feedback = resolve_copy_decision( &legs(true, false, false, false, false, "pbcopy"), "hello", - TerminalName::Ghostty, - HostOs::Macos, - DisplayServer::Quartz, - false, - false, - false, + ClipboardEnvironment { + host_os: HostOs::Macos, + display_server: DisplayServer::Quartz, + ..environment(TerminalName::Ghostty) + }, ); - assert_eq!(decision.delivery, ClipboardDelivery::Confirmed); - assert_eq!(decision.feedback, ClipboardFeedback::Copied); + assert_eq!(feedback, ClipboardFeedback::Copied); + assert_eq!(feedback.delivery(), ClipboardDelivery::Confirmed); } #[test] fn wayland_native_requires_verified_destination() { + let environment = ClipboardEnvironment { + display_server: DisplayServer::Wayland, + ..environment(TerminalName::Vte) + }; let unverified = legs(false, true, false, false, false, ""); - assert!(!trusted_native( - &unverified, - HostOs::Linux, - DisplayServer::Wayland, - false, - false - )); + assert!(!trusted_native(&unverified, environment)); let data_control = legs(false, true, true, false, false, ""); - assert!(trusted_native( - &data_control, - HostOs::Linux, - DisplayServer::Wayland, - false, - false - )); + assert!(trusted_native(&data_control, environment)); let wl_copy = legs(true, false, false, false, false, "wl-copy"); - assert!(trusted_native( - &wl_copy, - HostOs::Linux, - DisplayServer::Wayland, - false, - false - )); + assert!(trusted_native(&wl_copy, environment)); } #[test] - fn remote_native_write_only_is_failed() { - let decision = resolve( + fn remote_native_write_only_uses_failed_remote() { + let feedback = resolve_copy_decision( &legs(true, true, false, false, false, "xclip"), "hello", - TerminalName::Ghostty, - HostOs::Linux, - DisplayServer::X11, - true, - false, - false, + ClipboardEnvironment { + display_server: DisplayServer::X11, + remote: true, + ..environment(TerminalName::Ghostty) + }, ); - assert_eq!(decision.delivery, ClipboardDelivery::Failed); + assert_eq!(feedback, ClipboardFeedback::FailedRemote); + assert_eq!(feedback.delivery(), ClipboardDelivery::Failed); } #[test] fn known_osc_capable_terminal_is_confirmed() { - let decision = resolve( + let feedback = resolve_copy_decision( &legs(false, false, false, false, true, ""), "hello", - TerminalName::Ghostty, - HostOs::Linux, - DisplayServer::Unknown, - true, - false, - false, + ClipboardEnvironment { + remote: true, + ..environment(TerminalName::Ghostty) + }, ); - assert_eq!(decision.delivery, ClipboardDelivery::Confirmed); - assert_eq!(decision.feedback, ClipboardFeedback::CopiedOscRemote); + assert_eq!(feedback, ClipboardFeedback::CopiedOscRemote); + assert_eq!(feedback.delivery(), ClipboardDelivery::Confirmed); } #[test] fn ssh_unknown_brand_osc_is_unverified() { - let decision = resolve( + let feedback = resolve_copy_decision( &legs(false, false, false, false, true, ""), "hello", - TerminalName::Unknown, - HostOs::Linux, - DisplayServer::Unknown, - true, - false, - false, + ClipboardEnvironment { + remote: true, + ..environment(TerminalName::Unknown) + }, ); - assert_eq!(decision.delivery, ClipboardDelivery::Unverified); - assert_eq!(decision.feedback, ClipboardFeedback::UnverifiedOscRemote); + assert_eq!(feedback, ClipboardFeedback::UnverifiedOscRemote); + assert_eq!(feedback.delivery(), ClipboardDelivery::Unverified); } #[test] fn container_unknown_brand_osc_is_unverified() { - let decision = resolve( + let feedback = resolve_copy_decision( &legs(false, false, false, false, true, ""), "hello", - TerminalName::Unknown, - HostOs::Linux, - DisplayServer::Unknown, - false, - true, - false, + ClipboardEnvironment { + container: true, + ..environment(TerminalName::Unknown) + }, ); - assert_eq!(decision.delivery, ClipboardDelivery::Unverified); - assert_eq!(decision.feedback, ClipboardFeedback::UnverifiedOscContainer); + assert_eq!(feedback, ClipboardFeedback::UnverifiedOscContainer); + assert_eq!(feedback.delivery(), ClipboardDelivery::Unverified); } #[test] - fn known_unsupported_terminal_osc_is_failed() { - for brand in [TerminalName::AppleTerminal, TerminalName::Vte] { - let decision = resolve( + fn known_unsupported_terminal_osc_is_failed_remote() { + for (brand, remote, container) in [ + (TerminalName::AppleTerminal, true, false), + (TerminalName::Vte, true, false), + (TerminalName::AppleTerminal, true, true), + ] { + let feedback = resolve_copy_decision( &legs(false, false, false, false, true, ""), "hello", - brand, - HostOs::Linux, - DisplayServer::Unknown, - true, - false, - false, + ClipboardEnvironment { + remote, + container, + ..environment(brand) + }, ); - assert_eq!(decision.delivery, ClipboardDelivery::Failed, "{brand:?}"); + assert_eq!(feedback, ClipboardFeedback::FailedRemote, "{brand:?}"); + assert_eq!(feedback.delivery(), ClipboardDelivery::Failed, "{brand:?}"); } } + #[test] + fn container_with_detected_unsupported_brand_is_failed_remote() { + let feedback = resolve_copy_decision( + &legs(false, false, false, false, true, ""), + "hello", + ClipboardEnvironment { + container: true, + ..environment(TerminalName::AppleTerminal) + }, + ); + assert_eq!(feedback, ClipboardFeedback::FailedRemote); + assert_eq!(feedback.delivery(), ClipboardDelivery::Failed); + } + #[test] fn active_wrap_sink_with_osc_is_confirmed_for_any_brand() { for brand in [TerminalName::Unknown, TerminalName::AppleTerminal] { - let decision = resolve( + let feedback = resolve_copy_decision( &legs(false, false, false, false, true, ""), "hello", - brand, - HostOs::Linux, - DisplayServer::Unknown, - true, - false, - true, + ClipboardEnvironment { + remote: true, + osc52_sink: true, + ..environment(brand) + }, ); - assert_eq!(decision.delivery, ClipboardDelivery::Confirmed, "{brand:?}"); + assert!(feedback.delivery().is_confirmed(), "{brand:?}"); } } #[test] - fn wrap_sink_without_osc_write_is_failed() { - let decision = resolve( + fn wrap_sink_without_osc_write_is_failed_remote() { + let feedback = resolve_copy_decision( &legs(false, false, false, false, false, ""), "hello", - TerminalName::Unknown, - HostOs::Linux, - DisplayServer::Unknown, - true, - false, - true, + ClipboardEnvironment { + remote: true, + osc52_sink: true, + ..environment(TerminalName::Unknown) + }, ); - assert_eq!(decision.delivery, ClipboardDelivery::Failed); + assert_eq!(feedback, ClipboardFeedback::FailedRemote); } #[test] fn tmux_success_wins_over_unverified_osc() { - let decision = resolve( + let feedback = resolve_copy_decision( &legs(false, false, false, true, true, ""), "hello", - TerminalName::Unknown, - HostOs::Linux, - DisplayServer::Unknown, - true, - false, - false, + ClipboardEnvironment { + remote: true, + ..environment(TerminalName::Unknown) + }, ); - assert_eq!(decision.delivery, ClipboardDelivery::Confirmed); - assert_eq!(decision.feedback, ClipboardFeedback::CopiedTmux); + assert_eq!(feedback, ClipboardFeedback::CopiedTmux); + assert_eq!(feedback.delivery(), ClipboardDelivery::Confirmed); } #[test] - fn no_successful_leg_is_failed() { - let decision = resolve( + fn no_successful_local_leg_is_failed() { + let feedback = resolve_copy_decision( &legs(false, false, false, false, false, ""), "hello", - TerminalName::Ghostty, - HostOs::Linux, - DisplayServer::Unknown, - true, - false, - false, + environment(TerminalName::Ghostty), + ); + assert_eq!(feedback, ClipboardFeedback::Failed); + assert_eq!(feedback.delivery(), ClipboardDelivery::Failed); + } + + #[test] + fn remote_and_container_prefer_container_feedback_and_telemetry_branch() { + let confirmed = resolve_copy_decision( + &legs(false, false, false, false, true, ""), + "hello", + ClipboardEnvironment { + remote: true, + container: true, + ..environment(TerminalName::Ghostty) + }, + ); + assert_eq!(confirmed, ClipboardFeedback::CopiedOscContainer); + assert_eq!( + Into::<&'static str>::into(confirmed), + "copied_osc_container" + ); + + let unverified = resolve_copy_decision( + &legs(false, false, false, false, true, ""), + "hello", + ClipboardEnvironment { + remote: true, + container: true, + ..environment(TerminalName::Unknown) + }, + ); + assert_eq!(unverified, ClipboardFeedback::UnverifiedOscContainer); + assert_eq!( + Into::<&'static str>::into(unverified), + "unverified_osc_container" ); - assert_eq!(decision.delivery, ClipboardDelivery::Failed); - assert_eq!(decision.feedback, ClipboardFeedback::Failed); } #[test] fn vscode_ssh_non_ascii_stays_confirmed_with_warning_toast() { - let decision = resolve( + let feedback = resolve_copy_decision( &legs(false, false, false, false, true, ""), "café", - TerminalName::VsCode, - HostOs::Linux, - DisplayServer::Unknown, - true, - false, - false, + ClipboardEnvironment { + remote: true, + ..environment(TerminalName::VsCode) + }, ); - assert_eq!(decision.delivery, ClipboardDelivery::Confirmed); - assert_eq!(decision.feedback, ClipboardFeedback::VsCodeSshNonAscii); + assert_eq!(feedback, ClipboardFeedback::VsCodeSshNonAscii); + assert_eq!(feedback.delivery(), ClipboardDelivery::Confirmed); } #[test] @@ -471,42 +510,48 @@ mod tests { assert_eq!( native_clipboard_preflight( true, - HostOs::Linux, - DisplayServer::Wayland, - false, - false, - data_control, - wl_copy, + ClipboardEnvironment { + display_server: DisplayServer::Wayland, + wayland_data_control: data_control, + wl_copy_available: wl_copy, + ..environment(TerminalName::Vte) + }, ), expected, "data_control={data_control} wl_copy={wl_copy}" ); } - assert_eq!( - native_clipboard_preflight( - true, - HostOs::Linux, - DisplayServer::Wayland, - true, - false, - true, - true, - ), - NativeClipboardPreflight::RemoteOnly - ); + for (remote, container) in [(true, false), (false, true), (true, true)] { + assert_eq!( + native_clipboard_preflight( + true, + ClipboardEnvironment { + display_server: DisplayServer::Wayland, + remote, + container, + wayland_data_control: true, + wl_copy_available: true, + ..environment(TerminalName::Vte) + }, + ), + NativeClipboardPreflight::RemoteOnly, + "remote={remote} container={container}" + ); + } } #[test] fn expected_delivery_matches_preflight_routes() { + let unknown_remote = ClipboardEnvironment { + remote: true, + ..environment(TerminalName::Unknown) + }; assert_eq!( expected_delivery( NativeClipboardPreflight::RemoteOnly, false, true, - TerminalName::Unknown, - true, - false, - false, + unknown_remote, ), ClipboardDelivery::Unverified ); @@ -515,10 +560,10 @@ mod tests { NativeClipboardPreflight::RemoteOnly, false, true, - TerminalName::Vte, - true, - false, - false, + ClipboardEnvironment { + remote: true, + ..environment(TerminalName::Vte) + }, ), ClipboardDelivery::Failed ); @@ -527,10 +572,11 @@ mod tests { NativeClipboardPreflight::RemoteOnly, false, true, - TerminalName::Vte, - true, - false, - true, + ClipboardEnvironment { + remote: true, + osc52_sink: true, + ..environment(TerminalName::Vte) + }, ), ClipboardDelivery::Confirmed ); @@ -539,10 +585,7 @@ mod tests { NativeClipboardPreflight::RemoteOnly, true, false, - TerminalName::Unknown, - true, - false, - false, + unknown_remote, ), ClipboardDelivery::Confirmed ); @@ -551,10 +594,7 @@ mod tests { NativeClipboardPreflight::Unavailable, false, false, - TerminalName::Vte, - false, - false, - false, + environment(TerminalName::Vte), ), ClipboardDelivery::Failed ); diff --git a/crates/codegen/xai-grok-pager-render/src/render/draw.rs b/crates/codegen/xai-grok-pager-render/src/render/draw.rs index 65f22e6..549afb5 100644 --- a/crates/codegen/xai-grok-pager-render/src/render/draw.rs +++ b/crates/codegen/xai-grok-pager-render/src/render/draw.rs @@ -46,70 +46,115 @@ use crossterm::{QueueableCommand, cursor}; use ratatui::Frame; use ratatui::backend::CrosstermBackend; use std::io::Write; -use std::sync::mpsc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, mpsc}; use std::time::{Duration, Instant}; use xai_ratatui_inline::LinkSpan; /// Terminal type for the pager. Defined here (beside [`TermWriter`]) so the /// `render` module does not depend on `app`. Re-exported from `app` as /// `crate::app::PagerTerminal` for existing call sites. pub type PagerTerminal = xai_ratatui_inline::Terminal>; -/// Shared queued/written frame counters linking [`TermWriter`] to the writer -/// thread, so callers can wait for the output pipeline to drain. +#[derive(Debug)] +pub enum WriterEvent { + Written(u64), + Failed(std::io::Error), +} +/// Outcome of a bounded writer drain attempt. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WriterDrain { + Drained, + TimedOut, +} +/// Tracks submitted and successfully flushed presentation sequences. /// -/// The channel between them is fire-and-forget by design (the event loop must -/// never block on pty I/O), but a few operations need a *happens-before* on -/// terminal bytes: suspending into a tty-taking child (`$EDITOR` / `$PAGER`) -/// while a frame is still queued lets that frame race the child's own output — -/// it can land on the child's alternate screen (so the main screen never -/// receives it) or tear mid-escape-sequence around the alt-screen switch, -/// leaving the restored screen out of sync with the renderer's diff buffer -/// (stale rows, one-line offsets, literal `[` fragments). [`wait_drained`] -/// closes that window. -/// -/// `queued` is incremented *before* the frame is sent and `written` after the -/// writer thread has flushed it to the tty, so `written == queued` ⇒ every -/// frame handed to the channel has reached the terminal fd. -/// -/// [`wait_drained`]: WriterSync::wait_drained -#[derive(Clone, Debug, Default)] +/// During a child handoff, input is parked before this state is drained. Since +/// a sequence is reserved before its payload is sent, an accepted frame blocks +/// the drain before it is visible to the writer; no queued frame can land after +/// the child takes the tty. +#[derive(Clone, Debug)] pub struct WriterSync { - queued: std::sync::Arc, - written: std::sync::Arc, + queued: Arc, + written: Arc, + failed: Arc, + writer_active: Arc, + event_tx: Option>, +} +impl Default for WriterSync { + fn default() -> Self { + Self::new() + } } impl WriterSync { pub fn new() -> Self { - Self::default() + Self { + queued: Arc::new(AtomicU64::new(0)), + written: Arc::new(AtomicU64::new(0)), + failed: Arc::new(AtomicBool::new(false)), + writer_active: Arc::new(AtomicBool::new(false)), + event_tx: None, + } } - /// Record a frame handed to the channel. Called by [`TermWriter::flush`] - /// *before* the send so `written` can never observably exceed `queued`. - fn mark_queued(&self) { - self.queued - .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + fn with_event_sender(event_tx: tokio::sync::mpsc::UnboundedSender) -> Self { + Self { + queued: Arc::new(AtomicU64::new(0)), + written: Arc::new(AtomicU64::new(0)), + failed: Arc::new(AtomicBool::new(false)), + writer_active: Arc::new(AtomicBool::new(false)), + event_tx: Some(event_tx), + } } - /// Record a frame fully written + flushed to the tty (writer thread). - fn mark_written(&self) { - self.written - .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + #[cfg(test)] + fn new_for_test() -> (Self, tokio::sync::mpsc::UnboundedReceiver) { + let (event_tx, event_rx) = tokio::sync::mpsc::unbounded_channel(); + (Self::with_event_sender(event_tx), event_rx) } - /// Whether every queued frame has been written to the tty. - pub fn is_drained(&self) -> bool { - self.written.load(std::sync::atomic::Ordering::SeqCst) - >= self.queued.load(std::sync::atomic::Ordering::SeqCst) + fn reserve_sequence(&self) -> u64 { + self.queued.fetch_add(1, Ordering::Release) + 1 } - /// Block (bounded) until the writer thread has flushed every queued frame. - /// - /// Returns `true` when drained, `false` on timeout (wedged pty / dead - /// writer thread — callers proceed anyway, matching the bounded - /// reader-park in the suspend path). - pub fn wait_drained(&self, timeout: Duration) -> bool { + fn mark_written(&self, sequence: u64) { + self.written.store(sequence, Ordering::Release); + if let Some(event_tx) = &self.event_tx { + let _ = event_tx.send(WriterEvent::Written(sequence)); + } + } + fn mark_failed(&self, error: std::io::Error) { + if self + .failed + .compare_exchange(false, true, Ordering::Release, Ordering::Relaxed) + .is_err() + { + return; + } + if let Some(event_tx) = &self.event_tx { + let _ = event_tx.send(WriterEvent::Failed(error)); + } + } + pub fn queued(&self) -> u64 { + self.queued.load(Ordering::Acquire) + } + pub fn written(&self) -> u64 { + self.written.load(Ordering::Acquire) + } + pub fn failed(&self) -> bool { + self.failed.load(Ordering::Acquire) + } + fn is_drained(&self) -> bool { + !self.failed() && self.written() >= self.queued() + } + /// Block until the writer flushes every accepted payload, output fails, or + /// the deadline passes. + pub fn wait_drained(&self, timeout: Duration) -> std::io::Result { let deadline = Instant::now() + timeout; while !self.is_drained() { + if self.failed() { + return Err(std::io::Error::other("terminal output failed")); + } if Instant::now() >= deadline { - return false; + return Ok(WriterDrain::TimedOut); } std::thread::sleep(Duration::from_millis(1)); } - true + Ok(WriterDrain::Drained) } } /// A writer that buffers frame output and sends it to a background thread @@ -124,25 +169,41 @@ impl WriterSync { /// terminal emulator is slow to read (e.g. Ghostty busy with another pane), /// only the writer thread stalls — the event loop keeps processing timers, /// events, and ACP messages. +pub struct WriterPayload { + pub(crate) sequence: u64, + pub(crate) data: Vec, +} +pub type WriterSender = mpsc::Sender; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WriterAlreadyActive; +impl std::fmt::Display for WriterAlreadyActive { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("WriterSync already owns a live TermWriter") + } +} +impl std::error::Error for WriterAlreadyActive {} pub struct TermWriter { buf: Vec, - tx: mpsc::Sender>, + tx: WriterSender, sync: WriterSync, } impl TermWriter { - pub fn new(tx: mpsc::Sender>, sync: WriterSync) -> Self { - Self { + pub fn new(tx: WriterSender, sync: WriterSync) -> Result { + sync.writer_active + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .map_err(|_| WriterAlreadyActive)?; + Ok(Self { buf: Vec::with_capacity(32 * 1024), tx, sync, - } + }) } /// Drop the current frame's buffered bytes without sending them. pub fn discard(&mut self) { self.buf.clear(); } - /// The queued/written counters shared with the writer thread. Used by the - /// suspend path to [`WriterSync::wait_drained`] before a child takes the tty. + /// Shared writer progress used by the suspend path to + /// [`WriterSync::wait_drained`] before a child takes the tty. pub fn writer_sync(&self) -> &WriterSync { &self.sync } @@ -153,10 +214,19 @@ impl Write for TermWriter { Ok(data.len()) } fn flush(&mut self) -> std::io::Result<()> { - if !self.buf.is_empty() { - let data = std::mem::take(&mut self.buf); - self.sync.mark_queued(); - let _ = self.tx.send(data); + if self.buf.is_empty() { + return Ok(()); + } + let sequence = self.sync.reserve_sequence(); + let data = std::mem::take(&mut self.buf); + if self.tx.send(WriterPayload { sequence, data }).is_err() { + let error = std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "terminal writer thread exited", + ); + self.sync + .mark_failed(std::io::Error::new(error.kind(), error.to_string())); + return Err(error); } Ok(()) } @@ -164,6 +234,7 @@ impl Write for TermWriter { impl Drop for TermWriter { fn drop(&mut self) { let _ = self.flush(); + self.sync.writer_active.store(false, Ordering::Release); } } /// Handle for the background writer thread. @@ -171,17 +242,25 @@ impl Drop for TermWriter { /// Joining ensures all queued frames have been written to the terminal /// before proceeding with teardown (e.g. `LeaveAlternateScreen`). pub struct WriterThread { - handle: Option>, + handle: Option>>, + sync: WriterSync, } impl WriterThread { /// Block until the writer thread has processed all pending frames and /// exited. The [`mpsc::Sender`] must be dropped *before* calling this, /// otherwise the thread will never see the channel close. - pub fn join(mut self) { - if let Some(h) = self.handle.take() { - let _ = h.join(); + pub fn join(mut self) -> std::io::Result<()> { + let Some(handle) = self.handle.take() else { + return Ok(()); + }; + match handle.join() { + Ok(result) => result, + Err(_) => Err(std::io::Error::other("terminal writer thread panicked")), } } + pub fn writer_sync(&self) -> &WriterSync { + &self.sync + } } impl Drop for WriterThread { fn drop(&mut self) { @@ -190,25 +269,47 @@ impl Drop for WriterThread { } } } +fn write_payload( + writer: &mut impl Write, + payload: &WriterPayload, + sync: &WriterSync, +) -> std::io::Result<()> { + match writer + .write_all(&payload.data) + .and_then(|()| writer.flush()) + { + Ok(()) => { + sync.mark_written(payload.sequence); + Ok(()) + } + Err(error) => { + sync.mark_failed(std::io::Error::new(error.kind(), error.to_string())); + Err(error) + } + } +} /// Spawn a background OS thread that writes frame data to stderr. /// -/// Returns `(Sender, WriterSync, WriterThread)`. Send `Vec` frame data -/// through the sender; the thread writes each frame to stderr via a 64 KiB -/// `BufWriter`. The [`WriterSync`] must be shared with every [`TermWriter`] -/// built on the sender so [`WriterSync::wait_drained`] tracks the queue. -/// Drop the sender to signal the thread to exit, then call -/// [`WriterThread::join`] to wait for it. -pub fn spawn_writer_thread() -> (mpsc::Sender>, WriterSync, WriterThread) { - let (tx, rx) = mpsc::channel::>(); - let sync = WriterSync::new(); +/// Returns the frame sender, shared writer state, completion-event receiver, +/// and the thread handle that must be joined during terminal teardown. +pub fn spawn_writer_thread() -> ( + WriterSender, + WriterSync, + tokio::sync::mpsc::UnboundedReceiver, + WriterThread, +) { + let (tx, rx) = mpsc::channel::(); + let (event_tx, event_rx) = tokio::sync::mpsc::unbounded_channel(); + let sync = WriterSync::with_event_sender(event_tx); let thread_sync = sync.clone(); + let writer_thread_sync = sync.clone(); let test_delay = std::env::var("GROK_TEST_FRAME_WRITE_DELAY_MS") .ok() .and_then(|v| v.parse::().ok()) .map(Duration::from_millis); let handle = std::thread::Builder::new() .name("term-writer".into()) - .spawn(move || { + .spawn(move || -> std::io::Result<()> { #[cfg(not(windows))] let mut writer: Box = { let tui_out = xai_tty_utils::dup_tui_stderr().unwrap_or_else(|_| { @@ -223,24 +324,33 @@ pub fn spawn_writer_thread() -> (mpsc::Sender>, WriterSync, WriterThread 64 * 1024, std::io::stderr(), )); - while let Ok(data) = rx.recv() { + while let Ok(payload) = rx.recv() { if let Some(delay) = test_delay { std::thread::sleep(delay); } - { + let result = { let _guard = xai_grok_shared::stderr::stderr_lock(); - let _ = writer.write_all(&data); - let _ = writer.flush(); + write_payload(&mut writer, &payload, &thread_sync) + }; + if let Err(error) = result { + tracing::error!(% error, "terminal output failed"); + return Err(error); } - thread_sync.mark_written(); + } + if thread_sync.failed() { + Err(std::io::Error::other("terminal output failed")) + } else { + Ok(()) } }) .expect("failed to spawn term-writer thread"); ( tx, sync, + event_rx, WriterThread { handle: Some(handle), + sync: writer_thread_sync, }, ) } @@ -386,8 +496,10 @@ mod tests { frame.render_widget(Paragraph::new("hello world"), frame.area()); (None, None) } - let (tx, rx) = mpsc::channel::>(); - let backend = CrosstermBackend::new(TermWriter::new(tx, WriterSync::new())); + let (tx, rx) = mpsc::channel::(); + let backend = CrosstermBackend::new( + TermWriter::new(tx, WriterSync::new()).expect("single test writer"), + ); let mut terminal = xai_ratatui_inline::Terminal::with_options( backend, TerminalOptions { @@ -397,10 +509,10 @@ mod tests { .expect("build terminal"); let mut cursor = CursorState::new(); draw_frame(&mut terminal, &mut cursor, render); - let first: Vec = rx.try_iter().flatten().collect(); + let first: Vec = rx.try_iter().flat_map(|payload| payload.data).collect(); assert!(!first.is_empty(), "first frame should emit bytes"); draw_frame(&mut terminal, &mut cursor, render); - let second: Vec = rx.try_iter().flatten().collect(); + let second: Vec = rx.try_iter().flat_map(|payload| payload.data).collect(); assert!( second.is_empty(), "idle (unchanged) frame must emit 0 bytes, got {}: {:?}", @@ -408,41 +520,127 @@ mod tests { String::from_utf8_lossy(&second), ); } - /// `wait_drained` semantics: drained when `written` has caught up with - /// `queued` — immediately when nothing is pending, after the consumer - /// marks the frame written, and a bounded `false` when it never does. - /// This is the happens-before the suspend path relies on so no queued - /// frame can race a tty-taking `$EDITOR` / `$PAGER` child. #[test] - fn writer_sync_drains_when_written_catches_queued() { - let sync = WriterSync::new(); - assert!(sync.wait_drained(Duration::from_millis(1))); - sync.mark_queued(); - assert!(!sync.is_drained()); - assert!(!sync.wait_drained(Duration::from_millis(5))); - let consumer_sync = sync.clone(); - let consumer = std::thread::spawn(move || { - std::thread::sleep(Duration::from_millis(10)); - consumer_sync.mark_written(); - }); - assert!(sync.wait_drained(Duration::from_secs(5))); - consumer.join().expect("consumer thread"); + fn writer_success_is_acknowledged_after_flush() { + let (sync, mut events) = WriterSync::new_for_test(); + let sequence = sync.reserve_sequence(); + let payload = WriterPayload { + sequence, + data: b"frame bytes".to_vec(), + }; + let mut sink = Vec::new(); + write_payload(&mut sink, &payload, &sync).expect("write payload"); + assert_eq!(sink, b"frame bytes"); + assert_eq!(sync.written(), sequence); + assert!( + matches!(events.try_recv(), Ok(WriterEvent::Written(written)) if written == + sequence) + ); + assert_eq!( + sync.wait_drained(Duration::from_secs(1)).unwrap(), + WriterDrain::Drained + ); } - /// A `TermWriter::flush` with buffered bytes marks the frame queued; the - /// writer-thread side marking it written restores the drained state. #[test] - fn term_writer_flush_marks_queued() { - let (tx, rx) = mpsc::channel::>(); + fn writer_flush_failure_is_not_acknowledged() { + struct FlushFailWriter { + data: Vec, + } + impl Write for FlushFailWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.data.extend_from_slice(buf); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Err(std::io::Error::other("flush failed")) + } + } + let (sync, mut events) = WriterSync::new_for_test(); + let sequence = sync.reserve_sequence(); + let payload = WriterPayload { + sequence, + data: b"frame bytes".to_vec(), + }; + let mut sink = FlushFailWriter { data: Vec::new() }; + assert!(write_payload(&mut sink, &payload, &sync).is_err()); + assert_eq!(sink.data, b"frame bytes"); + assert_eq!(sync.written(), 0); + assert!(sync.failed()); + assert!(matches!(events.try_recv(), Ok(WriterEvent::Failed(_)))); + } + #[test] + fn writer_failure_is_not_acknowledged() { + struct FailingWriter; + impl Write for FailingWriter { + fn write(&mut self, _buf: &[u8]) -> std::io::Result { + Err(std::io::Error::other("write failed")) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + let (sync, mut events) = WriterSync::new_for_test(); + let sequence = sync.reserve_sequence(); + let payload = WriterPayload { + sequence, + data: b"frame bytes".to_vec(), + }; + assert!(write_payload(&mut FailingWriter, &payload, &sync).is_err()); + assert_eq!(sync.written(), 0); + assert!(sync.failed()); + assert!(matches!(events.try_recv(), Ok(WriterEvent::Failed(_)))); + assert!(sync.wait_drained(Duration::from_secs(1)).is_err()); + } + #[test] + fn writer_drain_timeout_is_bounded_and_retryable() { let sync = WriterSync::new(); - let mut writer = TermWriter::new(tx, sync.clone()); + let sequence = sync.reserve_sequence(); + let started = Instant::now(); + assert_eq!( + sync.wait_drained(Duration::from_millis(5)).unwrap(), + WriterDrain::TimedOut + ); + assert!(started.elapsed() < Duration::from_secs(1)); + sync.mark_written(sequence); + assert_eq!( + sync.wait_drained(Duration::ZERO).unwrap(), + WriterDrain::Drained + ); + } + #[test] + fn term_writer_send_failure_is_published_and_not_acknowledged() { + let (tx, rx) = mpsc::channel::(); + drop(rx); + let (sync, mut events) = WriterSync::new_for_test(); + let mut writer = TermWriter::new(tx, sync.clone()).expect("single test writer"); + writer.write_all(b"frame bytes").expect("buffer write"); + assert!(writer.flush().is_err()); + assert_eq!(sync.queued(), 1); + assert_eq!(sync.written(), 0); + assert!(matches!(events.try_recv(), Ok(WriterEvent::Failed(_)))); + } + #[test] + fn writer_sync_rejects_multiple_live_producers() { + let (tx, _rx) = mpsc::channel::(); + let sync = WriterSync::new(); + let first = TermWriter::new(tx.clone(), sync.clone()).expect("first writer"); + assert!(matches!( + TermWriter::new(tx.clone(), sync.clone()), + Err(WriterAlreadyActive) + )); + drop(first); + assert!(TermWriter::new(tx, sync).is_ok()); + } + #[test] + fn drain_observes_reservation_before_payload_is_consumed() { + let (tx, rx) = mpsc::channel::(); + let sync = WriterSync::new(); + let mut writer = TermWriter::new(tx, sync.clone()).expect("single test writer"); + writer.write_all(b"frame bytes").expect("buffer write"); writer.flush().expect("flush"); - assert!(sync.is_drained()); - writer.write_all(b"frame bytes").expect("write"); - writer.flush().expect("flush"); - assert!(!sync.is_drained(), "queued frame not yet written"); - assert_eq!(rx.try_recv().expect("frame on channel"), b"frame bytes"); - sync.mark_written(); - assert!(sync.is_drained()); + assert_eq!(sync.queued(), 1); + assert!(!sync.is_drained()); + assert_eq!(rx.recv().expect("payload").sequence, 1); } fn state_hidden() -> CursorState { CursorState { last_pos: None } diff --git a/crates/codegen/xai-grok-pager/Cargo.toml b/crates/codegen/xai-grok-pager/Cargo.toml index aa5a489..ee842a1 100644 --- a/crates/codegen/xai-grok-pager/Cargo.toml +++ b/crates/codegen/xai-grok-pager/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "xai-grok-pager" -version = "0.2.102" +version = "0.2.105" edition.workspace = true license = "Apache-2.0" authors = ["xAI"] diff --git a/crates/codegen/xai-grok-pager/docs/user-guide/02-authentication.md b/crates/codegen/xai-grok-pager/docs/user-guide/02-authentication.md index a6c84d5..08313b9 100644 --- a/crates/codegen/xai-grok-pager/docs/user-guide/02-authentication.md +++ b/crates/codegen/xai-grok-pager/docs/user-guide/02-authentication.md @@ -14,6 +14,14 @@ grok Grok stores credentials in `~/.grok/auth.json` and reuses them across sessions. Grok refreshes access tokens automatically in the background. When a token can't be refreshed, Grok prompts you to sign in again. Credentials without a server-provided expiry fall back to a 30-day lifetime. +### Credential storage + +Tokens in `~/.grok/auth.json` (and MCP OAuth tokens in `~/.grok/mcp_credentials.json`) are written with owner-only permissions (`0600` on Unix). Anyone with filesystem access to those paths can use the credentials, so: + +- Prefer full-disk encryption (FileVault, BitLocker, LUKS, or equivalent). +- Do not copy `auth.json` or `mcp_credentials.json` into shared directories, tickets, or chat. +- On multi-user hosts, keep `$HOME` / `$GROK_HOME` private to your account. + ### Re-authenticate To switch accounts or resolve an authentication problem, run: @@ -261,6 +269,25 @@ During a session, the active method handles all mid-session refreshes. --- +## Related settings + +`/privacy` does not change these config knobs: + +| Setting | How to set it | +|---------|---------------| +| `[features] telemetry` | `config.toml` or `GROK_TELEMETRY_ENABLED` | +| `[telemetry] trace_upload` | `config.toml` or `GROK_TELEMETRY_TRACE_UPLOAD` | +| External OpenTelemetry | `GROK_EXTERNAL_OTEL` / `[telemetry] otel_*`. See [Monitoring Usage](24-monitoring-usage.md). | + +On team accounts, only a team admin can toggle privacy with `/privacy`. +Team admins can also enable or disable Zero Data Retention (ZDR) for their team. +See [How to enable ZDR](https://docs.x.ai/developers/faq/security#how-to-enable-zdr). +When ZDR is on, `/privacy` cannot change coding-data sharing. + +See [Monitoring Usage](24-monitoring-usage.md#related-settings) and [Configuration](05-configuration.md#telemetry). + +--- + ## Troubleshooting ### Debug logging diff --git a/crates/codegen/xai-grok-pager/docs/user-guide/04-slash-commands.md b/crates/codegen/xai-grok-pager/docs/user-guide/04-slash-commands.md index 64b302d..360aa69 100644 --- a/crates/codegen/xai-grok-pager/docs/user-guide/04-slash-commands.md +++ b/crates/codegen/xai-grok-pager/docs/user-guide/04-slash-commands.md @@ -399,7 +399,11 @@ Report an issue or send feedback. ### `/btw` -Send an aside to the agent without interrupting the current task. +Send an aside to the agent without interrupting the current task. In minimal +mode (`--minimal`), the answer appears in a dismissible panel above the prompt. +**Esc** dismisses the panel; a finished answer is saved into native scrollback, +while a late response to a dismissed loading panel is dropped. Side Q/A is not +part of the main turn. ``` /btw also check the error handling @@ -513,8 +517,16 @@ Show or toggle privacy and data-retention status. ``` /privacy +/privacy opt-in +/privacy opt-out ``` +Does not change `[features] telemetry`, `trace_upload`, or external OTEL settings. +See [Monitoring Usage](24-monitoring-usage.md#related-settings). +On team accounts, only a team admin can toggle privacy with `/privacy`. +Team admins can also enable or disable Zero Data Retention (ZDR) for their team: +[How to enable ZDR](https://docs.x.ai/developers/faq/security#how-to-enable-zdr). + --- ## Configuration and UI diff --git a/crates/codegen/xai-grok-pager/docs/user-guide/05-configuration.md b/crates/codegen/xai-grok-pager/docs/user-guide/05-configuration.md index 2836932..ee71744 100644 --- a/crates/codegen/xai-grok-pager/docs/user-guide/05-configuration.md +++ b/crates/codegen/xai-grok-pager/docs/user-guide/05-configuration.md @@ -58,6 +58,9 @@ collapsed_edit_blocks = false # show edits as one-line +N/-M diffstat s # back-to-back same-file edits into one row, expand for the # diffs (default: false; pager.toml [scrollback.blocks.edit] # expanded_by_default/line_summary override its fold shape) +page_flip_on_send = true # pin a just-sent prompt at the top of the viewport so the + # response starts on a fresh page (default: true); set false + # so sending never moves the scroll position screen_mode = "fullscreen" # default render mode: "fullscreen" | "minimal" # (unset → fullscreen); set via /settings → Default screen mode @@ -182,6 +185,14 @@ you for that session only. A CLI flag always wins over the config value for that invocation. +#### Snap prompt to top on send + +By default, sending a prompt scrolls it to the top of the viewport so the +response starts on a fresh page. Set `[ui] page_flip_on_send = false` (or +toggle **Snap prompt to top on send** in `/settings` → Appearance) to leave +the scroll position unchanged when you send. Applies on the next send; no +restart. + #### Scrolling Four `[ui]` settings tune mouse-wheel and trackpad scrolling in the @@ -226,8 +237,14 @@ timeout_secs = 1800 # seconds to wait when enabled (default: [toolset.web_fetch] proxy_endpoint = "https://proxy.example.com" # egress proxy URL allowed_domains = ["docs.rs", "x.ai"] # override the built-in allowlist +allow_local = false # true = allow localhost / 127.0.0.0/8 / ::1 only ``` +`allow_local` is off by default (SSRF fail-closed). When `true` (or +`GROK_WEB_FETCH_ALLOW_LOCAL=1`), `web_fetch` may reach **explicit** loopback +hosts only — private, link-local, and cloud-metadata ranges stay blocked. +Resolution: TOML > env > default off. + `[toolset.ask_user_question]` is honored across **requirements.toml**, **managed config**, and **user `config.toml`**. Precedence: requirements → env (`GROK_ASK_USER_QUESTION_TIMEOUT_ENABLED` / @@ -370,16 +387,16 @@ Session cells remain staged until a foreign-session scanner consumes them. Each ```toml [compat.cursor] skills = true # scan ~/.cursor/skills/ and /.cursor/skills/ -rules = true # scan /.cursor/rules/ -agents = true # scan ~/.cursor/ for AGENTS.md files +rules = true # scan ~/.cursor/rules/ and /.cursor/rules/ +agents = true # scan ~/.cursor/ for named instruction files mcps = true # scan ~/.cursor/mcp.json and /.cursor/mcp.json hooks = true # scan ~/.cursor/hooks.json and /.cursor/hooks.json sessions = true # staged; no scanner consumer yet [compat.claude] skills = true # scan ~/.claude/skills/ and /.claude/skills/ -rules = true # scan /.claude/rules/ -agents = true # scan ~/.claude/ for CLAUDE.md / CLAUDE.local.md +rules = true # scan ~/.claude/rules/ and /.claude/rules/ +agents = true # scan ~/.claude/ and /.claude/CLAUDE*.md mcps = true # scan ~/.claude.json for MCP servers hooks = true # scan ~/.claude/settings.json for hooks sessions = true # staged; no scanner consumer yet @@ -390,6 +407,8 @@ sessions = true # staged; no scanner consumer yet Codex `skills`, `rules`, `agents`, `mcps`, and `hooks` cells are reserved and currently inert; they do not enable `.codex` discovery. +For Claude and Cursor, `rules` and `agents` are independent: disabling named instruction files does not disable either the home or project rules directory, and disabling rules does not disable named files. Claude's `agents` cell gates home-level `~/.claude/` named files and project `/.claude/CLAUDE*.md`; generic top-level `Claude.md`, `CLAUDE.md`, and `CLAUDE.local.md` remain recognized. Project rule paths are scanned at every directory from the repo root to the current directory. + Each cell can be toggled via environment variable or `config.toml`. See the environment-variables reference for the env var names. Resolution order: env var > config.toml > default (on). @@ -546,7 +565,15 @@ See [Keyboard Shortcuts](03-keyboard-shortcuts.md) for the complete reference. ### Telemetry -The `[features] telemetry` toggle (in the `[features]` block above) is the master switch for anonymous usage telemetry. When telemetry is enabled, enterprises that run their own collector can redirect it or selectively disable parts of it under `[telemetry]`: +Independent knobs (see [Monitoring Usage](24-monitoring-usage.md#related-settings)): + +- **`[features] telemetry`** / `GROK_TELEMETRY_ENABLED`: product analytics master switch. `/privacy` does not change it. +- **`/privacy`** / Settings: coding data sharing (separate from telemetry). +- **`[telemetry] trace_upload`** / `GROK_TELEMETRY_TRACE_UPLOAD`: session traces; follows telemetry when unset. +- **`[telemetry] otel_*`** / `GROK_EXTERNAL_OTEL`: external OTEL to your collector (below). + +When telemetry is enabled, enterprises that run their own collector can redirect +it or selectively disable parts of it under `[telemetry]`: ```toml [telemetry] @@ -558,7 +585,7 @@ trace_upload = false # disable session/trac Set these only to point telemetry at your own infrastructure or to turn parts of it off. The built-in endpoint and credentials are managed by Grok; leave them unset to use the defaults. -The same `[telemetry]` table also configures the **external OpenTelemetry stream** — an independent opt-in (it does not require the telemetry toggle above) that ships a curated, content-free usage schema to your *own* OTLP collector. Collector auth is supplied via `OTEL_EXPORTER_OTLP_HEADERS` and is never stored on disk. See [Monitoring & Usage](24-monitoring-usage.md) for the full schema, env vars, and privacy model. +The same `[telemetry]` table also configures the **external OpenTelemetry stream**, an independent opt-in (it does not require the telemetry toggle above) that ships a curated, content-free usage schema to your *own* OTLP collector. Collector auth is supplied via `OTEL_EXPORTER_OTLP_HEADERS` and is never stored on disk. See [Monitoring & Usage](24-monitoring-usage.md) for the full schema, env vars, and privacy model. ```toml [telemetry] @@ -751,6 +778,7 @@ Key environment variables. See the README for the complete list. | `GROK_MEMORY` | Enable (`1`) or disable (`0`) cross-session memory | | `GROK_SUBAGENTS` | Enable (`1`) or disable (`0`) subagents | | `GROK_WEB_FETCH` | Enable (`1`) or disable (`0`) the web_fetch tool | +| `GROK_WEB_FETCH_ALLOW_LOCAL` | Allow `web_fetch` to explicit loopback hosts only (`localhost` / `127.0.0.0/8` / `::1`). Same as `[toolset.web_fetch] allow_local`. Default off. Private/metadata stay blocked. | | `GROK_AGENT` | Custom agent definition path or name | | `GROK_SANDBOX` | Sandbox profile (off, workspace, devbox, read-only, strict; or a custom profile name) | @@ -773,6 +801,9 @@ Key environment variables. See the README for the complete list. | Variable | Description | |----------|-------------| | `GROK_TELEMETRY_ENABLED` | Enable/disable telemetry | +| `GROK_TELEMETRY_TRACE_UPLOAD` | Enable/disable session trace upload | +| `GROK_TELEMETRY_MIXPANEL_ENABLED` | Enable/disable Mixpanel specifically | +| `GROK_EXTERNAL_OTEL` | External OTEL to your collector (see [24-monitoring-usage.md](24-monitoring-usage.md)) | | `GROK_FEEDBACK_ENABLED` | Enable/disable feedback system | | `GROK_DEPLOYMENT_KEY` | Management API key for enterprise | diff --git a/crates/codegen/xai-grok-pager/docs/user-guide/12-project-rules.md b/crates/codegen/xai-grok-pager/docs/user-guide/12-project-rules.md index 05ea1d9..44540f2 100644 --- a/crates/codegen/xai-grok-pager/docs/user-guide/12-project-rules.md +++ b/crates/codegen/xai-grok-pager/docs/user-guide/12-project-rules.md @@ -35,7 +35,15 @@ In addition to AGENTS.md files, Grok scans for `*.md` files in rules directories | `/.claude/rules/` | Claude compatibility (configurable) | | `/.cursor/rules/` | Cursor compatibility (configurable) | -Grok scans the Claude and Cursor rules directories by default. To disable scanning for a specific vendor, set its cell in the `[compat]` config section or the corresponding environment variable. See [Configuration](05-configuration.md#harness-compatibility) for details. +Grok also scans home-level rules, regardless of where it starts. These roots are already vendor-specific, so rules live directly under `rules/`: + +| Location | Notes | +|----------|-------| +| `$GROK_HOME/rules/` (default `~/.grok/rules/`) | Always scanned; applies to all projects | +| `~/.claude/rules/` | Controlled by `compat.claude.rules` | +| `~/.cursor/rules/` | Controlled by `compat.cursor.rules` | + +Home rules load first, in the table order, followed by project files from repo root to the current directory. Files are alphabetical within each rules directory. The vendor `rules` cells control both home and project rules independently of the corresponding `agents` cells. Claude's `agents` cell controls named files under `~/.claude/` and project `/.claude/CLAUDE*.md`; generic top-level names such as `Claude.md`, `CLAUDE.md`, and `CLAUDE.local.md` remain recognized. See [Configuration](05-configuration.md#harness-compatibility). --- @@ -43,7 +51,7 @@ Grok scans the Claude and Cursor rules directories by default. To disable scanni Grok scans for project rules in this order: -1. **Global rules**: `~/.grok/` (applies to all projects) +1. **Home rules**: `$GROK_HOME`, then enabled `~/.claude/` and `~/.cursor/` sources 2. **Repo rules**: If inside a git repo, every directory from the repo root down to the current working directory (inclusive) 3. **CWD-only**: If not inside a git repo, only the current working directory diff --git a/crates/codegen/xai-grok-pager/docs/user-guide/20-background-tasks.md b/crates/codegen/xai-grok-pager/docs/user-guide/20-background-tasks.md index bb80476..7f3cc1c 100644 --- a/crates/codegen/xai-grok-pager/docs/user-guide/20-background-tasks.md +++ b/crates/codegen/xai-grok-pager/docs/user-guide/20-background-tasks.md @@ -186,6 +186,18 @@ To toggle the prompt queue instead, press `Ctrl+;`. --- +## The Watching Status Line + +Whenever background work is still running while the agent looks idle — between turns, or while a turn is blocked on a user-interruptible wait — a persistent status line appears above the prompt: + +``` +◎ watching · 1 command · 2 monitors · 1 loop · 1 subagent +``` + +It counts running background commands, monitors, scheduled `/loop` tasks, and background subagents, and updates live as each finishes. Any of them can wake the agent for a new turn (commands and subagents on completion, monitors on events, loops on their timer), so the cue stays up until nothing is left. Completions land in the transcript as a single "Task completed" chip — the transcript never repeats "N commands still running" lines. + +--- + ## Use Cases and Patterns ### Dev Server + Coding diff --git a/crates/codegen/xai-grok-pager/docs/user-guide/24-monitoring-usage.md b/crates/codegen/xai-grok-pager/docs/user-guide/24-monitoring-usage.md index 1c88720..bd19e05 100644 --- a/crates/codegen/xai-grok-pager/docs/user-guide/24-monitoring-usage.md +++ b/crates/codegen/xai-grok-pager/docs/user-guide/24-monitoring-usage.md @@ -9,6 +9,22 @@ own OpenTelemetry collector, so platform teams can monitor adoption, token consumption, tool-permission decisions, and errors across the fleet — without any data flowing through SpaceXAI. +## Related settings + +These knobs are independent of each other (and of this guide's external OTEL stream): + +| Setting | How to set it | +|---------|---------------| +| Telemetry master switch | `[features] telemetry` / `GROK_TELEMETRY_ENABLED` | +| `/privacy` | `/privacy opt-in` / `/privacy opt-out`, or Settings | +| Trace upload | `[telemetry] trace_upload` / `GROK_TELEMETRY_TRACE_UPLOAD` | +| External OpenTelemetry | `GROK_EXTERNAL_OTEL` / `[telemetry] otel_*` (this guide) | + +See also [Authentication](02-authentication.md#related-settings) and +[Configuration](05-configuration.md#telemetry). + +## External OTEL stream + The external stream is: - **Off by default**, and requires a *double opt-in* (a master switch **and** @@ -19,7 +35,7 @@ The external stream is: - **Structurally separate** from SpaceXAI-internal telemetry: its exporters carry only the headers you configure, never SpaceXAI credentials. - **Independent of SpaceXAI data-retention opt-outs**: it works even when - `telemetry` is disabled and for ZDR (zero-data-retention) teams — those + `telemetry` is disabled and for ZDR (zero-data-retention) teams. Those settings govern SpaceXAI-side retention; the external stream is governed solely by your own OTEL configuration. diff --git a/crates/codegen/xai-grok-pager/docs/user-guide/README.md b/crates/codegen/xai-grok-pager/docs/user-guide/README.md index 33262b2..d095f22 100644 --- a/crates/codegen/xai-grok-pager/docs/user-guide/README.md +++ b/crates/codegen/xai-grok-pager/docs/user-guide/README.md @@ -50,3 +50,5 @@ Automate, script, and integrate Grok Build with other systems. | 20 | [Background Tasks and Monitoring](20-background-tasks.md) | `background: true`, `/loop`, `monitor`, and `Ctrl+G` to demote | | 21 | [Terminal Support and Troubleshooting](21-terminal-support.md) | tmux, SSH, truecolor, clipboard, and OSC 52 | | 22 | [Permissions and Safety Controls](22-permissions-and-safety.md) | `dontAsk` mode, auto-approved tools, the safe-bash list, and restrictive PreToolUse hooks (such as git/gh-only) | +| 23 | [Agent Dashboard](23-dashboard.md) | Central overview of local sessions and forks | +| 24 | [Monitoring Usage (External OpenTelemetry)](24-monitoring-usage.md) | Customer OTEL export | diff --git a/crates/codegen/xai-grok-pager/src/acp/mod.rs b/crates/codegen/xai-grok-pager/src/acp/mod.rs index 0819b5b..38df40c 100644 --- a/crates/codegen/xai-grok-pager/src/acp/mod.rs +++ b/crates/codegen/xai-grok-pager/src/acp/mod.rs @@ -159,7 +159,6 @@ pub async fn connect(cancel: &CancellationToken, flags: ConnectFlags) -> Result< agent_config.resolve_runtime_fields(&xai_grok_shell::agent::config::RuntimeResolutionContext { raw_config: &raw_config, remote_settings: flags.remote_settings.as_ref(), - cwd: None, is_headless: false, cli_subagents: Some(flags.subagents), cli_web_search_model: None, diff --git a/crates/codegen/xai-grok-pager/src/acp/tracker.rs b/crates/codegen/xai-grok-pager/src/acp/tracker.rs index 856be05..b03679f 100644 --- a/crates/codegen/xai-grok-pager/src/acp/tracker.rs +++ b/crates/codegen/xai-grok-pager/src/acp/tracker.rs @@ -848,6 +848,7 @@ impl AcpUpdateTracker { self.retry_activity = None; self.suppressed_tools.clear(); self.blocking_waits.clear(); + self.orphan_updates.clear(); self.skip_next_skill_body = false; } /// Finish the current thinking block, passing elapsed time to the entry. @@ -2884,10 +2885,22 @@ mod tests { tracker.handle_update(thought_chunk("thinking"), &meta(), &mut sb); assert!(tracker.current_agent_msg.is_some()); assert!(tracker.current_thinking.is_some()); + tracker.handle_update(tool_update_completed("tc-orphan"), &meta(), &mut sb); + assert_eq!(tracker.orphan_updates.len(), 1); + tracker.task_tool_background.insert("task-x".into(), true); tracker.finish_turn(&mut sb); assert!(tracker.current_agent_msg.is_none()); assert!(tracker.current_thinking.is_none()); assert!(tracker.pending_tools.is_empty()); + assert!( + tracker.orphan_updates.is_empty(), + "orphaned tool-call updates are turn-scoped" + ); + assert_eq!( + tracker.task_tool_background.get("task-x"), + Some(&true), + "background Task flags survive turn end for the late SubagentSpawned" + ); assert!( !sb.needs_animation(), "no entries should be running after finish_turn" diff --git a/crates/codegen/xai-grok-pager/src/app/acp_handler/background.rs b/crates/codegen/xai-grok-pager/src/app/acp_handler/background.rs index 1971938..99bdbbb 100644 --- a/crates/codegen/xai-grok-pager/src/app/acp_handler/background.rs +++ b/crates/codegen/xai-grok-pager/src/app/acp_handler/background.rs @@ -558,11 +558,8 @@ pub(super) fn handle_task_completed(notif: &acp::ExtNotification, app: &mut AppV return false; }; - let (task_snapshot, will_wake) = match session_notif.update { - XaiSessionUpdate::TaskCompleted { - task_snapshot, - will_wake, - } => (task_snapshot, will_wake), + let task_snapshot = match session_notif.update { + XaiSessionUpdate::TaskCompleted { task_snapshot, .. } => task_snapshot, _ => return false, }; @@ -700,10 +697,8 @@ pub(super) fn handle_task_completed(notif: &acp::ExtNotification, app: &mut AppV }; scrollback.push_block(block); - // Parked countdown: a Running command just finished under the parked - // "Worked for … still running" story. Root sessions only: a subagent-local - // task never counted toward the root marker's total. Re-borrow the - // agent — `resolve_target_view` consumed the earlier `&mut`. + // Re-eval a withheld park; the slot self-dedupes. Root sessions only. + // (Re-borrow: `resolve_target_view` consumed the earlier `&mut`.) if was_running && !matches!(matched, SessionMatch::Child(_)) && let Some(agent) = app.agents.get_mut(&matched.agent_id()) @@ -711,20 +706,5 @@ pub(super) fn handle_task_completed(notif: &acp::ExtNotification, app: &mut AppV agent.maybe_push_parked_marker(); } - // Between turns, a root-session completion re-emits the work-only status - // line so the story stays chronological (zero left: no line). When a wake - // response follows (`will_wake`, stamped by the shell), the wake turn's - // end marker carries the fresh counts instead — skip the line. Child - // (subagent) tasks route their chip to the child view above and never - // count toward the root marker — no root status line for them. Mutually - // exclusive with the parked tick above: parked means the turn is still - // running, which `maybe_push_work_status`'s busy gate refuses. - if !will_wake - && !matches!(matched, SessionMatch::Child(_)) - && let Some(agent) = app.agents.get_mut(&matched.agent_id()) - { - agent.maybe_push_work_status(); - } - is_active } diff --git a/crates/codegen/xai-grok-pager/src/app/acp_handler/mod.rs b/crates/codegen/xai-grok-pager/src/app/acp_handler/mod.rs index 5f0d6b1..74e8ab0 100644 --- a/crates/codegen/xai-grok-pager/src/app/acp_handler/mod.rs +++ b/crates/codegen/xai-grok-pager/src/app/acp_handler/mod.rs @@ -59,10 +59,10 @@ use routing::{ mcp_target_agent, resolve_notif_agent, resolve_target_view, }; +use prompt_origin::{finish_wake_turn, viewer_turn_anchor}; pub(crate) use prompt_origin::{ is_server_initiated_prompt, is_wake_prompt, should_adopt_running_prompt, }; -use prompt_origin::{push_wake_end_marker, viewer_turn_anchor, wake_turn_elapsed}; pub(crate) use subagent_activity::finalize_killed_subagent; use subagent_activity::{subagent_activity_label, sync_subagent_activity}; @@ -244,18 +244,6 @@ pub(crate) fn handle(msg: AcpClientMessage, app: &mut AppView) -> bool { } if let Some(ts) = meta.turn_start_ms { agent.turn_start_ms = Some(ts); - // A wake turn's end marker derives elapsed from its - // deltas — non-adopted turns have no other timing - // source. `turnStartMs` is constant per turn, so - // record once per pid (the equality check also - // skips the classifier on the turn's later deltas). - if let Some(pid) = meta.prompt_id.as_deref() - && agent.wake_turn_start.as_ref().map(|(p, _)| p.as_str()) - != Some(pid) - && is_wake_prompt(pid) - { - agent.wake_turn_start = Some((pid.to_string(), ts)); - } } } @@ -381,9 +369,6 @@ pub(crate) fn handle(msg: AcpClientMessage, app: &mut AppView) -> bool { && agent.session.current_prompt_id.as_ref() != Some(notif_pid) && agent.attached_as_viewer { - // The driver's next turn closes the between-turns - // status window on this pane too. - agent.end_work_announced = false; agent.session.current_prompt_id = Some(notif_pid.clone()); // A viewer adopting another client's new turn: drop // the prior turn's chips but KEEP the seen ring so a diff --git a/crates/codegen/xai-grok-pager/src/app/acp_handler/prompt_origin.rs b/crates/codegen/xai-grok-pager/src/app/acp_handler/prompt_origin.rs index a8c375e..7114d23 100644 --- a/crates/codegen/xai-grok-pager/src/app/acp_handler/prompt_origin.rs +++ b/crates/codegen/xai-grok-pager/src/app/acp_handler/prompt_origin.rs @@ -24,10 +24,10 @@ pub(crate) fn is_scheduler_fired_prompt(prompt_id: &str) -> bool { /// Returns true for the auto-wake turn families (`task-completed-…`, /// `subagent-completed-…`, `notifications-…`). These run non-adopted — no /// `PromptResponse`, no viewer finalize — so their durable `TurnCompleted` is -/// the only signal marking the back-to-idle point, and it pushes the turn-end -/// marker directly. Deliberately narrower than "non-adopted synthetic": goal -/// turns render through the goal chip/loop chrome and `plan-resume-…` keeps -/// its current markerless shape. +/// the only signal marking the back-to-idle point (see [`finish_wake_turn`]; +/// wake turns close markerless). Deliberately narrower than "non-adopted +/// synthetic": goal turns render through the goal chip/loop chrome and +/// `plan-resume-…` keeps its own markerless shape. pub(crate) fn is_wake_prompt(prompt_id: &str) -> bool { matches!( xai_grok_shell::session::PromptOrigin::from_prompt_id(prompt_id), @@ -85,42 +85,10 @@ pub(super) fn viewer_turn_anchor(turn_start_ms: Option) -> std::time::Insta .unwrap_or(now) } -/// Elapsed for a wake turn's end marker: its delta-borne `turnStartMs` -/// ([`AgentView::wake_turn_start`], consumed here on a pid match) to the -/// terminal's `agentTimestampMs` — both stamped by the shell clock, so client -/// skew cancels (fall back to client now when the stamp is missing). `None` -/// — no tracked start (old shells / no deltas seen) or a nonsensical negative -/// span — renders the marker without a duration rather than lying with -/// "0.0s". -pub(super) fn wake_turn_elapsed( - agent: &mut AgentView, - prompt_id: &str, - end_ms: Option, -) -> Option { - let (_, start_ms) = agent.wake_turn_start.take_if(|(pid, _)| pid == prompt_id)?; - let end_ms = end_ms.unwrap_or_else(|| chrono::Utc::now().timestamp_millis()); - u64::try_from(end_ms - start_ms) - .ok() - .map(std::time::Duration::from_millis) -} - -/// Push a wake turn's end marker via the shared terminal-marker helper so the -/// wake turn's OWN stop hooks (pid-matched stash) render inline on the marker -/// instead of as a stray block. A REAL turn's leftover stash (pid mismatch) -/// must stay pending for its own marker rail — never fold into, nor flush -/// standalone on, an unrelated wake — hence `preserve_mismatched_stash`. -pub(super) fn push_wake_end_marker( - agent: &mut AgentView, - prompt_id: &str, - elapsed: Option, -) { - // Wake turns skip PromptResponse; finish streaming so a trailing ` is flushed. +/// Close out a wake turn: markerless, but the stream must be finished here — +/// wake turns skip `PromptResponse`, so this is the only flush site for an +/// in-flight streamed entry (dead wakes included). Leaves a real turn's +/// stop-hook stash pending for its own marker rail. +pub(super) fn finish_wake_turn(agent: &mut AgentView) { agent.session.tracker.finish_turn(&mut agent.scrollback); - - crate::app::turn_completion::push_turn_terminal_marker( - agent, - Some(SessionEvent::TurnCompleted { elapsed }), - Some(prompt_id), - /* preserve_mismatched_stash */ true, - ); } diff --git a/crates/codegen/xai-grok-pager/src/app/acp_handler/queue.rs b/crates/codegen/xai-grok-pager/src/app/acp_handler/queue.rs index f0e16b1..f2f4649 100644 --- a/crates/codegen/xai-grok-pager/src/app/acp_handler/queue.rs +++ b/crates/codegen/xai-grok-pager/src/app/acp_handler/queue.rs @@ -279,14 +279,15 @@ pub(super) fn handle_queue_changed(notif: &acp::ExtNotification, app: &mut AppVi // Nothing running locally: adopt now + run the turn-start shim // (render the queued prompt's user block, set `TurnRunning`). None => { - if let Some(agent) = app.agents.get_mut(&aid) { + let page_flip_entry = app.agents.get_mut(&aid).and_then(|agent| { super::super::dispatch::apply_turn_start_shim( agent, pid, running_text, &running_kind, - ); - } + ) + }); + super::super::dispatch::note_peek_page_flip(app, aid, page_flip_entry); } // A different prompt is still finishing locally (FIFO handoff // race — the next broadcast can arrive before the previous diff --git a/crates/codegen/xai-grok-pager/src/app/acp_handler/session_notification.rs b/crates/codegen/xai-grok-pager/src/app/acp_handler/session_notification.rs index 763568c..5525a70 100644 --- a/crates/codegen/xai-grok-pager/src/app/acp_handler/session_notification.rs +++ b/crates/codegen/xai-grok-pager/src/app/acp_handler/session_notification.rs @@ -219,11 +219,8 @@ pub(super) fn handle_session_notification(notif: &acp::ExtNotification, app: &mu } else if is_wake_prompt(&prompt_id) { if agent.session.state.is_busy() { false - } else if matches!(stop_reason.as_str(), "cancelled" | "error" | "rate_limit") { - agent.maybe_push_work_status() } else { - let elapsed = wake_turn_elapsed(agent, &prompt_id, meta.agent_timestamp_ms); - push_wake_end_marker(agent, &prompt_id, elapsed); + finish_wake_turn(agent); true } } else { @@ -506,7 +503,6 @@ pub(super) fn handle_session_notification(notif: &acp::ExtNotification, app: &mu turns, duration_ms, tokens_used, - will_wake, .. } => { tracing::info!( @@ -571,9 +567,7 @@ pub(super) fn handle_session_notification(notif: &acp::ExtNotification, app: &mu } entry.invalidate_cache(); } - let mut was_running = false; if let Some(info) = agent.subagent_sessions.get_mut(&child_session_id) { - was_running = info.is_running(); info.finished = true; info.status = Some(Arc::from(status)); info.error = error.map(Arc::from); @@ -594,11 +588,8 @@ pub(super) fn handle_session_notification(notif: &acp::ExtNotification, app: &mu crate::app::subagent::finalize_finished_child_view(child_view, elapsed_dur); } } - if was_running && !resuming { - agent.maybe_refresh_parked_subagent_marker(); - } - if !resuming && !will_wake { - agent.maybe_push_work_status(); + if !resuming { + agent.maybe_push_parked_marker(); } true } @@ -692,18 +683,6 @@ pub(super) fn handle_session_notification(notif: &acp::ExtNotification, app: &mu hook_entries, batch_prompt_id.as_deref(), ); - } else if batch_is_wake - && !batch_prompt_id - .as_deref() - .is_some_and(|pid| agent.scrollback.has_turn_terminal_marker_with_pid(pid)) - { - stash_live_stop_batch( - agent, - batch_prompt_id.clone(), - event_name, - hook_entries, - true, - ); } else { agent .scrollback @@ -1261,8 +1240,8 @@ pub(super) fn apply_retry_state( ); } is_credit_limit = super::super::dispatch::is_credit_limit_error(None, reason); - let is_free_usage = - *rate_limited && super::super::dispatch::is_free_usage_exhausted_error(reason); + let is_free_usage = *rate_limited + && xai_grok_shell::sampling::error::is_free_usage_exhausted_error(reason); if is_credit_limit { session.credit_limit_blocked = true; } else if is_free_usage { @@ -1272,7 +1251,10 @@ pub(super) fn apply_retry_state( scrollback.push_block(RenderBlock::session_event(SessionEvent::ReAuthRequired)); } else { let error = if *rate_limited { - format_rate_limited_user_message(Some(reason.as_str()), is_api_key_auth) + crate::app::effects::sanitize_user_error(&format_rate_limited_user_message( + Some(reason.as_str()), + is_api_key_auth, + )) } else { format!("failed after {attempts} retries: {reason}") }; diff --git a/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/interjection.rs b/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/interjection.rs index 0eb3773..53d1cb2 100644 --- a/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/interjection.rs +++ b/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/interjection.rs @@ -42,11 +42,11 @@ } /// Regression: a Forgone slot (interjection continued - /// the parked turn, no marker on screen) must also silence the countdown - /// — a full "Worked for …" tick under the interjected message would - /// recreate the flipped transcript. Rendered slots keep ticking. + /// the parked turn, no marker on screen) must silence later marker pushes + /// — a full "Worked for …" line under the interjected message would + /// recreate the flipped transcript. #[test] - fn forgone_slot_suppresses_countdown_ticks() { + fn forgone_slot_suppresses_later_marker_pushes() { use crate::app::agent_view::test_fixtures::{count_parked, simulate_task_output_wait}; let mut app = make_app_with_agent("sess-park"); @@ -77,15 +77,11 @@ ); } - /// Feature: "sleep 10, 15, 20 in the background" — while the turn is - /// parked, each task completion appends a fresh FULL marker with the - /// remaining count, so the user watches it tick down (3 → 2 → 1), each - /// line a complete "Worked for X. N commands still running.". - /// The last completion pushes nothing (0/0): the wait returns and the - /// real completion marker narrates the end. (Elapsed renders as "0.0s": - /// `turn_started_at` is unset in this fixture.) + /// "sleep 10, 15, 20 in the background": completions within one park + /// episode push chips only — the marker never re-pushes. (Elapsed + /// renders as "0.0s": `turn_started_at` is unset in this fixture.) #[test] - fn parked_countdown_ticks_down_as_tasks_complete() { + fn parked_completions_push_chips_without_marker_repush() { use crate::app::agent_view::test_fixtures::simulate_task_output_wait; let mut app = make_app_with_agent("sess-park"); @@ -101,7 +97,8 @@ assert!(agent.renders_parked()); } - // sleep 10 exits → full marker with "2 commands still running." + // Each completion lands as a chip; no marker re-push, no "N commands + // still running." lines. handle_ext_notification( &make_task_completed_notif("sess-park", "t10", "sleep 10", Some(0)), &mut app, @@ -111,12 +108,10 @@ &make_task_completed_notif("sess-park", "t10", "sleep 10", Some(0)), &mut app, ); - // sleep 15 exits → full marker with "1 command still running." handle_ext_notification( &make_task_completed_notif("sess-park", "t15", "sleep 15", Some(0)), &mut app, ); - // sleep 20 exits → nothing left; no "0 commands" line. handle_ext_notification( &make_task_completed_notif("sess-park", "t20", "sleep 20", Some(0)), &mut app, @@ -125,63 +120,88 @@ let agent = app.agents.get_mut(&AgentId(0)).unwrap(); assert_eq!( parked_marker_messages(agent), - vec![ - "Worked for 0.0s. 3 commands still running.".to_string(), - "Worked for 0.0s. 2 commands still running.".to_string(), - "Worked for 0.0s. 1 command still running.".to_string(), - ], + vec!["Worked for 0.0s".to_string()], + "one plain marker per park episode — completions never re-push" + ); + assert!( + work_status_lines(&agent.scrollback).is_empty(), + "no work-only status lines in the transcript" + ); + } + + /// Parity with the bg-command completion rail: a park withheld at park + /// time (held queue) gets re-evaluated by a subagent completion once the + /// blocker cleared, so the boundary marker isn't deferred to whenever the + /// next unrelated notification happens to arrive. + #[test] + fn subagent_finish_reevaluates_withheld_parked_marker() { + use crate::app::agent_view::test_fixtures::{count_parked, simulate_wait_all}; + + let mut app = make_app_with_agent("sess-park"); + { + let agent = app.agents.get_mut(&AgentId(0)).unwrap(); + agent.session.state = AgentState::TurnRunning; + agent.session.current_prompt_id = Some("p1".into()); + for child_id in ["child-1", "child-2"] { + agent + .subagent_sessions + .insert(child_id.into(), make_subagent_info(child_id)); + } + simulate_wait_all(agent); + // Held queue at park time: the marker is withheld. + agent.session.enqueue_prompt("queued follow-up".into()); + agent.maybe_push_parked_marker(); + assert_eq!(count_parked(agent), 0, "held queue withholds the marker"); + // The queue drains; nothing has re-evaluated the marker yet. + agent.session.pending_prompts.clear(); + } + + handle( + make_ext_session_notification("sess-park", test_subagent_finished("child-1")), + &mut app, + ); + + let agent = app.agents.get_mut(&AgentId(0)).unwrap(); + assert_eq!( + count_parked(agent), + 1, + "the completion re-evaluates the withheld park" + ); + assert_eq!( + parked_marker_messages(agent), + vec!["Worked for 0.0s".to_string()], ); } #[test] - fn consecutive_subagent_finishes_refresh_one_uncommitted_marker() { + fn consecutive_subagent_finishes_leave_single_parked_marker() { let mut app = make_app_with_agent("sess-park"); let marker_id = { let agent = app.agents.get_mut(&AgentId(0)).unwrap(); park_on_subagents(agent, &["child-1", "child-2", "child-3"]) }; - handle( - make_ext_session_notification("sess-park", test_subagent_finished("child-1")), - &mut app, - ); + for child in ["child-1", "child-1", "child-2", "child-3"] { + handle( + make_ext_session_notification("sess-park", test_subagent_finished(child)), + &mut app, + ); + } let agent = app.agents.get_mut(&AgentId(0)).unwrap(); assert_eq!( parked_marker_messages(agent), - vec!["Worked for 0.0s. 2 subagents still running.".to_string()], - ); - assert_eq!(parked_marker_ids(agent), vec![marker_id]); - - // Re-delivered finish for an already-finished subagent: not an edge. - handle( - make_ext_session_notification("sess-park", test_subagent_finished("child-1")), - &mut app, - ); - let agent = app.agents.get_mut(&AgentId(0)).unwrap(); - assert_eq!( - parked_marker_messages(agent), - vec!["Worked for 0.0s. 2 subagents still running.".to_string()], - ); - assert_eq!(parked_marker_ids(agent), vec![marker_id]); - - handle( - make_ext_session_notification("sess-park", test_subagent_finished("child-2")), - &mut app, - ); - handle( - make_ext_session_notification("sess-park", test_subagent_finished("child-3")), - &mut app, - ); - let agent = app.agents.get_mut(&AgentId(0)).unwrap(); - assert_eq!( - parked_marker_messages(agent), - vec!["Worked for 0.0s. 1 subagent still running.".to_string()], + vec!["Worked for 0.0s".to_string()], + "subagent finishes never re-push or mutate the park marker" ); assert_eq!(parked_marker_ids(agent), vec![marker_id]); } + /// A re-park after new parent output (text / thought / tool) is a new + /// park episode: the wait-state update that creates the second wait + /// pushes a fresh marker (epoch mismatch), while completions within one + /// episode never re-push. #[test] - fn parent_text_thought_and_tool_output_start_new_subagent_segments() { + fn parent_text_thought_and_tool_output_start_new_park_episodes() { use crate::acp::meta::NotificationMeta; use crate::app::agent_view::test_fixtures::simulate_task_output_wait_call; @@ -224,6 +244,11 @@ ); { let agent = app.agents.get_mut(&AgentId(0)).unwrap(); + // Same episode: a repeated push attempt (e.g. another wait + // update restating the same wait) is deduped by epoch. + agent.maybe_push_parked_marker(); + assert_eq!(parked_marker_ids(agent).len(), 1); + let output = match output_kind { "text" => acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new( acp::ContentBlock::Text(acp::TextContent::new("parent text")), @@ -244,20 +269,17 @@ &mut agent.scrollback, )); simulate_task_output_wait_call(agent, "wait-2", "not-ours", 30_000); + // The wait-state notification path re-evaluates the marker on + // every wait update (`maybe_push_parked_marker` from the ACP + // handler); mirror it for the fixture-driven second wait. + agent.maybe_push_parked_marker(); } - handle( - make_ext_session_notification("sess-park", test_subagent_finished("child-2")), - &mut app, - ); let agent = app.agents.get_mut(&AgentId(0)).unwrap(); assert_eq!( parked_marker_messages(agent), - vec![ - "Worked for 0.0s. 2 subagents still running.".to_string(), - "Worked for 0.0s. 1 subagent still running.".to_string(), - ], - "{output_kind} output must start a new segment", + vec!["Worked for 0.0s".to_string(), "Worked for 0.0s".to_string()], + "{output_kind} output must start a new park episode", ); let marker_ids = parked_marker_ids(agent); assert_eq!(marker_ids.len(), 2); @@ -267,68 +289,7 @@ } #[test] - fn committed_subagent_marker_appends_fallback() { - let mut app = make_app_with_agent("sess-park"); - let first_marker_id = { - let agent = app.agents.get_mut(&AgentId(0)).unwrap(); - let marker_id = park_on_subagents(agent, &["child-1", "child-2"]); - let marker_index = (0..agent.scrollback.len()) - .find(|&index| agent.scrollback.get(index).is_some_and(|entry| entry.id == marker_id)) - .unwrap(); - agent.scrollback.mark_committed(marker_index); - marker_id - }; - - handle( - make_ext_session_notification("sess-park", test_subagent_finished("child-1")), - &mut app, - ); - - let agent = app.agents.get_mut(&AgentId(0)).unwrap(); - assert_eq!( - parked_marker_messages(agent), - vec![ - "Worked for 0.0s. 2 subagents still running.".to_string(), - "Worked for 0.0s. 1 subagent still running.".to_string(), - ], - ); - let marker_ids = parked_marker_ids(agent); - assert_eq!(marker_ids.len(), 2); - assert_eq!(marker_ids[0], first_marker_id); - assert_ne!(marker_ids[0], marker_ids[1]); - } - - #[test] - fn stale_subagent_marker_handle_appends_fallback() { - let mut app = make_app_with_agent("sess-park"); - let old_marker_id = { - let agent = app.agents.get_mut(&AgentId(0)).unwrap(); - park_on_subagents(agent, &["child-1", "child-2"]) - }; - assert!(app - .agents - .get_mut(&AgentId(0)) - .unwrap() - .scrollback - .remove_entry(old_marker_id)); - - handle( - make_ext_session_notification("sess-park", test_subagent_finished("child-1")), - &mut app, - ); - - let agent = app.agents.get_mut(&AgentId(0)).unwrap(); - assert_eq!( - parked_marker_messages(agent), - vec!["Worked for 0.0s. 1 subagent still running.".to_string()], - ); - let marker_ids = parked_marker_ids(agent); - assert_eq!(marker_ids.len(), 1); - assert_ne!(marker_ids[0], old_marker_id); - } - - #[test] - fn interjection_suppresses_later_subagent_refresh() { + fn interjection_suppresses_later_marker_push() { let mut app = make_app_with_agent("sess-park"); let marker_id = { let agent = app.agents.get_mut(&AgentId(0)).unwrap(); @@ -350,13 +311,13 @@ let agent = app.agents.get_mut(&AgentId(0)).unwrap(); assert_eq!( parked_marker_messages(agent), - vec!["Worked for 0.0s. 2 subagents still running.".to_string()], + vec!["Worked for 0.0s".to_string()], ); assert_eq!(parked_marker_ids(agent), vec![marker_id]); } #[test] - fn replayed_subagent_finish_does_not_refresh_marker() { + fn replayed_subagent_finish_does_not_touch_marker() { let mut app = make_app_with_agent("sess-park"); let marker_id = { let agent = app.agents.get_mut(&AgentId(0)).unwrap(); @@ -373,13 +334,13 @@ let agent = app.agents.get_mut(&AgentId(0)).unwrap(); assert_eq!( parked_marker_messages(agent), - vec!["Worked for 0.0s. 2 subagents still running.".to_string()], + vec!["Worked for 0.0s".to_string()], ); assert_eq!(parked_marker_ids(agent), vec![marker_id]); } #[test] - fn imminent_subagent_wait_does_not_refresh_marker() { + fn imminent_subagent_wait_keeps_single_marker() { use crate::app::agent_view::test_fixtures::simulate_task_output_wait; let mut app = make_app_with_agent("sess-park"); @@ -405,7 +366,7 @@ let agent = app.agents.get_mut(&AgentId(0)).unwrap(); assert_eq!( parked_marker_messages(agent), - vec!["Worked for 0.0s. 2 subagents still running.".to_string()], + vec!["Worked for 0.0s".to_string()], ); assert_eq!(parked_marker_ids(agent), vec![marker_id]); } @@ -517,7 +478,7 @@ assert_eq!(count_parked(agent), 1, "genuine park still renders"); assert_eq!( parked_marker_messages(agent), - vec!["Worked for 0.0s. 1 command still running.".to_string()], + vec!["Worked for 0.0s".to_string()], ); } @@ -644,7 +605,7 @@ assert_eq!(count_parked(agent), 1, "wait-all on live work parks"); assert_eq!( parked_marker_messages(agent), - vec!["Worked for 0.0s. 1 command still running.".to_string()], + vec!["Worked for 0.0s".to_string()], ); } @@ -676,7 +637,7 @@ assert_eq!(count_parked(agent), 1, "spawn re-evaluates the skipped park"); assert_eq!( parked_marker_messages(agent), - vec!["Worked for 0.0s. 1 subagent still running.".to_string()], + vec!["Worked for 0.0s".to_string()], ); } @@ -709,7 +670,7 @@ ); assert_eq!( parked_marker_messages(agent), - vec!["Worked for 0.0s. 1 command still running.".to_string()], + vec!["Worked for 0.0s".to_string()], ); } diff --git a/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/mod.rs b/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/mod.rs index 2f99289..1da0c7e 100644 --- a/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/mod.rs +++ b/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/mod.rs @@ -192,12 +192,12 @@ pub(super) fn insert_running_task(agent: &mut AgentView, task_id: &str, command: }, ); } -/// Marker texts of all parked blocks in scrollback, in order — the -/// initial parked marker plus every countdown re-push. +/// Marker texts of all parked blocks in scrollback, in order — one per +/// park episode (re-pushed only after new parent output, i.e. a re-park). pub(super) fn parked_marker_messages(agent: &AgentView) -> Vec { (0..agent.scrollback.len()) .filter_map(|i| match agent.scrollback.get(i).map(|e| &e.block) { - Some(RenderBlock::SessionEvent(b)) if b.parked => Some(b.marker_text()), + Some(RenderBlock::SessionEvent(b)) if b.parked => Some(b.event.message()), _ => None, }) .collect() @@ -931,18 +931,6 @@ pub(super) fn xai_wake_turn_completed_notif( std::sync::Arc::from(serde_json::value::to_raw_value(&payload).unwrap()), ) } -/// The newest turn-marker block on the agent's scrollback. -pub(super) fn last_marker_block( - sb: &ScrollbackState, -) -> &crate::scrollback::blocks::SessionEventBlock { - (0..sb.len()) - .rev() - .find_map(|i| match sb.get(i).map(|e| &e.block) { - Some(RenderBlock::SessionEvent(b)) => Some(b), - _ => None, - }) - .expect("a turn-end marker must exist") -} /// Build a `HookExecution` update (one successful run) on the /// `x.ai/session/update` rail, optionally stamped `isReplay`. /// `prompt_id == None` models pre-attribution shells. @@ -1005,6 +993,7 @@ pub(super) fn last_marker_stop_hook_groups( }) } /// Work-only status lines ("N … still running") pushed as system rows. +/// Never pushed in production; tests assert emptiness. pub(super) fn work_status_lines(sb: &ScrollbackState) -> Vec { (0..sb.len()) .filter_map(|i| match sb.get(i).map(|e| &e.block) { @@ -1016,9 +1005,8 @@ pub(super) fn work_status_lines(sb: &ScrollbackState) -> Vec { .collect() } /// Register two running background commands on the (idle) agent through -/// the wire, then open the between-turns status window the way a -/// counted turn-end marker would. -pub(super) fn seed_two_bg_tasks_and_announce(app: &mut AppView, session_id: &str) { +/// the wire. +pub(super) fn seed_two_bg_tasks(app: &mut AppView, session_id: &str) { let _ = handle_ext_notification( &make_task_backgrounded_notif(session_id, "tc-1", "task-1", "sleep 98"), app, @@ -1027,7 +1015,6 @@ pub(super) fn seed_two_bg_tasks_and_announce(app: &mut AppView, session_id: &str &make_task_backgrounded_notif(session_id, "tc-2", "task-2", "sleep 99"), app, ); - app.agents.get_mut(&AgentId(0)).unwrap().end_work_announced = true; } /// Build an `x.ai/session/interjection` ext-notification (no id). pub(super) fn interjection_ext(session_id: &str, text: &str) -> acp::ExtNotification { @@ -1201,12 +1188,6 @@ pub(super) fn test_subagent_spawned( } } pub(super) fn test_subagent_finished(child_sid: &str) -> XaiSessionUpdate { - test_subagent_finished_with_wake(child_sid, false) -} -pub(super) fn test_subagent_finished_with_wake( - child_sid: &str, - will_wake: bool, -) -> XaiSessionUpdate { XaiSessionUpdate::SubagentFinished { subagent_id: child_sid.into(), child_session_id: child_sid.into(), @@ -1217,7 +1198,7 @@ pub(super) fn test_subagent_finished_with_wake( duration_ms: 500, tokens_used: 0, output: None, - will_wake, + will_wake: false, } } pub(super) fn test_subagent_progress( diff --git a/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/session_events.rs b/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/session_events.rs index 9e116c3..625817d 100644 --- a/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/session_events.rs +++ b/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/session_events.rs @@ -155,10 +155,12 @@ } } + /// Production `RetryState::Exhausted.reason` is `SamplingError::Api`'s + /// Display: `API error (status 429 Too Many Requests): …`. #[test] fn retry_exhausted_rate_limited_surfaces_server_detail() { - let reason = - "The model is currently at capacity due to high demand. Please try again.".to_string(); + let body = "The model is currently at capacity due to high demand. Please try again."; + let reason = format!("API error (status 429 Too Many Requests): {body}"); let exhausted = RetryState::Exhausted { attempts: 3, reason: reason.clone(), @@ -170,7 +172,8 @@ apply_retry_state(&exhausted, &mut session, &mut scrollback, false); match last_session_event(&scrollback) { Some(SessionEvent::RetryFailed { error, .. }) => { - assert_eq!(error, reason); + assert_eq!(error, body); + assert!(!error.contains("API error (status")); } other => panic!("expected detail RetryFailed, got {other:?}"), } @@ -182,7 +185,8 @@ let rpm = RetryState::Exhausted { attempts: 2, - reason: "Some resource has been exhausted: You are sending requests too quickly. \ + reason: "API error (status 429 Too Many Requests): \ + Some resource has been exhausted: You are sending requests too quickly. \ Please slow down, or upgrade to a Grok subscription for higher limits: \ https://grok.com/supergrok" .into(), diff --git a/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/turn_completion.rs b/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/turn_completion.rs index 969f0bb..c16718a 100644 --- a/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/turn_completion.rs +++ b/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/turn_completion.rs @@ -231,46 +231,10 @@ } #[test] - fn wake_delta_records_wake_turn_start() { - // The wake turn's deltas are the marker's only timing source: the - // stamp is pid-scoped so a later real turn's `turnStartMs` cannot - // masquerade as the wake turn's start. + fn wake_turn_completed_is_markerless() { let mut app = make_app_with_agent("sess-wake"); - let _ = handle( - make_viewer_chunk_with_turn_start("sess-wake", "task-completed-bg1", 5_000), - &mut app, - ); - let agent = app.agents.get(&AgentId(0)).unwrap(); - let (pid, _) = agent - .wake_turn_start - .as_ref() - .expect("a wake delta must record its turn start"); - assert_eq!(pid, "task-completed-bg1"); - - // A real (user) turn's delta must not overwrite the record. - let _ = handle( - make_viewer_chunk_with_turn_start("sess-wake", "pid-user", 1_000), - &mut app, - ); - let agent = app.agents.get(&AgentId(0)).unwrap(); - assert_eq!( - agent.wake_turn_start.as_ref().map(|(p, _)| p.as_str()), - Some("task-completed-bg1"), - "only wake-turn deltas feed the wake start record" - ); - } - - #[test] - fn wake_turn_completed_pushes_end_marker_with_counts() { - let mut app = make_app_with_agent("sess-wake"); - seed_two_bg_tasks_and_announce(&mut app, "sess-wake"); - { - // Window closed (e.g. the last marker was workless) — the wake - // marker must REOPEN it via the shared single assignment. - let agent = app.agents.get_mut(&AgentId(0)).unwrap(); - agent.end_work_announced = false; - agent.wake_turn_start = Some(("task-completed-bg1".into(), 1_700_000_000_000)); - } + seed_two_bg_tasks(&mut app, "sess-wake"); + let len_before = app.agents[&AgentId(0)].scrollback.len(); let affected = handle_ext_notification( &xai_wake_turn_completed_notif( @@ -280,85 +244,48 @@ ), &mut app, ); - assert!(affected, "a wake marker on the active agent redraws"); + assert!(affected, "the wake back-to-idle point still redraws"); let agent = app.agents.get(&AgentId(0)).unwrap(); assert!( agent.session.state.is_idle(), "a wake turn is never adopted — the pager stays idle around it" ); - let block = last_marker_block(&agent.scrollback); assert_eq!( - block.marker_text(), - "Worked for 5.0s. 2 commands still running.", - "elapsed spans the delta-borne start to the terminal's shell clock" + agent.scrollback.len(), + len_before, + "a completed wake turn pushes no marker" ); - assert!(!block.parked); assert_eq!( - block.prompt_id.as_deref(), - Some("task-completed-bg1"), - "the marker carries the wake pid for hook attribution" - ); - assert!( - agent.end_work_announced, - "a counted wake marker reopens the between-turns status window" - ); - assert!( - agent.wake_turn_start.is_none(), - "the tracked start is consumed by its marker" + agent.watchers().commands, + 2, + "the running commands stay on the status-row watchers cue" ); } #[test] - fn wake_marker_without_tracked_start_omits_elapsed() { - // Old shells stamp no `turnStartMs` on deltas — the marker renders - // without a duration rather than lying with "0.0s". + fn wake_terminal_finishes_in_flight_streamed_entry() { + // A wake turn streams its response, then its terminal lands: the + // terminal is the ONLY flush site (wake turns skip PromptResponse), + // so the streamed entry must be finished — not left spinning until + // the next turn's stream start. Dead wakes take the same path. let mut app = make_app_with_agent("sess-wake"); - let _ = handle_ext_notification( - &make_task_backgrounded_notif("sess-wake", "tc-1", "task-1", "sleep 98"), + let _ = handle( + make_viewer_chunk_with_turn_start("sess-wake", "task-completed-bg1", 5_000), &mut app, ); + assert!( + app.agents[&AgentId(0)].scrollback.has_running_entries(), + "the streamed wake chunk opens a live entry" + ); let _ = handle_ext_notification( &xai_wake_turn_completed_notif("sess-wake", "task-completed-bg1", None), &mut app, ); - - let agent = app.agents.get(&AgentId(0)).unwrap(); - assert_eq!( - last_marker_block(&agent.scrollback).marker_text(), - "Turn completed. 1 command still running." - ); - } - - #[test] - fn zero_count_wake_marker_is_plain_and_closes_window() { - let mut app = make_app_with_agent("sess-wake"); - { - let agent = app.agents.get_mut(&AgentId(0)).unwrap(); - agent.end_work_announced = true; - agent.wake_turn_start = Some(("task-completed-bg1".into(), 1_700_000_000_000)); - } - - let _ = handle_ext_notification( - &xai_wake_turn_completed_notif( - "sess-wake", - "task-completed-bg1", - Some(1_700_000_000_000 + 2_000), - ), - &mut app, - ); - - let agent = app.agents.get(&AgentId(0)).unwrap(); - let block = last_marker_block(&agent.scrollback); - assert_eq!(block.marker_text(), "Worked for 2.0s."); assert!( - block.end_work.is_none(), - "zero counts → legacy plain marker" - ); - assert!( - !agent.end_work_announced, - "a workless wake marker proves nothing is running — window closed" + !app.agents[&AgentId(0)].scrollback.has_running_entries(), + "the wake terminal must finish the streamed entry" ); } @@ -420,8 +347,7 @@ fn failed_wake_turn_keeps_markerless_shape() { // "Worked for" would lie about an errored/cancelled wake turn, and // the cancel/failure UX is driver-side context this signal lacks — - // those stop reasons keep today's markerless shape. The status-line - // re-emit on this leg self-gates silent here (closed window, no work). + // those stop reasons keep today's markerless shape. let mut app = make_app_with_agent("sess-wake"); let len_before = app.agents[&AgentId(0)].scrollback.len(); @@ -440,29 +366,30 @@ } #[test] - fn dead_wake_reemits_skipped_work_status_line() { - // The shell's `will_wake` promise made the chip skip its status line; - // the wake then died markerless (cancelled) — nothing else marks the - // moment, so the terminal re-emits the work-only line. The window - // stays open: the line announces the same still-running work. + fn dead_wake_pushes_no_status_line() { let mut app = make_app_with_agent("sess-wake"); - seed_two_bg_tasks_and_announce(&mut app, "sess-wake"); + seed_two_bg_tasks(&mut app, "sess-wake"); + let len_before = app.agents[&AgentId(0)].scrollback.len(); - let affected = handle_ext_notification( + let _ = handle_ext_notification( &xai_turn_completed_notif("sess-wake", "task-completed-bg1", "cancelled", false), &mut app, ); - assert!(affected, "the re-emitted status line must redraw"); let agent = app.agents.get(&AgentId(0)).unwrap(); - assert_eq!( - work_status_lines(&agent.scrollback), - vec!["2 commands still running.".to_string()], - "the dead wake's terminal re-emits the skipped work-only line" - ); assert!( - agent.end_work_announced, - "a status line is not a marker — the window stays open" + work_status_lines(&agent.scrollback).is_empty(), + "a dead wake must not push a work-only status line" + ); + assert_eq!( + agent.scrollback.len(), + len_before, + "a dead wake pushes nothing" + ); + assert_eq!( + agent.watchers().commands, + 2, + "the still-running work feeds the status-row cue instead" ); } @@ -470,14 +397,13 @@ fn wake_terminal_during_local_turn_pushes_nothing() { // Wire interleave: wake turn W streams (pager idle), the user sends a // prompt locally (TurnRunning), then FIFO delivers W's terminal - // before the new turn's deltas. A foreign "Worked for" (or status - // line) under the fresh prompt would misattribute — the local turn's - // own marker carries the counts when it ends. + // before the new turn's deltas. A foreign "Worked for" under the + // fresh prompt would misattribute — the local turn pushes its own + // marker when it ends. let mut app = make_app_with_agent("sess-wake"); - seed_two_bg_tasks_and_announce(&mut app, "sess-wake"); + seed_two_bg_tasks(&mut app, "sess-wake"); { let agent = app.agents.get_mut(&AgentId(0)).unwrap(); - agent.wake_turn_start = Some(("task-completed-bg1".into(), 1_000)); agent.session.start_turn(&mut agent.scrollback); agent.session.current_prompt_id = Some("pid-local".into()); } @@ -499,18 +425,14 @@ agent.session.state.is_turn_running(), "the local turn is untouched" ); - assert!(agent.end_work_announced, "the skip leaves the window as-is"); - assert!( - agent.wake_turn_start.is_some(), - "the pid-scoped elapsed slot stays; it cannot misfire on other turns" - ); } #[test] - fn wake_marker_leaves_real_turn_stash_pending() { + fn wake_terminal_leaves_real_turn_stash_pending() { // Stop-hook stash semantics belong to real turns: a stash stamped - // with a REAL turn's pid must survive a wake marker untouched — no - // fold, no standalone flush — and wait for its own marker rail. + // with a REAL turn's pid must survive a wake turn's (markerless) + // terminal untouched — no fold, no standalone flush — and wait for + // its own marker rail. use crate::scrollback::blocks::tool::{HookRunEntry, HookRunStatus}; let mut app = make_app_with_agent("sess-wake"); { @@ -529,6 +451,7 @@ )], }); } + let len_before = app.agents[&AgentId(0)].scrollback.len(); let _ = handle_ext_notification( &xai_wake_turn_completed_notif("sess-wake", "task-completed-bg1", None), @@ -537,9 +460,9 @@ let agent = app.agents.get(&AgentId(0)).unwrap(); assert_eq!( - last_marker_stop_hook_groups(&agent.scrollback), - Some(0), - "a real turn's stash must not attach to the wake marker" + agent.scrollback.len(), + len_before, + "a wake terminal pushes nothing (no marker, no stash flush)" ); assert_eq!(count_lifecycle_blocks(&agent.scrollback), 0); assert!( @@ -837,45 +760,40 @@ } #[test] - fn between_turns_completion_reemits_work_status_until_zero() { - let mut app = make_app_with_agent("sess-reemit"); - seed_two_bg_tasks_and_announce(&mut app, "sess-reemit"); + fn between_turns_completion_pushes_chip_only() { + let mut app = make_app_with_agent("sess-chip-only"); + seed_two_bg_tasks(&mut app, "sess-chip-only"); assert!(app.agents[&AgentId(0)].session.state.is_idle()); + assert_eq!(app.agents[&AgentId(0)].watchers().commands, 2); - // First completion: chip, then a fresh status line with the rest. let _ = handle_ext_notification( - &make_task_completed_notif("sess-reemit", "task-1", "sleep 98", Some(0)), + &make_task_completed_notif("sess-chip-only", "task-1", "sleep 98", Some(0)), &mut app, ); let agent = app.agents.get(&AgentId(0)).unwrap(); + assert!( + work_status_lines(&agent.scrollback).is_empty(), + "no work-only status line after a between-turns completion" + ); assert_eq!( - work_status_lines(&agent.scrollback), - vec!["1 command still running.".to_string()], - "the completion re-emits the remaining-work status line" - ); - let tail_is_status = matches!( - agent.scrollback.get(agent.scrollback.len() - 1).map(|e| &e.block), - Some(RenderBlock::System(b)) if b.text.contains("still running") - ); - assert!(tail_is_status, "the status line lands AFTER the chip"); - - // Last completion: chip only — zero left closes the story. - let _ = handle_ext_notification( - &make_task_completed_notif("sess-reemit", "task-2", "sleep 99", Some(0)), - &mut app, - ); - let agent = app.agents.get(&AgentId(0)).unwrap(); - assert_eq!( - work_status_lines(&agent.scrollback).len(), + agent.watchers().commands, 1, - "zero remaining work must not add a status line" + "the status-row cue counts down instead" ); + + let _ = handle_ext_notification( + &make_task_completed_notif("sess-chip-only", "task-2", "sleep 99", Some(0)), + &mut app, + ); + let agent = app.agents.get(&AgentId(0)).unwrap(); + assert!(work_status_lines(&agent.scrollback).is_empty()); + assert_eq!(agent.watchers().commands, 0, "zero left — cue disappears"); } #[test] fn mid_turn_completion_pushes_chip_only() { let mut app = make_app_with_agent("sess-midturn"); - seed_two_bg_tasks_and_announce(&mut app, "sess-midturn"); + seed_two_bg_tasks(&mut app, "sess-midturn"); { let agent = app.agents.get_mut(&AgentId(0)).unwrap(); agent.session.start_turn(&mut agent.scrollback); @@ -893,51 +811,37 @@ } #[test] - fn unannounced_completion_pushes_chip_only() { - // No turn-end marker announced work (e.g. a fresh attach): the - // between-turns window is closed and completions stay chip-only. - let mut app = make_app_with_agent("sess-unannounced"); - seed_two_bg_tasks_and_announce(&mut app, "sess-unannounced"); - app.agents.get_mut(&AgentId(0)).unwrap().end_work_announced = false; - + fn subagent_finished_between_turns_pushes_no_status_line() { + let mut app = make_app_with_parent_and_child("sess-sub-quiet", "child-1"); let _ = handle_ext_notification( - &make_task_completed_notif("sess-unannounced", "task-1", "sleep 98", Some(0)), + &make_task_backgrounded_notif("sess-sub-quiet", "tc-1", "task-1", "sleep 98"), &mut app, ); - assert!( - work_status_lines(&app.agents[&AgentId(0)].scrollback).is_empty(), - "an unannounced window must not spawn status lines" - ); - } - - #[test] - fn subagent_finished_between_turns_reemits_work_status() { - let mut app = make_app_with_parent_and_child("sess-sub-reemit", "child-1"); - let _ = handle_ext_notification( - &make_task_backgrounded_notif("sess-sub-reemit", "tc-1", "task-1", "sleep 98"), - &mut app, - ); - app.agents.get_mut(&AgentId(0)).unwrap().end_work_announced = true; let _ = handle( - make_ext_session_notification("sess-sub-reemit", test_subagent_finished("child-1")), + make_ext_session_notification("sess-sub-quiet", test_subagent_finished("child-1")), &mut app, ); + let agent = app.agents.get(&AgentId(0)).unwrap(); + assert!( + work_status_lines(&agent.scrollback).is_empty(), + "a finished subagent pushes no work-only status line" + ); assert_eq!( - work_status_lines(&app.agents[&AgentId(0)].scrollback), - vec!["1 command still running.".to_string()], - "a finished subagent re-emits the remaining-work status line" + agent.watchers().commands, + 1, + "the remaining bg command stays on the status-row cue" ); } #[test] - fn will_wake_completion_skips_work_status_line() { - // The shell stamped `will_wake`: a wake response follows the chip and - // its end marker carries the fresh counts — the after-chip work-only - // line would duplicate them. + fn will_wake_flag_is_ignored_wire_compat_pin() { + // `will_wake` is a wire-compat field the TUI no longer reads: a + // stamped completion must behave exactly like an unstamped one + // (chip-only). Pins the "ignored, not load-bearing" contract. let mut app = make_app_with_agent("sess-wake-skip"); - seed_two_bg_tasks_and_announce(&mut app, "sess-wake-skip"); + seed_two_bg_tasks(&mut app, "sess-wake-skip"); let _ = handle_ext_notification( &task_completed_notif("sess-wake-skip", "task-1", "sleep 98", Some(0), None, true), @@ -949,69 +853,13 @@ work_status_lines(&agent.scrollback).is_empty(), "a wake-bound completion pushes its chip only" ); - assert!( - agent.end_work_announced, - "the skip leaves the window to the wake marker" - ); - } - - #[test] - fn legacy_task_completed_without_will_wake_field_emits_status() { - // Old shells don't stamp the field — missing reads as `false`, so the - // skew degrades to a transient duplicate line at worst, never a lost - // status. - let mut app = make_app_with_agent("sess-legacy"); - seed_two_bg_tasks_and_announce(&mut app, "sess-legacy"); - - let notif = make_task_completed_notif("sess-legacy", "task-1", "sleep 98", Some(0)); - let mut v: serde_json::Value = serde_json::from_str(notif.params.get()).unwrap(); - v["update"] - .as_object_mut() - .unwrap() - .remove("will_wake") - .expect("the typed builder stamps the field"); - let legacy = acp::ExtNotification::new( - "x.ai/task_completed", - std::sync::Arc::from(serde_json::value::to_raw_value(&v).unwrap()), - ); - let _ = handle_ext_notification(&legacy, &mut app); - - assert_eq!( - work_status_lines(&app.agents[&AgentId(0)].scrollback), - vec!["1 command still running.".to_string()], - "a stamp-less completion keeps the no-wake fallback line" - ); - } - - #[test] - fn will_wake_subagent_finished_skips_work_status_line() { - let mut app = make_app_with_parent_and_child("sess-sub-skip", "child-1"); - let _ = handle_ext_notification( - &make_task_backgrounded_notif("sess-sub-skip", "tc-1", "task-1", "sleep 98"), - &mut app, - ); - app.agents.get_mut(&AgentId(0)).unwrap().end_work_announced = true; - - let _ = handle( - make_ext_session_notification( - "sess-sub-skip", - test_subagent_finished_with_wake("child-1", true), - ), - &mut app, - ); - - assert!( - work_status_lines(&app.agents[&AgentId(0)].scrollback).is_empty(), - "a wake-bound subagent completion pushes no status line" - ); } #[test] fn child_session_completions_never_spam_root_status() { // A background subagent's own task traffic routes to the CHILD view; - // it never counts toward the root marker, so its completions must - // not push root status lines — no matter how many land in the open - // between-turns window. + // it never counts toward the root's watchers, so its completions must + // not push root status lines. let mut app = make_app_with_parent_and_child("sess-child-quiet", "child-1"); let _ = handle_ext_notification( &make_task_backgrounded_notif("child-1", "tc-c1", "task-c1", "sleep 97"), @@ -1021,7 +869,6 @@ &make_task_backgrounded_notif("child-1", "tc-c2", "task-c2", "sleep 98"), &mut app, ); - app.agents.get_mut(&AgentId(0)).unwrap().end_work_announced = true; assert!(app.agents[&AgentId(0)].session.state.is_idle()); let _ = handle_ext_notification( @@ -1185,48 +1032,14 @@ } #[test] - fn wake_stop_hooks_during_local_turn_stash_under_wake_pid() { - // A wake batch while a local turn runs keys to its OWN wake pid, never - // the local turn — else a late wake stop would fold onto an unrelated - // turn's marker. - let mut app = make_app_with_agent("sess-wake-park"); - { - let agent = app.agents.get_mut(&AgentId(0)).unwrap(); - agent.session.start_turn(&mut agent.scrollback); - agent.session.current_prompt_id = Some("pid-main".into()); - } - - let _ = handle_ext_notification( - &xai_hook_execution_notif_for_prompt( - "sess-wake-park", - "stop", - Some("task-completed-bg1"), - false, - ), - &mut app, - ); - - let agent = app.agents.get(&AgentId(0)).unwrap(); - assert_eq!(count_lifecycle_blocks(&agent.scrollback), 0); - let pending = agent - .pending_stop_hooks - .as_ref() - .expect("wake batch stashes for its own end marker"); - assert_eq!( - pending.prompt_id.as_deref(), - Some("task-completed-bg1"), - "keyed to the wake pid, not the running local turn" - ); - assert_eq!(pending.groups.len(), 1); - assert_eq!(pending.groups[0].0, "stop"); - } - - #[test] - fn wake_stop_hooks_idle_stash_for_wake_marker() { - // Idle wake turn (non-adopted) whose hook beats its own TurnCompleted → - // stashes under the wake pid for `push_wake_end_marker`, not standalone. + fn wake_stop_hooks_render_standalone_at_arrival() { + // Wake turns close markerless, so a wake-pid stop batch has no marker + // to fold into — it renders standalone the moment it arrives, whether + // it beats or trails its wake TurnCompleted. Never stashed: a stash + // keyed to a wake pid would wait for a marker that never comes. let mut app = make_app_with_agent("sess-wake-idle"); + // Hook beats the wake terminal. let _ = handle_ext_notification( &xai_hook_execution_notif_for_prompt( "sess-wake-idle", @@ -1236,149 +1049,64 @@ ), &mut app, ); - let agent = app.agents.get(&AgentId(0)).unwrap(); - assert_eq!( - count_lifecycle_blocks(&agent.scrollback), - 0, - "an idle wake stop batch must stash, not flush a standalone block" - ); - let pending = agent - .pending_stop_hooks - .as_ref() - .expect("wake batch stashes for its own end marker"); - assert_eq!( - pending.prompt_id.as_deref(), - Some("notifications-019f-abc"), - "keyed to the wake pid so push_wake_end_marker folds it" - ); - assert_eq!(pending.groups.len(), 1); - assert_eq!(pending.groups[0].0, "stop"); - } - - #[test] - fn wake_stop_hooks_marker_first_attach() { - // Idle wake turn whose end marker lands before its hook → the hook - // attaches to the marker, not stash (guards the marker-arm-before-stash - // routing order). - let mut app = make_app_with_agent("sess-wake-marker1st"); - - let _ = handle_ext_notification( - &xai_wake_turn_completed_notif("sess-wake-marker1st", "task-completed-bg1", None), - &mut app, - ); - assert_eq!( - last_marker_stop_hook_groups(&app.agents[&AgentId(0)].scrollback), - Some(0), - "wake marker starts without hooks" - ); - - let _ = handle_ext_notification( - &xai_hook_execution_notif_for_prompt( - "sess-wake-marker1st", - "stop", - Some("task-completed-bg1"), - false, - ), - &mut app, - ); - - let agent = app.agents.get(&AgentId(0)).unwrap(); - assert_eq!( - last_marker_stop_hook_groups(&agent.scrollback), - Some(1), - "the wake stop hook must merge into the wake marker already on screen" - ); - assert_eq!( - count_lifecycle_blocks(&agent.scrollback), - 0, - "no standalone block" - ); - assert!( - agent.pending_stop_hooks.is_none(), - "nothing left stashed once it attached" - ); - } - - #[test] - fn late_wake_stop_attaches_to_wake_marker_not_running_local_turn() { - // A wake turn finished (its marker is on screen); a new local turn is - // running when the wake's delayed stop hook lands. It attaches to the - // wake marker, never folding onto the unrelated local turn. - let mut app = make_app_with_agent("sess-late-wake"); - - let _ = handle_ext_notification( - &xai_wake_turn_completed_notif("sess-late-wake", "task-completed-bg1", None), - &mut app, - ); - assert_eq!( - last_marker_stop_hook_groups(&app.agents[&AgentId(0)].scrollback), - Some(0) - ); - - { - let agent = app.agents.get_mut(&AgentId(0)).unwrap(); - agent.session.start_turn(&mut agent.scrollback); - agent.session.current_prompt_id = Some("pid-L".into()); - } - - let _ = handle_ext_notification( - &xai_hook_execution_notif_for_prompt( - "sess-late-wake", - "stop", - Some("task-completed-bg1"), - false, - ), - &mut app, - ); - - let agent = app.agents.get(&AgentId(0)).unwrap(); - assert_eq!( - last_marker_stop_hook_groups(&agent.scrollback), - Some(1), - "the late wake stop attaches to the wake marker" - ); - assert!( - agent.pending_stop_hooks.is_none(), - "it must not stash onto the running local turn L" - ); - assert_eq!(count_lifecycle_blocks(&agent.scrollback), 0); - } - - #[test] - fn wake_repeat_stop_after_marker_renders_standalone() { - // A duplicate same-name wake stop, after its marker already folded the - // first, renders standalone immediately — not stashed for a marker that - // will never come (which would defer it to a stale flush). - let mut app = make_app_with_agent("sess-wake-dup"); - - let _ = handle_ext_notification( - &xai_wake_turn_completed_notif("sess-wake-dup", "task-completed-bg1", None), - &mut app, - ); - for _ in 0..2 { - let _ = handle_ext_notification( - &xai_hook_execution_notif_for_prompt( - "sess-wake-dup", - "stop", - Some("task-completed-bg1"), - false, - ), - &mut app, - ); - } - - let agent = app.agents.get(&AgentId(0)).unwrap(); - assert_eq!( - last_marker_stop_hook_groups(&agent.scrollback), - Some(1), - "the first stop folded onto the wake marker" - ); assert_eq!( count_lifecycle_blocks(&agent.scrollback), 1, - "the repeat renders standalone, not deferred" + "a wake stop batch renders standalone at arrival" ); + assert!(agent.pending_stop_hooks.is_none(), "never stashed"); + + // Hook trails the wake terminal — same standalone shape. + let _ = handle_ext_notification( + &xai_wake_turn_completed_notif("sess-wake-idle", "task-completed-bg1", None), + &mut app, + ); + let _ = handle_ext_notification( + &xai_hook_execution_notif_for_prompt( + "sess-wake-idle", + "stop", + Some("task-completed-bg1"), + false, + ), + &mut app, + ); + let agent = app.agents.get(&AgentId(0)).unwrap(); + assert_eq!(count_lifecycle_blocks(&agent.scrollback), 2); assert!(agent.pending_stop_hooks.is_none()); } + #[test] + fn wake_stop_hooks_never_stash_under_local_turn() { + // A wake batch landing while a LOCAL turn runs must not stash under + // (or fold onto) the unrelated local turn — it renders standalone, + // and the local turn's marker rail stays clean. + let mut app = make_app_with_agent("sess-wake-local"); + { + let agent = app.agents.get_mut(&AgentId(0)).unwrap(); + agent.session.start_turn(&mut agent.scrollback); + agent.session.current_prompt_id = Some("pid-main".into()); + } + + let _ = handle_ext_notification( + &xai_hook_execution_notif_for_prompt( + "sess-wake-local", + "stop", + Some("task-completed-bg1"), + false, + ), + &mut app, + ); + + let agent = app.agents.get(&AgentId(0)).unwrap(); + assert_eq!( + count_lifecycle_blocks(&agent.scrollback), + 1, + "the wake batch renders standalone under the running local turn" + ); + assert!( + agent.pending_stop_hooks.is_none(), + "it must not stash onto the running local turn" + ); + } + diff --git a/crates/codegen/xai-grok-pager/src/app/actions.rs b/crates/codegen/xai-grok-pager/src/app/actions.rs index 652b40a..90f3f09 100644 --- a/crates/codegen/xai-grok-pager/src/app/actions.rs +++ b/crates/codegen/xai-grok-pager/src/app/actions.rs @@ -515,6 +515,8 @@ pub enum Action { SetTimestamps(bool), /// Set timeline sidebar visibility (per-turn tick rail). SetTimeline(bool), + /// Set `[ui].page_flip_on_send` (default ON). Persists via `Effect::PersistSetting`. + SetPageFlipOnSend(bool), /// Set simple mode (ASCII / minimal glyphs). Persists via `Effect::PersistSetting`. SetSimpleMode(bool), /// Set the per-tip contextual-hint user config (`[ui.contextual_hints]`). @@ -778,8 +780,6 @@ pub enum Action { DashboardCommitRename, /// Cancel an in-progress rename without committing. DashboardCancelRename, - /// Apply a single keystroke to the in-progress rename draft. - DashboardRenameInput(String), /// Stop / kill the selected row (top-level: cancel turn → close; /// subagent: kill). Double-press protected for top-level rows. DashboardStop, @@ -1863,6 +1863,8 @@ pub enum Effect { agent_id: AgentId, session_id: acp::SessionId, question: String, + /// Correlates minimal responses; fullscreen leaves this unset. + minimal_request_id: Option, }, /// Request a session recap via the x.ai/recap ext method. Fire-and-forget: /// the recap arrives later as a `SessionRecap` notification. @@ -1908,8 +1910,8 @@ pub enum Effect { method_id: acp::AuthMethodId, use_oauth: bool, }, - /// Clear the "copied!" feedback after a delay. - ScheduleClearAuthCopied, + /// Clear the auth copy feedback after a delay if its generation is still current. + ScheduleClearAuthCopyFeedback { generation: u64 }, /// Register the current session in the active-sessions crash-recovery /// registry (`~/.grok/active_sessions.json`). RegisterActiveSession { @@ -2542,6 +2544,8 @@ pub enum TaskResult { BtwResponse { agent_id: AgentId, result: Result, + /// Correlates minimal responses; fullscreen leaves this unset. + minimal_request_id: Option, }, /// `x.ai/recap` request acknowledged (fire-and-forget). The recap itself /// arrives separately as a `SessionRecap` notification; this only carries @@ -2597,8 +2601,10 @@ pub enum TaskResult { GateVerifyTimeout { generation: u64, }, - /// The 2-second "copied!" display timer expired. - AuthCopiedTimeout, + /// The 2-second auth copy feedback timer expired. + AuthCopyFeedbackTimeout { + generation: u64, + }, DeepSearchResults { results: Vec, seq: u64, diff --git a/crates/codegen/xai-grok-pager/src/app/agent_view/input.rs b/crates/codegen/xai-grok-pager/src/app/agent_view/input.rs index 35aad31..1eb12f3 100644 --- a/crates/codegen/xai-grok-pager/src/app/agent_view/input.rs +++ b/crates/codegen/xai-grok-pager/src/app/agent_view/input.rs @@ -236,6 +236,86 @@ impl AgentView { ) -> InputOutcome { self.handle_input_inner(ev, registry, true) } + /// Route minimal-only `/btw` ownership before the unchanged shared router. + pub(in crate::app) fn handle_minimal_input( + &mut self, + ev: &Event, + registry: &ActionRegistry, + ) -> InputOutcome { + match self.handle_minimal_btw_input(ev) { + crate::minimal_api::MinimalBtwInput::Handled(outcome) => *outcome, + crate::minimal_api::MinimalBtwInput::Occluded => { + let jump_dismissed = self.dismiss_jump_picker_if_suppressed(); + let suspended = crate::minimal_api::suspend_minimal_btw(self); + let outcome = if jump_dismissed + && matches!( + ev, Event::Key(key) if key.kind != KeyEventKind::Release && key + .code == KeyCode::Esc && key.modifiers.is_empty() + ) { + InputOutcome::Changed + } else { + self.handle_input(ev, registry) + }; + if let Some(suspended) = suspended { + crate::minimal_api::restore_minimal_btw(self, suspended); + } + outcome + } + crate::minimal_api::MinimalBtwInput::Delegate => self.handle_input(ev, registry), + } + } + /// Handle only minimal `/btw` dismissal and keyboard scrolling. + fn handle_minimal_btw_input(&mut self, ev: &Event) -> crate::minimal_api::MinimalBtwInput { + use crate::minimal_api::MinimalBtwInput::{Delegate, Handled, Occluded}; + if !crate::minimal_api::minimal_btw_surface_available(self) { + return Occluded; + } + if let Event::Key(key) = ev + && key.kind != KeyEventKind::Release + && key.code == KeyCode::Esc + && key.modifiers.is_empty() + && self.btw_state.is_some() + { + return Handled(Box::new(self.dismiss_btw_panel())); + } + if self.active_pane != AgentPane::Prompt + || !self.btw_focused + || !crate::minimal_api::minimal_btw_geometry_is_paintable(self.last_btw_area) + { + return Delegate; + } + let Some(btw_scroll_max) = self.btw_state.as_ref().and_then(|btw| { + matches!(btw, crate::views::btw_overlay::BtwOverlayState::Done { .. }).then(|| { + let content_width = self.last_btw_area.width.saturating_sub(4) as usize; + let max_body = self.last_btw_area.height.saturating_sub(2) as usize; + btw.max_scroll_offset(content_width, max_body) + }) + }) else { + return Delegate; + }; + if btw_scroll_max == 0 { + return Delegate; + } + let Event::Key(key) = ev else { + return Delegate; + }; + if key.kind == KeyEventKind::Release || !key.modifiers.is_empty() { + return Delegate; + } + let page = self.last_btw_area.height.saturating_sub(2).max(1) as usize; + let Some(btw) = self.btw_state.as_mut() else { + return Delegate; + }; + match key.code { + KeyCode::Up => btw.scroll_up(1), + KeyCode::Down => btw.scroll_down(1, btw_scroll_max), + KeyCode::PageUp => btw.scroll_up(page), + KeyCode::PageDown => btw.scroll_down(page, btw_scroll_max), + _ => return Delegate, + } + self.clear_btw_drag_state(); + Handled(Box::new(InputOutcome::Changed)) + } fn handle_input_inner( &mut self, ev: &Event, @@ -408,7 +488,7 @@ impl AgentView { { return InputOutcome::Changed; } - if let Event::Mouse(_) = ev { + if matches!(ev, Event::Mouse(_) | Event::Paste(_)) { return InputOutcome::Changed; } } @@ -496,6 +576,17 @@ impl AgentView { } self.handle_line_viewer_key(key) } + Event::Paste(text) => { + self.line_viewer + .as_mut() + .map_or(InputOutcome::Unchanged, |viewer| { + if viewer.list_state.handle_paste(text, &viewer.lines) { + InputOutcome::Changed + } else { + InputOutcome::Unchanged + } + }) + } Event::Mouse(mouse) => self.handle_line_viewer_mouse(mouse), _ => InputOutcome::Changed, }; @@ -549,6 +640,7 @@ impl AgentView { self.handle_persona_detail_key(key) } Event::Mouse(mouse) => self.handle_persona_detail_mouse(mouse), + Event::Paste(text) => self.handle_persona_detail_paste(text), _ => InputOutcome::Changed, }; } @@ -561,6 +653,7 @@ impl AgentView { self.handle_agents_modal_key(key) } Event::Mouse(mouse) => self.handle_agents_modal_mouse(mouse), + Event::Paste(text) => self.handle_agents_modal_paste(text), _ => InputOutcome::Changed, }; } @@ -573,6 +666,17 @@ impl AgentView { self.handle_block_viewer_key(key) } Event::Mouse(mouse) => self.handle_block_viewer_mouse(mouse), + Event::Paste(text) => { + self.block_viewer + .as_mut() + .map_or(InputOutcome::Unchanged, |viewer| { + if viewer.handle_paste(text) { + InputOutcome::Changed + } else { + InputOutcome::Unchanged + } + }) + } _ => InputOutcome::Changed, }; } @@ -585,6 +689,7 @@ impl AgentView { self.handle_modal_key(key) } Event::Mouse(mouse) => self.handle_modal_mouse(mouse), + Event::Paste(text) => self.handle_modal_paste(text), _ => InputOutcome::Changed, }; } @@ -674,7 +779,17 @@ impl AgentView { } self.handle_plan_feedback_key(key) } - Event::Paste(text) => self.route_popup_paste(text), + Event::Paste(text) => { + if self + .plan_approval_view + .as_ref() + .is_some_and(|view| view.focus != PlanApprovalFocus::Preview) + { + self.route_popup_paste(text) + } else { + InputOutcome::Unchanged + } + } Event::Mouse(mouse) => { let mut changed = false; match mouse.kind { @@ -826,12 +941,10 @@ impl AgentView { AgentPane::Catalog => self.handle_catalog_key(key, registry), }, Event::Paste(text) => { - if self.active_pane != AgentPane::Prompt - && self.session.state.is_idle() - && self.active_modal.is_none() - && self.question_view.is_none() + if self.active_pane == AgentPane::Scrollback + && let Some(outcome) = self.handle_scrollback_search_paste(text) { - self.set_active_pane(AgentPane::Prompt, false); + return outcome; } if self.active_pane == AgentPane::Prompt { self.ephemeral_tip @@ -862,7 +975,18 @@ impl AgentView { let _ = synchronous_text_insertion; outcome } else { - InputOutcome::Unchanged + let consumed = match self.active_pane { + AgentPane::Todo => self.todo.handle_paste(text), + AgentPane::Tasks => self.tasks.handle_paste(text), + AgentPane::Catalog => self.catalog.handle_paste(text), + AgentPane::Queue => self.queue.handle_paste(text), + AgentPane::Prompt | AgentPane::Scrollback => false, + }; + if consumed { + InputOutcome::Changed + } else { + InputOutcome::Unchanged + } } } Event::Mouse(mouse) => self.handle_mouse(mouse), @@ -1234,6 +1358,29 @@ mod btw_focus_tests { .expect("btw panel present") .scroll_offset() } + fn minimal_btw_agent() -> AgentView { + let mut agent = prompt_focused_agent(); + let request_id = crate::minimal_api::start_minimal_btw(&mut agent, "q".into()); + assert!(crate::minimal_api::finish_minimal_btw( + &mut agent, + request_id, + Ok(long_btw_answer()) + )); + agent + } + fn assert_minimal_btw_active(agent: &AgentView, surface: &str) { + assert!( + agent.btw_state.is_some(), + "{surface} Esc must leave the latent /btw panel intact" + ); + assert!( + matches!( + agent.minimal_btw_lifecycle, + Some(crate::minimal_api::MinimalBtwLifecycle::Active { .. }) + ), + "{surface} Esc must restore the complete minimal /btw lifecycle" + ); + } #[test] fn focused_panel_scrolls_with_arrows() { let mut agent = prompt_focused_agent(); @@ -1355,6 +1502,103 @@ mod btw_focus_tests { assert!(agent.btw_state.is_none(), "Esc dismisses the /btw panel"); assert!(!agent.btw_focused, "dismissing the panel clears its focus"); } + #[test] + fn minimal_permission_owns_esc_over_hidden_btw() { + let mut agent = minimal_btw_agent(); + let reg = ActionRegistry::defaults(); + agent + .permission_queue + .push_back(super::paste_key_tests::make_followup_permission_state()); + agent.handle_minimal_input(&key(KeyCode::Esc), ®); + assert_minimal_btw_active(&agent, "permission"); + assert_eq!( + agent.permission_queue.len(), + 1, + "Esc preserves the pending permission" + ); + assert_eq!( + agent + .permission_queue + .front() + .map(|permission| permission.focus), + Some(crate::views::permission_view::PermissionFocus::Options), + "permission handled Esc by returning focus to options" + ); + } + #[test] + fn minimal_modal_and_viewers_own_esc_over_hidden_btw() { + let reg = ActionRegistry::defaults(); + let mut agents = minimal_btw_agent(); + agents.agents_modal = Some(crate::views::agents_modal::AgentsModalState::new( + std::path::Path::new("/nonexistent"), + &std::collections::HashMap::new(), + &crate::app::bundle::BundleState::default(), + None, + None, + )); + agents.handle_minimal_input(&key(KeyCode::Esc), ®); + assert!(agents.agents_modal.is_none(), "agents modal handled Esc"); + assert_minimal_btw_active(&agents, "agents modal"); + let mut block = minimal_btw_agent(); + block.block_viewer = Some(crate::views::block_viewer::BlockViewerPane::for_plain_text( + "t", "content", + )); + block.handle_minimal_input(&key(KeyCode::Esc), ®); + assert!(block.block_viewer.is_none(), "block viewer handled Esc"); + assert_minimal_btw_active(&block, "block viewer"); + let mut video = minimal_btw_agent(); + video.video_viewer = Some(crate::prompt_images::VideoViewerState::test_stub()); + video.handle_minimal_input(&key(KeyCode::Esc), ®); + assert!(video.video_viewer.is_none(), "video viewer handled Esc"); + assert_minimal_btw_active(&video, "video viewer"); + let mut goal = minimal_btw_agent(); + goal.goal_state = Some(crate::app::agent::GoalDisplayState::test_stub()); + goal.show_goal_detail = true; + goal.handle_minimal_input(&key(KeyCode::Esc), ®); + assert!(!goal.show_goal_detail, "goal detail handled Esc"); + assert_minimal_btw_active(&goal, "goal detail"); + } + #[test] + fn minimal_btw_surface_owner_covers_shared_modal_cascade() { + let mut agent = minimal_btw_agent(); + assert!(crate::minimal_api::minimal_btw_surface_available(&agent)); + agent.image_viewer = Some( + crate::prompt_images::ImageViewerState::open_from_path_deferred(std::path::Path::new( + "x.png", + )), + ); + assert!(!crate::minimal_api::minimal_btw_surface_available(&agent)); + agent.image_viewer = None; + agent.gboom = Some(crate::gboom::GboomState::new()); + assert!(!crate::minimal_api::minimal_btw_surface_available(&agent)); + agent.gboom = None; + agent.block_viewer = Some(crate::views::block_viewer::BlockViewerPane::for_plain_text( + "t", "content", + )); + assert!(!crate::minimal_api::minimal_btw_surface_available(&agent)); + } + #[test] + fn fullscreen_keeps_btw_first_esc_precedence() { + let mut agent = prompt_focused_agent(); + let reg = ActionRegistry::defaults(); + agent.btw_state = Some(BtwOverlayState::done("q".into(), long_btw_answer())); + agent + .permission_queue + .push_back(super::paste_key_tests::make_followup_permission_state()); + agent.handle_input(&key(KeyCode::Esc), ®); + assert!(agent.btw_state.is_none()); + assert!(!agent.permission_queue.is_empty()); + } + #[test] + fn minimal_does_not_scroll_unpainted_btw_geometry() { + let mut agent = prompt_focused_agent(); + let reg = ActionRegistry::defaults(); + agent.btw_state = Some(BtwOverlayState::done("q".into(), long_btw_answer())); + agent.btw_focused = true; + agent.last_btw_area = Rect::default(); + agent.handle_minimal_input(&key(KeyCode::Down), ®); + assert_eq!(done_scroll_offset(&agent), 0); + } /// A hidden `/jump` picker shadowed by the `/btw` panel must not let one Esc /// close both: the first Esc drops the shadowed picker (and is spent there), /// the panel survives, and only a second Esc dismisses it. @@ -1622,3 +1866,38 @@ mod voice_stop_click_during_plan_review_tests { ); } } +#[cfg(test)] +mod rich_textarea_paste_routing_tests { + use super::test_fixtures::make_agent; + use crate::actions::ActionRegistry; + use crate::app::inline_edit::InlineEditState; + use crate::scrollback::entry::EntryId; + use crossterm::event::Event; + use xai_ratatui_textarea::{TextArea, TextAreaState}; + #[test] + fn inline_edit_receives_raw_multiline_paste_without_touching_prompt() { + let mut agent = make_agent(); + agent.prompt.set_text("hidden prompt"); + let mut textarea = TextArea::new(); + textarea.set_text("ab"); + textarea.set_cursor(1); + agent.inline_edit = Some(InlineEditState { + entry_id: EntryId::new(1), + prompt_index: 0, + original: "ab".to_owned(), + textarea, + textarea_state: TextAreaState::default(), + last_text_area: None, + last_rect: None, + }); + let _ = agent.handle_input( + &Event::Paste("中\nline".to_owned()), + &ActionRegistry::defaults(), + ); + assert_eq!( + agent.inline_edit.as_ref().map(|edit| edit.textarea.text()), + Some("a中\nlineb") + ); + assert_eq!(agent.prompt.text(), "hidden prompt"); + } +} diff --git a/crates/codegen/xai-grok-pager/src/app/agent_view/links.rs b/crates/codegen/xai-grok-pager/src/app/agent_view/links.rs index 0b16cff..ad490c4 100644 --- a/crates/codegen/xai-grok-pager/src/app/agent_view/links.rs +++ b/crates/codegen/xai-grok-pager/src/app/agent_view/links.rs @@ -1553,8 +1553,16 @@ mod link_click_tests { let registry = ActionRegistry::defaults(); let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE); let outcome = agent.handle_scrollback_key(&enter, ®istry); - assert!(matches!(outcome, InputOutcome::Changed), "got {outcome:?}"); - assert!(agent.inline_edit.is_some(), "Enter must start inline edit"); + if crate::app::inline_edit::INLINE_EDIT_ENABLED { + assert!(matches!(outcome, InputOutcome::Changed), "got {outcome:?}"); + assert!(agent.inline_edit.is_some(), "Enter must start inline edit"); + } else { + assert!(agent.inline_edit.is_none(), "feature gated off: no edit"); + assert!( + matches!(outcome, InputOutcome::Action(Action::OpenBlockViewer)), + "gated off: Enter must fall through to OpenBlockViewer, got {outcome:?}" + ); + } } /// Bash prompts are not inline-editable: Enter falls through to the /// registry (OpenBlockViewer) exactly as before. @@ -1575,8 +1583,10 @@ mod link_click_tests { "expected fall-through to OpenBlockViewer, got {outcome:?}" ); } - /// Double-click on a user prompt enters inline edit mode (replacing the - /// old fold-toggle for editable prompts). + /// Double-click on a user prompt: enters inline edit when the feature is + /// enabled; while gated off it does NOT edit (falls through to the fold + /// arm), leaving the prompt free for text selection. Written for both flag + /// states so it stays valid when INLINE_EDIT_ENABLED is flipped back on. #[test] fn double_click_on_user_prompt_enters_inline_edit() { let mut agent = make_agent(); @@ -1592,10 +1602,17 @@ mod link_click_tests { let now = std::time::Instant::now(); (agent.last_click, _) = agent.handle_scrollback_click(now, 0, false); let _ = agent.handle_scrollback_click(now + std::time::Duration::from_millis(10), 0, false); - assert!( - agent.inline_edit.is_some(), - "double-click must start inline edit" - ); + if crate::app::inline_edit::INLINE_EDIT_ENABLED { + assert!( + agent.inline_edit.is_some(), + "double-click must start inline edit" + ); + } else { + assert!( + agent.inline_edit.is_none(), + "feature gated off: double-click must not edit" + ); + } } #[test] fn enter_on_subagent_group_header_falls_through_to_group_toggle() { diff --git a/crates/codegen/xai-grok-pager/src/app/agent_view/mod.rs b/crates/codegen/xai-grok-pager/src/app/agent_view/mod.rs index 8929d1c..54c92c4 100644 --- a/crates/codegen/xai-grok-pager/src/app/agent_view/mod.rs +++ b/crates/codegen/xai-grok-pager/src/app/agent_view/mod.rs @@ -673,16 +673,19 @@ pub(crate) enum AgentDeferredSend { Interject, } /// How the parked-marker slot was consumed. Both variants carry the turn's -/// prompt id and both keep the parked (idle) chrome. `Rendered` markers keep -/// flowing on tail staleness (see `maybe_push_parked_marker`); `Forgone` (an +/// prompt id and both keep the parked (idle) chrome. `Rendered` markers are +/// one-per-park-episode — a re-park after new parent output (epoch bump) +/// pushes a fresh one (see `maybe_push_parked_marker`); `Forgone` (an /// interjection continued the parked turn) is final — a later "Worked for" /// line would land below the interjected message, flipping the transcript. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum ParkedMarkerSlot { - /// A "Worked for X. … still running." marker block was pushed. + /// A "Worked for X" marker block was pushed. Rendered { prompt_id: String, - entry_id: EntryId, + /// The parent-output boundary at push time: chips/completions landing + /// under the marker don't bump it, so a matching epoch means "same + /// park episode — don't re-push". agent_output_epoch: u64, }, /// The marker was forgone: an interjection continued the parked turn. @@ -697,17 +700,6 @@ impl ParkedMarkerSlot { } } } - /// The mutable parked marker entry and its parent-output boundary. - pub(crate) fn rendered_marker(&self) -> Option<(EntryId, u64)> { - match self { - ParkedMarkerSlot::Rendered { - entry_id, - agent_output_epoch, - .. - } => Some((*entry_id, *agent_output_epoch)), - ParkedMarkerSlot::Forgone(_) => None, - } - } } pub struct AgentView { pub session: AgentSession, @@ -855,14 +847,6 @@ pub struct AgentView { /// Keyed by prompt id: a new turn naturally invalidates the slot with no /// explicit clear site. See [`ParkedMarkerSlot`]. pub(crate) parked_wait_marker_for: Option, - /// A turn-end marker announced background work ("N still running"), so - /// no-wake completions landing between turns re-emit a fresh work-only - /// status line after their chip (wake-bound ones get the wake turn's end - /// marker instead). Written by exactly one assignment — the shared - /// marker tail `push_end_marker_block` (real-turn AND wake markers: - /// counted opens, workless closes) — and cleared when a real turn starts - /// and on every replay-window entry, closing the between-turns window. - pub(crate) end_work_announced: bool, /// Live `stop`/`stop_failure` hook runs held for the turn's terminal /// marker (driver order: the hooks arrive before the `PromptResponse` /// that pushes it). Consumed or flushed by `push_turn_terminal_marker`; @@ -881,15 +865,9 @@ pub struct AgentView { /// UTC ms when the current turn started (`turnStartMs` from notification meta). /// Used for turn elapsed display. pub turn_start_ms: Option, - /// `(prompt_id, turnStartMs)` of the streaming wake turn, recorded off its - /// live deltas. Non-adopted synthetic turns never set `turn_started_at`, - /// and the durable `TurnCompleted` envelope carries no timing — this is - /// the wake-end marker's only elapsed source. Consumed (pid-matched) at - /// that marker's push; `None` there renders the marker without a duration. - pub wake_turn_start: Option<(String, i64)>, /// Local wall-clock time when the current turn started. /// Set by `maybe_drain_queue` when a prompt is sent. Used to compute - /// elapsed time for "Worked for Xm Ys." system messages. + /// elapsed time for "Worked for Xm Ys" system messages. pub turn_started_at: Option, /// Turn-start anchor a `turn.first_activity` log was already emitted for (fire-once-per-turn guard). pub first_activity_logged_for: Option, @@ -1154,6 +1132,8 @@ pub struct AgentView { /// Active /btw side question overlay. When `Some`, renders as a dismissible /// overlay and captures keyboard input (Esc/Enter/Space to dismiss). pub btw_state: Option, + /// Minimal-only ownership/correlation for `btw_state`; absent in fullscreen. + pub(crate) minimal_btw_lifecycle: Option, /// Whether the /btw panel holds keyboard focus. The panel is non-blocking, /// so Up/Down/PgUp/PgDn scroll it when focused and otherwise reach the /// prompt. Set on a `Done` answer; cleared when the user types in or clicks @@ -2291,7 +2271,7 @@ pub(super) mod test_fixtures { ); } /// A minimal running (foreground) subagent registry row, so tests can - /// count it in `current_end_work` / park-marker snapshots. + /// count it in `watchers()` snapshots. pub fn running_subagent_info(child_sid: &str) -> crate::app::subagent::SubagentInfo { use std::sync::Arc; use std::time::Instant; @@ -2334,8 +2314,8 @@ pub(super) mod test_fixtures { child_updates_replayed: false, } } - /// Count of parked ("Worked for … still running") marker blocks in - /// the agent's scrollback. + /// Count of parked ("Worked for X") marker blocks in the agent's + /// scrollback. pub fn count_parked(agent: &AgentView) -> usize { use crate::scrollback::block::RenderBlock; (0..agent.scrollback.len()) diff --git a/crates/codegen/xai-grok-pager/src/app/agent_view/modals.rs b/crates/codegen/xai-grok-pager/src/app/agent_view/modals.rs index 3270b96..be0d83a 100644 --- a/crates/codegen/xai-grok-pager/src/app/agent_view/modals.rs +++ b/crates/codegen/xai-grok-pager/src/app/agent_view/modals.rs @@ -79,6 +79,16 @@ impl AgentView { } } + pub(super) fn handle_agents_modal_paste(&mut self, text: &str) -> InputOutcome { + let Some(ref mut state) = self.agents_modal else { + return InputOutcome::Unchanged; + }; + match crate::views::agents_modal::handle_agents_paste(state, text) { + crate::views::agents_modal::AgentsModalOutcome::Changed => InputOutcome::Changed, + _ => InputOutcome::Unchanged, + } + } + pub(super) fn handle_agents_modal_mouse( &mut self, mouse: &crossterm::event::MouseEvent, @@ -133,6 +143,16 @@ impl AgentView { } } + pub(super) fn handle_persona_detail_paste(&mut self, text: &str) -> InputOutcome { + let Some(ref mut detail) = self.persona_detail else { + return InputOutcome::Unchanged; + }; + match crate::views::persona_detail::handle_persona_detail_paste(detail, text) { + crate::views::persona_detail::PersonaDetailOutcome::Changed => InputOutcome::Changed, + _ => InputOutcome::Unchanged, + } + } + pub(super) fn handle_persona_detail_mouse( &mut self, mouse: &crossterm::event::MouseEvent, @@ -497,7 +517,7 @@ impl AgentView { crate::views::modal_window::handle_modal_key(&mut state.window, key, &config); match outcome { crate::views::modal_window::ModalWindowOutcome::CloseRequested => { - if state.picker_state.query.is_empty() && !state.picker_state.search_active { + if state.picker_state.query().is_empty() && !state.picker_state.search_active { self.extensions_modal = None; return InputOutcome::Changed; } @@ -740,7 +760,8 @@ impl AgentView { } crate::views::picker::PickerOutcome::Copy(_) => InputOutcome::Changed, crate::views::picker::PickerOutcome::SubmitQuery => InputOutcome::Changed, - crate::views::picker::PickerOutcome::Changed => InputOutcome::Changed, + crate::views::picker::PickerOutcome::Changed + | crate::views::picker::PickerOutcome::QueryChanged => InputOutcome::Changed, crate::views::picker::PickerOutcome::Unchanged => InputOutcome::Unchanged, } } @@ -821,9 +842,13 @@ impl AgentView { /// paste shortcut (Cmd-V / Shift-Insert) is swallowed because the modal /// intercept only routes `Event::Key` and `Event::Mouse` by default. pub(super) fn handle_extensions_modal_paste(&mut self, text: &str) -> InputOutcome { - if let Some(ref mut state) = self.extensions_modal - && state.apply_paste(text) - { + let Some(ref mut state) = self.extensions_modal else { + return InputOutcome::Unchanged; + }; + if state.modal_message.is_some() || state.pending_action.is_some() { + return InputOutcome::Unchanged; + } + if state.apply_paste(text) { InputOutcome::Changed } else { InputOutcome::Unchanged @@ -1111,7 +1136,8 @@ impl AgentView { ); InputOutcome::Changed } - crate::views::picker::PickerOutcome::Changed => InputOutcome::Changed, + crate::views::picker::PickerOutcome::Changed + | crate::views::picker::PickerOutcome::QueryChanged => InputOutcome::Changed, crate::views::picker::PickerOutcome::Unchanged => InputOutcome::Unchanged, _ => InputOutcome::Changed, } @@ -1845,7 +1871,7 @@ impl AgentView { .position(|h| { crate::views::extensions_modal::fuzzy_matches_hook( h, - &state.picker_state.query, + state.picker_state.query(), ) && new_filter.matches(!h.disabled) }) .unwrap_or(0); @@ -2469,7 +2495,7 @@ mod extensions_modal_search_key_tests { !state.picker_state.search_active, "Esc should deactivate search" ); - assert!(state.picker_state.query.is_empty()); + assert!(state.picker_state.query().is_empty()); } #[test] @@ -2510,7 +2536,7 @@ mod extensions_modal_search_key_tests { { let state = agent.extensions_modal.as_ref().unwrap(); assert!(state.picker_state.search_active); - assert_eq!(state.picker_state.query, "a"); + assert_eq!(state.picker_state.query(), "a"); } agent.handle_extensions_modal_key(&key(KeyCode::Esc)); @@ -2520,7 +2546,7 @@ mod extensions_modal_search_key_tests { .as_ref() .expect("modal stays open while a query is present"); assert!(!state.picker_state.search_active); - assert_eq!(state.picker_state.query, "a"); + assert_eq!(state.picker_state.query(), "a"); } agent.handle_extensions_modal_key(&key(KeyCode::Esc)); @@ -2530,7 +2556,7 @@ mod extensions_modal_search_key_tests { .as_ref() .expect("clearing the retained query keeps the modal open"); assert!(!state.picker_state.search_active); - assert!(state.picker_state.query.is_empty()); + assert!(state.picker_state.query().is_empty()); } agent.handle_extensions_modal_key(&key(KeyCode::Esc)); @@ -2560,7 +2586,8 @@ mod extensions_modal_search_key_tests { "search stays active across a tab switch" ); assert_eq!( - state.picker_state.query, "g", + state.picker_state.query(), + "g", "the query carries over to the new tab" ); } @@ -2577,7 +2604,7 @@ mod extensions_modal_search_key_tests { let state = agent.extensions_modal.as_ref().unwrap(); assert_eq!(state.active_tab, ExtensionsTab::Hooks); assert!(state.picker_state.search_active); - assert_eq!(state.picker_state.query, "g"); + assert_eq!(state.picker_state.query(), "g"); } #[test] @@ -2592,7 +2619,7 @@ mod extensions_modal_search_key_tests { let state = agent.extensions_modal.as_ref().unwrap(); assert_eq!(state.active_tab, ExtensionsTab::Hooks); assert!(state.picker_state.search_active); - assert_eq!(state.picker_state.query, "g"); + assert_eq!(state.picker_state.query(), "g"); } #[test] @@ -2755,3 +2782,75 @@ mod connectors_url_click_tests { )); } } + +#[cfg(test)] +mod editor_paste_routing_tests { + use std::collections::HashMap; + + use super::test_fixtures::make_agent; + use crate::actions::ActionRegistry; + use crate::app::bundle::BundleState; + use crate::views::agents_modal::{AgentsModalState, AgentsTab}; + use crate::views::extensions_modal::{ + ExtensionsModalState, ExtensionsTab, FieldSpec, ModalInput, + }; + use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; + + #[test] + fn persona_and_extensions_paste_only_into_active_forms() { + let registry = ActionRegistry::defaults(); + let mut agent = make_agent(); + agent.prompt.set_text("hidden prompt"); + + let cwd = tempfile::tempdir().expect("temp cwd"); + let mut agents = AgentsModalState::new( + cwd.path(), + &HashMap::new(), + &BundleState::default(), + None, + None, + ); + agents.active_tab = AgentsTab::Personas; + agent.agents_modal = Some(agents); + let _ = agent.handle_input( + &Event::Key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)), + ®istry, + ); + let _ = agent.handle_input(&Event::Paste("na\r\nme".to_owned()), ®istry); + assert_eq!( + agent + .agents_modal + .as_ref() + .and_then(|state| state.persona_input.as_ref()) + .map(|input| input.name()), + Some("name") + ); + assert_eq!(agent.prompt.text(), "hidden prompt"); + + agent.agents_modal = None; + let mut extensions = ExtensionsModalState::new(ExtensionsTab::McpServers); + extensions.input = Some(ModalInput::from_specs( + "mcp add".to_owned(), + vec![FieldSpec { + label: "URL".to_owned(), + required: true, + placeholder: None, + }], + )); + agent.extensions_modal = Some(extensions); + let _ = agent.handle_input( + &Event::Paste("https://example.test\r\n".to_owned()), + ®istry, + ); + assert_eq!( + agent + .extensions_modal + .as_ref() + .and_then(|state| state.input.as_ref()) + .and_then(|input| input.field(0)) + .map(|field| field.text()), + Some("https://example.test") + ); + assert_eq!(agent.prompt.text(), "hidden prompt"); + } +} diff --git a/crates/codegen/xai-grok-pager/src/app/agent_view/panes.rs b/crates/codegen/xai-grok-pager/src/app/agent_view/panes.rs index 213e10e..5275a08 100644 --- a/crates/codegen/xai-grok-pager/src/app/agent_view/panes.rs +++ b/crates/codegen/xai-grok-pager/src/app/agent_view/panes.rs @@ -48,7 +48,8 @@ impl AgentView { self.highlighted_link_idx = None; return InputOutcome::Action(Action::OpenLink(target)); } - if key!(Enter).matches(key) + if crate::app::inline_edit::INLINE_EDIT_ENABLED + && key!(Enter).matches(key) && !self.scrollback.is_selected_group_header() && let Some(idx) = self.scrollback.selected() && self @@ -195,32 +196,29 @@ impl AgentView { } } if composing { - match key.code { - KeyCode::Enter => { - if self.scrollback_search.as_ref()?.query().is_empty() { - self.scrollback_search = None; - } else { - if let Some(search) = self.scrollback_search.as_mut() { - search.accept(); - } - self.reveal_current_search_match(); + if key.code == KeyCode::Enter { + if self.scrollback_search.as_ref()?.query().is_empty() { + self.scrollback_search = None; + } else { + if let Some(search) = self.scrollback_search.as_mut() { + search.accept(); } - Some(InputOutcome::Changed) + self.reveal_current_search_match(); } - KeyCode::Backspace => { - let mut q = self.scrollback_search.as_ref()?.query().to_string(); - q.pop(); - self.set_scrollback_search_query(&q); - Some(InputOutcome::Changed) - } - KeyCode::Char(c) if !key.modifiers.intersects(non_text) => { - let mut q = self.scrollback_search.as_ref()?.query().to_string(); - q.push(c); - self.set_scrollback_search_query(&q); - Some(InputOutcome::Changed) - } - _ => Some(InputOutcome::Unchanged), + return Some(InputOutcome::Changed); } + let outcome = self + .scrollback_search + .as_mut()? + .apply_query_key(key, &self.scrollback); + Some(match outcome { + crate::input::line_editor::LineEditOutcome::TextChanged + | crate::input::line_editor::LineEditOutcome::CursorChanged + | crate::input::line_editor::LineEditOutcome::HandledNoChange => { + InputOutcome::Changed + } + crate::input::line_editor::LineEditOutcome::Unhandled => InputOutcome::Unchanged, + }) } else { match key.code { KeyCode::Char('n') if key.modifiers.is_empty() => self.navigate_search(true), @@ -231,6 +229,19 @@ impl AgentView { } } } + pub(super) fn handle_scrollback_search_paste(&mut self, text: &str) -> Option { + let search = self.scrollback_search.as_mut()?; + if !search.is_composing() { + return Some(InputOutcome::Unchanged); + } + let outcome = search.apply_query_paste(text, &self.scrollback); + Some(match outcome { + crate::input::line_editor::LineEditOutcome::TextChanged + | crate::input::line_editor::LineEditOutcome::CursorChanged + | crate::input::line_editor::LineEditOutcome::HandledNoChange => InputOutcome::Changed, + crate::input::line_editor::LineEditOutcome::Unhandled => InputOutcome::Unchanged, + }) + } /// Enqueue `query` for the background scan. Results (and the reveal) arrive /// later via [`poll_scrollback_search`](Self::poll_scrollback_search); the /// highlight updates immediately because it reads the UI-side matcher. @@ -726,3 +737,49 @@ mod scroll_granularity_tests { ); } } +#[cfg(test)] +mod paste_routing_tests { + use super::super::{AgentPane, test_fixtures::make_agent}; + use crate::actions::ActionRegistry; + use crate::app::app_view::InputOutcome; + use crate::scrollback::ScrollbackSearchState; + use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; + #[test] + fn scrollback_search_paste_stays_scoped_and_browse_is_inert() { + let mut agent = make_agent(); + agent.set_active_pane(AgentPane::Scrollback, true); + agent.prompt.set_text("hidden prompt"); + agent.scrollback_search = Some(ScrollbackSearchState::open()); + let registry = ActionRegistry::defaults(); + let _ = agent.handle_input(&Event::Paste("ab".to_owned()), ®istry); + let _ = agent.handle_input( + &Event::Key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)), + ®istry, + ); + let outcome = agent.handle_input(&Event::Paste("中\r\n".to_owned()), ®istry); + assert!(matches!(outcome, InputOutcome::Changed)); + assert_eq!( + agent + .scrollback_search + .as_ref() + .map(ScrollbackSearchState::query), + Some("a中b") + ); + assert_eq!(agent.prompt.text(), "hidden prompt"); + agent.scrollback_search.as_mut().unwrap().accept(); + let outcome = agent.handle_input(&Event::Paste("ignored".to_owned()), ®istry); + assert!(matches!(outcome, InputOutcome::Unchanged)); + assert_eq!( + agent + .scrollback_search + .as_ref() + .map(ScrollbackSearchState::query), + Some("a中b") + ); + assert_eq!(agent.prompt.text(), "hidden prompt"); + agent.scrollback_search = None; + let outcome = agent.handle_input(&Event::Paste("still ignored".to_owned()), ®istry); + assert!(matches!(outcome, InputOutcome::Unchanged)); + assert_eq!(agent.prompt.text(), "hidden prompt"); + } +} diff --git a/crates/codegen/xai-grok-pager/src/app/agent_view/paste.rs b/crates/codegen/xai-grok-pager/src/app/agent_view/paste.rs index 870d45f..a454125 100644 --- a/crates/codegen/xai-grok-pager/src/app/agent_view/paste.rs +++ b/crates/codegen/xai-grok-pager/src/app/agent_view/paste.rs @@ -1121,10 +1121,25 @@ pub(super) mod paste_key_tests { #[test] fn event_paste_plan_approval_non_image_file_url_decoded_into_prompt() { assert_event_paste_arm_decodes_non_image("plan_approval", |agent| { - agent.plan_approval_view = Some(make_plan_approval_view_state()); + let mut view = make_plan_approval_view_state(); + view.focus = crate::views::plan_approval_view::PlanApprovalFocus::Prompt; + agent.plan_approval_view = Some(view); agent.line_viewer = None; }); } + #[test] + fn event_paste_plan_preview_does_not_mutate_hidden_prompt() { + let mut agent = make_agent(); + agent.prompt.set_text("hidden prompt"); + agent.plan_approval_view = Some(make_plan_approval_view_state()); + agent.line_viewer = None; + let outcome = agent.handle_input( + &Event::Paste("ignored".to_owned()), + &ActionRegistry::defaults(), + ); + assert!(matches!(outcome, InputOutcome::Unchanged)); + assert_eq!(agent.prompt.text(), "hidden prompt"); + } /// Question-view `Event::Paste` arm routes through the classifier when /// the question view is in `InputMode` focus. #[test] diff --git a/crates/codegen/xai-grok-pager/src/app/agent_view/queue.rs b/crates/codegen/xai-grok-pager/src/app/agent_view/queue.rs index 08dcfaa..a8cdafd 100644 --- a/crates/codegen/xai-grok-pager/src/app/agent_view/queue.rs +++ b/crates/codegen/xai-grok-pager/src/app/agent_view/queue.rs @@ -137,7 +137,7 @@ impl AgentView { // The tracker drops wait_commands_or_subagents' explicit task_ids; // zero visible work is the only signal available here. Some(TurnActivity::Waiting(WaitingReason::TasksComplete)) => { - self.current_end_work().nonzero().is_none() + self.watchers().awaitable_work() == 0 } _ => false, } @@ -159,16 +159,17 @@ impl AgentView { .is_some_and(|info| !info.is_running()) } - /// Push a "Worked for X. {k} … still running…" marker when the turn is - /// parked on a sendable wait and the transcript tail is not already this - /// turn's marker with current counts — the tail keeps explaining the - /// idle-looking chrome across parks, restatements, and re-parks. Our own - /// push satisfies the check, so markers never self-chain. + /// Push a "Worked for X" marker when the turn parks on a sendable wait — + /// the transcript boundary explaining the idle-looking chrome. One marker + /// per park episode: same agent-output epoch as the rendered slot means + /// no re-push (chips/completions don't bump it); an epoch bump means the + /// wait resumed and re-parked, which pushes a fresh marker. Completion + /// rails also call this to re-eval a park withheld at park time (e.g. + /// held queue since drained). /// /// Called from the ACP notification path — not the draw path — so /// background tabs and minimal mode stamp the park at its true moment; - /// each push is append-only (minimal mode commits print-once). The - /// subagent-only refresh path may mutate an uncommitted parked row. A + /// each push is append-only (minimal mode commits print-once). A /// [`ParkedMarkerSlot::Forgone`] slot stays silent for the rest of the /// turn (see [`Self::suppress_parked_marker_on_interject`]). UI-only: no /// turn-lifecycle event, no stop hooks; the real completion prints its @@ -186,8 +187,20 @@ impl AgentView { match &self.parked_wait_marker_for { // Interjection ordering: forgone is final for the turn. Some(ParkedMarkerSlot::Forgone(pid)) if *pid == prompt_id => return, + // Same park episode (no parent output since the marker): the one + // marker already explains this park — chips landing below it + // must not re-push. + Some(ParkedMarkerSlot::Rendered { + prompt_id: pid, + agent_output_epoch, + .. + }) if *pid == prompt_id + && *agent_output_epoch == self.session.tracker.agent_output_epoch() => + { + return; + } // A tail user prompt after a rendered marker is an interjection: - // a "still running" line beneath it would flip the transcript. + // a marker line beneath it would flip the transcript. Some(ParkedMarkerSlot::Rendered { prompt_id: pid, .. }) if *pid == prompt_id && self.tail_is_user_prompt() => { @@ -195,10 +208,7 @@ impl AgentView { } _ => {} } - if self.parked_marker_is_current_tail(&prompt_id) { - return; - } - // Below the tail dedupe: a rendered park would otherwise log a false + // Below the slot dedupe: a rendered park would otherwise log a false // "skipped" on every subsequent update. if self.parked_wait_resolves_imminently() { tracing::debug!( @@ -210,54 +220,6 @@ impl AgentView { self.push_parked_marker_block(prompt_id); } - /// Refresh the current uncommitted parked marker after a live subagent completion. - pub(crate) fn maybe_refresh_parked_subagent_marker(&mut self) { - if !self.renders_parked() - || self.has_held_user_queue() - || self.tail_is_user_prompt() - || self.parked_wait_resolves_imminently() - { - return; - } - let end_work = self.current_end_work(); - if end_work.nonzero().is_none() { - return; - } - let Some(prompt_id) = self.session.current_prompt_id.clone() else { - return; - }; - let Some((entry_id, marker_epoch)) = self - .parked_wait_marker_for - .as_ref() - .and_then(ParkedMarkerSlot::rendered_marker) - else { - return; - }; - let agent_output_epoch = self.session.tracker.agent_output_epoch(); - let elapsed = self.turn_elapsed().unwrap_or_default(); - if marker_epoch == agent_output_epoch - && self - .scrollback - .refresh_parked_subagent_marker(entry_id, &prompt_id, elapsed, end_work) - { - return; - } - self.maybe_push_parked_marker(); - } - - /// The transcript tail is this turn's parked marker carrying the - /// current work counts. - fn parked_marker_is_current_tail(&self, prompt_id: &str) -> bool { - match self.scrollback.last().map(|entry| &entry.block) { - Some(crate::scrollback::block::RenderBlock::SessionEvent(b)) => { - b.parked - && b.prompt_id.as_deref() == Some(prompt_id) - && b.end_work == self.current_end_work().nonzero() - } - _ => false, - } - } - /// The transcript tail is a user-authored prompt row. fn tail_is_user_prompt(&self) -> bool { matches!( @@ -267,29 +229,23 @@ impl AgentView { } /// The parked marker block shape: a `TurnCompleted` marker flagged - /// `parked` (renders mid-turn, never accepts stop hooks) with the work - /// counts snapshotted at push time. + /// `parked` (renders mid-turn, never accepts stop hooks). fn push_parked_marker_block(&mut self, prompt_id: String) { let agent_output_epoch = self.session.tracker.agent_output_epoch(); let mut block = crate::scrollback::blocks::SessionEventBlock::new( crate::scrollback::blocks::SessionEvent::TurnCompleted { - // Legacy copy on purpose: unknown elapsed keeps the "in 0.0s" - // form here — only wake markers use the honest `None` form. + // Unknown elapsed renders as "Worked for 0.0s" rather than + // falling back to `None`'s bare "Turn completed." — the park + // boundary should read like every other turn marker. elapsed: Some(self.turn_elapsed().unwrap_or_default()), }, ); block.parked = true; block.prompt_id = Some(prompt_id.clone()); - // Park never touches `end_work_announced` (the turn is still running - // shell-side; the window is a between-turns concept) — so it does not - // use `push_end_marker_block`, only the field-normalization rule. - block.end_work = self.current_end_work().nonzero(); - let entry_id = self - .scrollback + self.scrollback .push_block(crate::scrollback::block::RenderBlock::SessionEvent(block)); self.parked_wait_marker_for = Some(ParkedMarkerSlot::Rendered { prompt_id, - entry_id, agent_output_epoch, }); } @@ -429,10 +385,10 @@ impl AgentView { && !self.is_waiting_on_subagent() } - /// Live background work on this agent's root session, split the way the - /// marker copy reads it (monitors apart from commands). - pub(crate) fn current_end_work(&self) -> crate::scrollback::blocks::EndWork { - let mut work = crate::scrollback::blocks::EndWork::default(); + /// Live counts for the turn-status watching cue; see + /// [`crate::views::turn_status::Watchers`]. + pub(crate) fn watchers(&self) -> crate::views::turn_status::Watchers { + let mut watchers = crate::views::turn_status::Watchers::default(); for task in self .session .bg_tasks @@ -440,62 +396,34 @@ impl AgentView { .filter(|t| t.status == crate::app::agent::BgTaskStatus::Running) { if task.is_monitor { - work.running_monitors += 1; + watchers.monitors += 1; } else { - work.running_commands += 1; + watchers.commands += 1; } } - work.running_subagents = self + watchers.loops = self.session.scheduled_tasks.len(); + watchers.subagents = self .subagent_sessions .values() .filter(|s| s.is_running()) .count(); - work + watchers } - /// Shared tail of every turn-end marker push — the real-turn rail - /// (`push_turn_terminal_marker`) and the wake rail (`push_wake_end_marker`): - /// snapshot the current counts into `end_work` (zero → `None` → legacy - /// text) and apply the announce window's single assignment — a counted - /// marker opens the between-turns status window, a workless one closes it. + /// Shared tail of every turn-end marker push + /// (`push_turn_terminal_marker`). pub(crate) fn push_end_marker_block( &mut self, event: crate::scrollback::blocks::SessionEvent, stop_hooks: Vec<(String, Vec)>, prompt_id: Option, ) { - let end_work = self.current_end_work().nonzero(); // The marker keeps its turn's pid for the tail-merge attribution check. - let mut block = crate::scrollback::blocks::SessionEventBlock::with_stop_hooks( + let block = crate::scrollback::blocks::SessionEventBlock::with_stop_hooks( event, stop_hooks, prompt_id, ); - block.end_work = end_work; self.scrollback .push_block(crate::scrollback::block::RenderBlock::SessionEvent(block)); - self.end_work_announced = end_work.is_some(); - } - - /// Push the work-only status line ("2 commands still running") after a - /// background completion that lands between turns AND is not followed by - /// a wake response — the no-wake fallback. Wake-bound completions - /// (`will_wake` stamped by the shell) skip this line: the wake turn's end - /// marker carries the fresh counts instead (see `push_wake_end_marker`). - /// Without a wake the story stays chronological the old way: marker(3) → - /// chip → status(2) → chip → status(1) → chip → nothing (zero left: the - /// last chip closes it). Gated on the last turn-end marker having - /// announced work and on no real turn running — mid-turn completions push - /// chips only. Returns whether a line was pushed (callers needing a - /// redraw verdict use it; chip sites ignore it). - pub(crate) fn maybe_push_work_status(&mut self) -> bool { - if !self.end_work_announced || self.session.state.is_busy() { - return false; - } - let Some(text) = self.current_end_work().still_running_text() else { - return false; - }; - self.scrollback - .push_block(crate::scrollback::block::RenderBlock::system(text)); - true } /// `Some(is_prompt_like)` for a resolvable merged-queue row; `None` when it @@ -1806,9 +1734,9 @@ mod queue_edit_routing_tests { } #[cfg(test)] -mod end_work_tests { +mod watcher_tests { use super::super::test_agent_view; - use crate::scrollback::blocks::EndWork; + use crate::views::turn_status::Watchers; fn insert_bg_task( agent: &mut crate::app::agent_view::AgentView, @@ -1842,7 +1770,7 @@ mod end_work_tests { } #[test] - fn current_end_work_counts_monitors_apart_from_commands() { + fn watchers_counts_monitors_apart_from_commands() { let mut agent = test_agent_view(Some("s1"), std::path::PathBuf::from("/tmp")); insert_bg_task(&mut agent, "bg-1", false); insert_bg_task(&mut agent, "mon-1", true); @@ -1850,11 +1778,12 @@ mod end_work_tests { agent.session.bg_tasks.get_mut("done-1").unwrap().status = crate::app::agent::BgTaskStatus::Done; assert_eq!( - agent.current_end_work(), - EndWork { - running_commands: 1, - running_monitors: 1, - running_subagents: 0, + agent.watchers(), + Watchers { + commands: 1, + monitors: 1, + loops: 0, + subagents: 0, } ); } diff --git a/crates/codegen/xai-grok-pager/src/app/agent_view/render.rs b/crates/codegen/xai-grok-pager/src/app/agent_view/render.rs index 43c7a6b..d0b8284 100644 --- a/crates/codegen/xai-grok-pager/src/app/agent_view/render.rs +++ b/crates/codegen/xai-grok-pager/src/app/agent_view/render.rs @@ -1054,28 +1054,15 @@ impl AgentView { } let queue_height = self.queue.desired_height(); let drain_blocked = self.drain_blocked(); - let watchers = turn_status::Watchers { - monitors: self - .session - .bg_tasks - .values() - .filter(|t| t.is_monitor && t.status == crate::app::agent::BgTaskStatus::Running) - .count(), - loops: self.session.scheduled_tasks.len(), - subagents: self - .subagent_sessions - .values() - .filter(|s| s.is_running()) - .count(), - }; + let watchers = self.watchers(); let parked = self.renders_parked(); - let turn_status_height = if !parked - && turn_status::should_show( - &self.session.state, - drain_blocked, - self.mcp_init_progress.as_ref(), - watchers, - ) { + let turn_status_height = if turn_status::should_show( + &self.session.state, + drain_blocked, + self.mcp_init_progress.as_ref(), + watchers, + parked, + ) { 1 } else { 0 @@ -1523,26 +1510,47 @@ impl AgentView { ); } let query = search.query(); - crate::views::picker::render_search_bar( - buf, - layout.scrollback.x, - bar_y, - layout.scrollback.width, - &theme, - query, - search.is_composing(), - !search.is_composing(), - query.len(), - None, - ); let counter = match search.current_index() { Some(i) => Some(format!("{}/{}", i + 1, search.match_count())), None if search.has_error() => Some("bad pattern".to_string()), None if !query.is_empty() => Some("no matches".to_string()), None => None, }; - if let Some(counter) = counter { - let w = counter.len() as u16; + let counter_width = counter + .as_deref() + .map_or(0, |text| UnicodeWidthStr::width(text) as u16); + let search_layout = + crate::views::picker::search_bar_layout(layout.scrollback.width, counter_width); + let leading_query; + let (rendered_query, viewport) = if search.is_composing() { + ( + query, + Some(search.query_viewport(search_layout.input_width())), + ) + } else { + leading_query = + crate::render::line_utils::truncate_str(query, search_layout.input_width()); + (leading_query.as_str(), None) + }; + crate::views::picker::render_search_bar_with_viewport( + buf, + layout.scrollback.x, + bar_y, + search_layout, + &theme, + rendered_query, + search.is_composing(), + !search.is_composing(), + None, + viewport.unwrap_or(xai_ratatui_textarea::SingleLineViewport { + visible_byte_range: 0..rendered_query.len(), + cursor_display_column: 0, + }), + ); + if let Some(counter) = counter + && search_layout.trailing_width() > 0 + { + let w = UnicodeWidthStr::width(counter.as_str()) as u16; if layout.scrollback.width > w { buf.set_string( layout.scrollback.x + layout.scrollback.width - w, @@ -1982,6 +1990,7 @@ impl AgentView { is_pending_user_input, goal_verifying, watchers, + parked, false, held_queue, held_queue_top_sendable, diff --git a/crates/codegen/xai-grok-pager/src/app/agent_view/selection.rs b/crates/codegen/xai-grok-pager/src/app/agent_view/selection.rs index 05fa794..8e2bd91 100644 --- a/crates/codegen/xai-grok-pager/src/app/agent_view/selection.rs +++ b/crates/codegen/xai-grok-pager/src/app/agent_view/selection.rs @@ -999,7 +999,13 @@ impl AgentView { } 2 if is_prompt => { // Edit in place; bash/cron keep the old fold behavior. - if !self.enter_inline_edit(idx) { + // + // Gated OFF for now (unsolved scroll jump on enter — see + // inline_edit::INLINE_EDIT_ENABLED). When disabled this is a + // no-op, so the block below runs and restores the EXACT + // pre-feature double-click behavior for a prompt: fold (if + // foldable) + scroll the entry to the top. + if !(crate::app::inline_edit::INLINE_EDIT_ENABLED && self.enter_inline_edit(idx)) { if foldable { self.scrollback.toggle_fold_selected(); } diff --git a/crates/codegen/xai-grok-pager/src/app/agent_view/session.rs b/crates/codegen/xai-grok-pager/src/app/agent_view/session.rs index 227d2d4..85da3e1 100644 --- a/crates/codegen/xai-grok-pager/src/app/agent_view/session.rs +++ b/crates/codegen/xai-grok-pager/src/app/agent_view/session.rs @@ -30,9 +30,16 @@ impl AgentView { self.last_seen_event_id = None; self.last_applied_event_seq = None; self.last_applied_xai_event_seq = None; + self.clear_minimal_btw_lifecycle(); } self.session.session_id = Some(session_id); } + /// Unbind this view from its current session identity. + pub(crate) fn unbind_session_id(&mut self) { + if self.session.session_id.take().is_some() { + self.clear_minimal_btw_lifecycle(); + } + } /// Record a prompt id this client originated (sent to the agent as the turn /// driver). Used by the ACP gate to keep `attached_as_viewer` per-turn /// accurate. Bounded FIFO; a no-op for ids already tracked. @@ -97,12 +104,10 @@ impl AgentView { auto_topup: None, goal_state: None, parked_wait_marker_for: None, - end_work_announced: false, pending_stop_hooks: None, last_cleared_goal_id: None, show_goal_detail: false, turn_start_ms: None, - wake_turn_start: None, turn_started_at: None, first_activity_logged_for: None, turn_paused_duration: std::time::Duration::ZERO, @@ -203,6 +208,7 @@ impl AgentView { agents_modal: None, persona_detail: None, btw_state: None, + minimal_btw_lifecycle: None, btw_focused: false, hit_btw_close: Default::default(), toast: None, @@ -311,18 +317,21 @@ impl AgentView { self.turn_paused_duration = std::time::Duration::ZERO; self.last_active_at = Some(Instant::now()); } + /// Invalidate and clear a minimal `/btw` lifecycle at a session boundary. + pub(crate) fn clear_minimal_btw_lifecycle(&mut self) { + crate::minimal_api::clear_minimal_btw(self); + } /// Enter a `session/load` replay window: flip `loading_replay` on and reset /// every field coupled to that transition together, so no site can drift /// (e.g. reset one coupled field but miss another). Called at every /// replay-window entry: the fresh/restore load ctor paths and the /// reconnect/fork reuse paths. pub(crate) fn begin_replay_window(&mut self) { + self.clear_minimal_btw_lifecycle(); self.session.loading_replay = true; self.replayed_terminal_prompts.clear(); self.unexpected_replay_drops = 0; self.pending_stop_hooks = None; - self.end_work_announced = false; - self.wake_turn_start = None; self.clear_send_now_expectation(); self.optimistic_queue_ids.clear(); self.send_now_awaiting_confirm = None; @@ -391,14 +400,11 @@ impl AgentView { reload.saw_todo_update = true; } } - /// Start a locally-tracked turn: close the between-turns status window - /// (completions inside a turn push chips only), then enter TurnRunning. - /// Every real turn start must route through here so no caller can miss - /// the close. Deliberately NOT used by server-initiated synthetic turns - /// (auto-wake / actor runs): they never call `start_turn`, and their - /// completions still deserve status lines. + /// Start a locally-tracked turn: enter TurnRunning with the turn-scoped + /// bookkeeping every real turn start must apply, so no caller can miss + /// it. Deliberately NOT used by server-initiated synthetic turns + /// (auto-wake / actor runs): they never call `start_turn`. pub(crate) fn start_turn_boundary(&mut self, starting_prompt_id: Option<&str>) { - self.end_work_announced = false; if self .expect_send_now_cancel .as_deref() @@ -1329,24 +1335,34 @@ mod resolve_turn_activity_tests { mod status_window_tests { use super::super::test_agent_view; #[test] - fn start_turn_boundary_closes_status_window() { + fn start_turn_boundary_enters_turn_running() { let mut agent = test_agent_view(Some("s1"), std::path::PathBuf::from("/tmp")); - agent.end_work_announced = true; agent.start_turn_boundary(None); - assert!( - !agent.end_work_announced, - "a real turn start closes the between-turns status window" - ); assert!(agent.session.state.is_turn_running()); } #[test] - fn begin_replay_window_closes_status_window() { + fn session_rebind_and_replay_invalidate_minimal_btw() { let mut agent = test_agent_view(Some("s1"), std::path::PathBuf::from("/tmp")); - agent.end_work_announced = true; + let old_request = crate::minimal_api::start_minimal_btw(&mut agent, "old question".into()); + agent.bind_session_id(agent_client_protocol::SessionId::new("s2")); + assert!(agent.btw_state.is_none()); + assert!(agent.minimal_btw_lifecycle.is_none()); + assert!(!crate::minimal_api::finish_minimal_btw( + &mut agent, + old_request, + Ok("old answer".into()) + )); + assert!(agent.btw_state.is_none()); + let replay_request = + crate::minimal_api::start_minimal_btw(&mut agent, "pre-replay question".into()); agent.begin_replay_window(); - assert!( - !agent.end_work_announced, - "a replay window closes the between-turns status window" - ); + assert!(agent.btw_state.is_none()); + assert!(agent.minimal_btw_lifecycle.is_none()); + assert!(!crate::minimal_api::finish_minimal_btw( + &mut agent, + replay_request, + Ok("pre-replay answer".into()) + )); + assert!(agent.btw_state.is_none()); } } diff --git a/crates/codegen/xai-grok-pager/src/app/agent_view/viewer.rs b/crates/codegen/xai-grok-pager/src/app/agent_view/viewer.rs index 8d8fae0..855d1c3 100644 --- a/crates/codegen/xai-grok-pager/src/app/agent_view/viewer.rs +++ b/crates/codegen/xai-grok-pager/src/app/agent_view/viewer.rs @@ -343,6 +343,7 @@ impl AgentView { } else { self.btw_state = None; } + self.minimal_btw_lifecycle = None; self.btw_focused = false; self.clear_btw_drag_state(); InputOutcome::Changed diff --git a/crates/codegen/xai-grok-pager/src/app/app_view.rs b/crates/codegen/xai-grok-pager/src/app/app_view.rs index 5075930..8d29c5f 100644 --- a/crates/codegen/xai-grok-pager/src/app/app_view.rs +++ b/crates/codegen/xai-grok-pager/src/app/app_view.rs @@ -9,6 +9,7 @@ use crate::actions::{ActionId, ActionRegistry, When}; use crate::appearance::AppearanceConfig; use crate::input::KeyboardNormalizer; use crate::input::key::KeyShortcut; +use crate::input::line_editor::{LineEditOutcome, LineEditor}; use crate::input::mouse::{MouseScrollState, ScrollConfig, ScrollDirection}; use crate::key; use crate::notifications::NotificationService; @@ -27,28 +28,56 @@ use xai_acp_lib::AcpAgentTx; #[derive(Debug, Default)] pub struct NewWorktreeDialogState { /// Text input for the worktree label (empty = auto-generated name). - pub label_input: String, + label: LineEditor, } +const MAX_WORKTREE_LABEL_BYTES: usize = 100; impl NewWorktreeDialogState { pub fn new() -> Self { Self { - label_input: String::new(), + label: LineEditor::default(), } } + pub fn label(&self) -> &str { + self.label.text() + } + pub(crate) fn viewport(&self, width: usize) -> xai_ratatui_textarea::SingleLineViewport { + self.label.viewport(width) + } + #[cfg(test)] + pub(crate) fn set_label(&mut self, label: impl Into) { + self.label.set_text(label); + } + #[cfg(test)] + pub(crate) fn set_cursor_byte(&mut self, cursor_byte: usize) -> LineEditOutcome { + self.label.set_cursor_byte(cursor_byte) + } + pub fn insert_paste(&mut self, text: &str) -> NewWorktreeDialogOutcome { + Self::from_line_edit( + self.label + .insert_paste_with_byte_limit(text, MAX_WORKTREE_LABEL_BYTES), + ) + } /// Handle a key event. Returns the dialog outcome. pub fn handle_key(&mut self, key: &crossterm::event::KeyEvent) -> NewWorktreeDialogOutcome { use crossterm::event::{KeyCode, KeyModifiers}; + if crate::input::key::is_paste_key(key) { + return crate::clipboard::system_clipboard_get() + .map_or(NewWorktreeDialogOutcome::Unchanged, |text| { + self.insert_paste(&text) + }); + } if key.modifiers.contains(KeyModifiers::CONTROL) && !crate::input::key::is_altgr(key.modifiers) + && matches!(key.code, KeyCode::Char('c' | 'd' | 'q')) { - return match key.code { - KeyCode::Char('c' | 'd' | 'q') => NewWorktreeDialogOutcome::Cancelled, - _ => NewWorktreeDialogOutcome::Unchanged, - }; + return NewWorktreeDialogOutcome::Cancelled; + } + if key.code == KeyCode::Enter && !key.modifiers.is_empty() { + return NewWorktreeDialogOutcome::Unchanged; } match key.code { - KeyCode::Enter => { - let label = self.label_input.trim().to_string(); + KeyCode::Enter if key.modifiers.is_empty() => { + let label = self.label().trim().to_string(); NewWorktreeDialogOutcome::Submitted(if label.is_empty() { None } else { @@ -56,20 +85,21 @@ impl NewWorktreeDialogState { }) } KeyCode::Esc => NewWorktreeDialogOutcome::Cancelled, - KeyCode::Backspace => { - if self.label_input.pop().is_some() { - NewWorktreeDialogOutcome::Changed - } else { - NewWorktreeDialogOutcome::Unchanged - } + _ => { + let remaining = MAX_WORKTREE_LABEL_BYTES.saturating_sub(self.label().len()); + let outcome = self.label.handle_key_with_insert_policy(key, |character| { + character.len_utf8() <= remaining + }); + Self::from_line_edit(outcome) } - KeyCode::Char(c) => { - if self.label_input.len() < 100 { - self.label_input.push(c); - } - NewWorktreeDialogOutcome::Changed - } - _ => NewWorktreeDialogOutcome::Unchanged, + } + } + fn from_line_edit(outcome: LineEditOutcome) -> NewWorktreeDialogOutcome { + match outcome { + LineEditOutcome::TextChanged + | LineEditOutcome::CursorChanged + | LineEditOutcome::HandledNoChange => NewWorktreeDialogOutcome::Changed, + LineEditOutcome::Unhandled => NewWorktreeDialogOutcome::Unchanged, } } } @@ -197,6 +227,26 @@ pub enum ActiveView { /// The top-level Agent Dashboard. State lives in `AppView::dashboard`. AgentDashboard, } +/// Target restored when leaving the dashboard (Ctrl+\ / Esc). +/// Consumed by `dispatch_exit_dashboard`; dead agents fall back to +/// insertion-order first / Welcome. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DashboardReturn { + /// Plain agent view (no session-overlay chrome). + Agent(AgentId), + /// Session overlay: re-set `attached_agent` on the way back. + Overlay(AgentId), +} +impl DashboardReturn { + pub fn agent_id(self) -> AgentId { + match self { + Self::Agent(id) | Self::Overlay(id) => id, + } + } + pub fn is_overlay(self) -> bool { + matches!(self, Self::Overlay(_)) + } +} /// Tick cadence demanded by the current view state — see /// [`AppView::tick_demand`]. Ordered: `None < Slow < Fast`. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] @@ -956,7 +1006,7 @@ pub struct AppView { /// Initial auth mode hint from method metadata. pub auth_start_mode: AuthMode, /// Text buffer for manual auth token paste (loopback mode). - pub auth_code_input: String, + pub(crate) auth_code_input: LineEditor, /// Monotonically increasing sequence number for auth requests. pub next_auth_request_seq: u64, /// Abort handle for the in-flight `PollAuthUrl` task (with its request_seq). @@ -966,8 +1016,10 @@ pub struct AppView { pub deferred_startup: crate::app::session_startup::DeferredStartupActions, /// Whether deferred welcome-screen login should force OAuth. pub auth_use_oauth: bool, - /// Whether the last clipboard copy during auth succeeded. - pub auth_clipboard_copied: bool, + /// Delivery state from the last clipboard copy during auth. + pub auth_clipboard_delivery: Option, + /// Generation of the current auth copy feedback and its clear timer. + pub auth_clipboard_feedback_generation: u64, /// Team principal UUID from auth (`None` for personal sessions). pub team_id: Option, /// Team name from auth (displayed in the shortcuts bar). @@ -1052,6 +1104,8 @@ pub struct AppView { /// Held outside the `ActiveView` discriminant because `DashboardState` /// is not `Copy` (owns its prompt widget, peek panel, etc.). pub dashboard: Option, + /// Where to return when leaving the dashboard. See [`DashboardReturn`]. + pub dashboard_return: Option, /// Persisted dashboard configuration (pinned rows, reorderings, /// grouping). Loaded once on startup from /// `~/.grok/config.toml`. `None` when the file/section is absent @@ -1295,17 +1349,18 @@ impl AppView { login_label: None, login_method_id: None, auth_start_mode: AuthMode::Pending, - auth_code_input: String::new(), + auth_code_input: LineEditor::default(), next_auth_request_seq: 1, auth_url_poll_handle: None, deferred_startup: Default::default(), auth_use_oauth: false, - auth_clipboard_copied: false, + auth_clipboard_delivery: None, + auth_clipboard_feedback_generation: 0, team_id: None, team_name: None, is_zdr: false, team_role: None, - coding_data_retention_opt_out: false, + coding_data_retention_opt_out: true, show_tips: None, auto_update: None, ask_user_question_timeout_enabled: None, @@ -1351,6 +1406,7 @@ impl AppView { cancel_rewind_enabled: true, session_recap_available: false, dashboard: None, + dashboard_return: None, dashboard_persisted: None, keyboard_normalizer: KeyboardNormalizer::from_terminal_context(), voice_mode_enabled: false, @@ -2054,11 +2110,12 @@ impl AppView { /// Quit always goes through double-press confirmation, even when /// escalated from agent-level (e.g., Ctrl-C while cancelling). pub fn handle_input(&mut self, ev: &Event) -> InputOutcome { - self.handle_input_with_paste_provenance(ev, PasteProvenance::Terminal) + self.handle_input_at_with_paste_provenance(ev, Instant::now(), PasteProvenance::Terminal) } - pub(crate) fn handle_input_with_paste_provenance( + pub(crate) fn handle_input_at_with_paste_provenance( &mut self, ev: &Event, + arrived_at: Instant, paste_provenance: PasteProvenance, ) -> InputOutcome { debug_assert!( @@ -2106,7 +2163,9 @@ impl AppView { let config = self .scroll_config .with_viewport_height(self.scroll_viewport_height()); - let update = self.scroll_state.on_scroll_event(direction, config); + let update = self + .scroll_state + .on_scroll_event_at(arrived_at, direction, config); let pos = (mouse.column, mouse.row); self.last_scroll_pos = Some(pos); if update.lines != 0 { @@ -2370,20 +2429,34 @@ impl AppView { return outcome; } let prompt_paging = !overlay_active && !self.screen_mode.is_minimal(); - match self.agents.get_mut(&id) { - Some(agent) => { - let outcome = if prompt_paging { - agent.handle_input_with_prompt_paging(ev, &self.registry) - } else { - agent.handle_input(ev, &self.registry) - }; - if let Event::Key(key) = ev { - agent.record_input(key, &outcome); + if self.screen_mode.is_minimal() { + match self.agents.get_mut(&id) { + Some(agent) => { + let outcome = agent.handle_minimal_input(ev, &self.registry); + if let Event::Key(key) = ev { + agent.record_input(key, &outcome); + } + self.pending_effects.append(&mut agent.pending_effects); + outcome } - self.pending_effects.append(&mut agent.pending_effects); - outcome + None => InputOutcome::Unchanged, + } + } else { + match self.agents.get_mut(&id) { + Some(agent) => { + let outcome = if prompt_paging { + agent.handle_input_with_prompt_paging(ev, &self.registry) + } else { + agent.handle_input(ev, &self.registry) + }; + if let Event::Key(key) = ev { + agent.record_input(key, &outcome); + } + self.pending_effects.append(&mut agent.pending_effects); + outcome + } + None => InputOutcome::Unchanged, } - None => InputOutcome::Unchanged, } } ActiveView::AgentDashboard => { @@ -2681,7 +2754,10 @@ impl AppView { } } pub(crate) use crate::views::session_picker::filter_session_entries; -use crate::views::session_picker::{CONTENT_EXPAND_OFFSET, PickerItem, build_entry_map}; +use crate::views::session_picker::{ + CONTENT_EXPAND_OFFSET, PickerItem, SessionPickerWorktreeSelection, build_entry_map, + session_picker_worktree_selection, sync_session_picker_query_expansion, +}; /// Context for welcome-view input handling. struct WelcomeInputCtx<'a> { auth_state: &'a AuthState, @@ -2695,7 +2771,7 @@ struct WelcomeInputCtx<'a> { /// that was started from inside a session. Esc / `q` then cancel the /// login and return to the session rather than quitting the app. mid_session_login: bool, - auth_code_input: &'a mut String, + auth_code_input: &'a mut LineEditor, prompt: &'a mut PromptWidget, prompt_focused: &'a mut bool, new_worktree_dialog: &'a mut Option, @@ -2829,31 +2905,30 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco return InputOutcome::Unchanged; } if let Some(dialog) = ctx.new_worktree_dialog.as_mut() { - if let Event::Key(key) = ev { - if key.kind == crossterm::event::KeyEventKind::Release { - return InputOutcome::Unchanged; + let outcome = match ev { + Event::Key(key) if key.kind != crossterm::event::KeyEventKind::Release => { + dialog.handle_key(key) } - match dialog.handle_key(key) { - NewWorktreeDialogOutcome::Submitted(label) => { - *ctx.new_worktree_dialog = None; - return InputOutcome::Action(Action::NewWorktreeSession { - load_session_id: None, - label, - git_ref: None, - }); - } - NewWorktreeDialogOutcome::Cancelled => { - *ctx.new_worktree_dialog = None; - return InputOutcome::Changed; - } - NewWorktreeDialogOutcome::Changed => return InputOutcome::Changed, - NewWorktreeDialogOutcome::Unchanged => return InputOutcome::Unchanged, + Event::Paste(text) => dialog.insert_paste(text), + Event::Resize(_, _) => return InputOutcome::Changed, + _ => NewWorktreeDialogOutcome::Unchanged, + }; + match outcome { + NewWorktreeDialogOutcome::Submitted(label) => { + *ctx.new_worktree_dialog = None; + return InputOutcome::Action(Action::NewWorktreeSession { + load_session_id: None, + label, + git_ref: None, + }); } + NewWorktreeDialogOutcome::Cancelled => { + *ctx.new_worktree_dialog = None; + return InputOutcome::Changed; + } + NewWorktreeDialogOutcome::Changed => return InputOutcome::Changed, + NewWorktreeDialogOutcome::Unchanged => return InputOutcome::Unchanged, } - if matches!(ev, Event::Resize(_, _)) { - return InputOutcome::Changed; - } - return InputOutcome::Unchanged; } if matches!(ctx.auth_state, AuthState::Done) && ctx.has_access @@ -2899,7 +2974,6 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco } if ctx.sp_entries.is_some() && matches!(ctx.auth_state, AuthState::Done) { use crate::views::picker::{PickerConfig, PickerOutcome, handle_picker_input}; - let query_before = ctx.sp_state.query.clone(); let source_filter = *ctx.sp_source_filter; let current_repo = crate::views::session_picker::repo_name_from_cwd(&ctx.cwd.to_string_lossy()); @@ -2907,7 +2981,7 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco ctx.sp_entries.as_deref(), ctx.sp_content_results.as_deref(), crate::views::session_picker::effective_filter_query( - &ctx.sp_state.query, + ctx.sp_state.query(), ctx.sp_entries_query.as_deref(), ), ctx.session_picker_grouped, @@ -2938,6 +3012,33 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco search_only_on_slash: false, vim_normal_first: crate::appearance::cache::load_vim_mode(), }; + if let Event::Key(key) = ev { + if key.kind == KeyEventKind::Press + && (key!('c', CONTROL).matches(key) || key!('d', CONTROL).matches(key)) + { + return InputOutcome::Action(Action::Quit); + } + if let Some(selection) = session_picker_worktree_selection( + key, + ctx.sp_state, + &entry_map, + &non_selectable_flags, + ctx.sp_entries.as_deref(), + ctx.sp_content_results.as_deref(), + ) { + return InputOutcome::Action(match selection { + SessionPickerWorktreeSelection::Fuzzy(original_index) => { + Action::PickSessionInWorktree(original_index) + } + SessionPickerWorktreeSelection::Content { session_id, cwd } => { + Action::PickContentSessionInWorktree { session_id, cwd } + } + SessionPickerWorktreeSelection::Unavailable => { + return InputOutcome::Changed; + } + }); + } + } let outcome = handle_picker_input(ev, ctx.sp_state, entry_count, &config); match outcome { PickerOutcome::Selected(i) => match entry_map.get(i).and_then(|e| e.as_ref()) { @@ -2958,7 +3059,7 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco None => return InputOutcome::Changed, }, PickerOutcome::SubmitQuery => { - let query = ctx.sp_state.query.trim().to_string(); + let query = ctx.sp_state.query().trim().to_string(); if !query.is_empty() { return InputOutcome::Action(Action::LoadSession(query, None, false)); } @@ -3034,54 +3135,27 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco } return InputOutcome::Changed; } - PickerOutcome::Changed => { - if ctx.sp_state.query != query_before { - return InputOutcome::Action(Action::TriggerDeepSearch); - } - return InputOutcome::Changed; + PickerOutcome::QueryChanged => { + sync_session_picker_query_expansion( + ctx.sp_entries.as_deref(), + ctx.sp_content_results.as_deref(), + ctx.sp_entries_query.as_deref(), + ctx.sp_state, + ctx.session_picker_grouped, + ctx.sp_content_loading, + source_filter, + Some(current_repo.as_str()), + ); + return InputOutcome::Action(Action::TriggerDeepSearch); } + PickerOutcome::Changed => return InputOutcome::Changed, PickerOutcome::Unchanged => { if let Event::Key(key) = ev && key.kind == KeyEventKind::Press { - if key!('w', CONTROL).matches(key) && entry_count > 0 { - match entry_map - .get(ctx.sp_state.selected) - .and_then(|e| e.as_ref()) - { - Some(PickerItem::Fuzzy { original_index }) => { - if let Some(entries) = ctx.sp_entries.as_ref() - && let Some(entry) = entries.get(*original_index) - && !crate::app::foreign_sessions::is_foreign_picker_source( - &entry.source, - ) - { - return InputOutcome::Action(Action::PickSessionInWorktree( - *original_index, - )); - } - } - Some(PickerItem::Content { hit_index }) => { - if let Some(hits) = ctx.sp_content_results.as_ref() - && let Some(hit) = hits.get(*hit_index) - { - return InputOutcome::Action( - Action::PickContentSessionInWorktree { - session_id: hit.session_id.clone(), - cwd: hit.cwd.clone(), - }, - ); - } - } - None => {} - } - } - if key!('/', CONTROL).matches(key) && !ctx.sp_state.query.trim().is_empty() { + if key!('/', CONTROL).matches(key) && !ctx.sp_state.query().trim().is_empty() { return InputOutcome::Action(Action::ForceDeepSearch); } - if key!('c', CONTROL).matches(key) || key!('d', CONTROL).matches(key) { - return InputOutcome::Action(Action::Quit); - } } return InputOutcome::Unchanged; } @@ -3230,20 +3304,34 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco return InputOutcome::Action(Action::QuitConfirmed); } if key!(Enter).matches(key) { - let trimmed = ctx.auth_code_input.trim().to_string(); + let trimmed = ctx.auth_code_input.text().trim().to_string(); if !trimmed.is_empty() { return InputOutcome::Action(Action::SubmitAuthCode(trimmed)); } return InputOutcome::Unchanged; } - if key!(Backspace).matches(key) { - ctx.auth_code_input.pop(); + let outcome = if crate::input::key::is_paste_key(key) { + let Some(text) = crate::clipboard::system_clipboard_get() else { + return InputOutcome::Unchanged; + }; + ctx.auth_code_input.insert_paste(&text) + } else if key.modifiers.intersects( + crossterm::event::KeyModifiers::CONTROL + | crossterm::event::KeyModifiers::ALT + | crossterm::event::KeyModifiers::SUPER, + ) && !crate::input::key::is_altgr(key.modifiers) + { return InputOutcome::Changed; - } - if let crossterm::event::KeyCode::Char(c) = key.code { - ctx.auth_code_input.push(c); - return InputOutcome::Changed; - } + } else { + ctx.auth_code_input + .handle_key_with_insert_policy(key, |character| !character.is_control()) + }; + return match outcome { + LineEditOutcome::TextChanged + | LineEditOutcome::CursorChanged + | LineEditOutcome::HandledNoChange => InputOutcome::Changed, + LineEditOutcome::Unhandled => InputOutcome::Unchanged, + }; } AuthState::Authenticating { .. } => { if key!(Esc).matches(key) @@ -3270,8 +3358,7 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco mode: AuthMode::Loopback, .. } => { - let cleaned: String = text.chars().filter(|c| *c != '\n' && *c != '\r').collect(); - ctx.auth_code_input.push_str(&cleaned); + let _ = ctx.auth_code_input.insert_paste(text); return InputOutcome::Changed; } _ => {} @@ -3877,8 +3964,9 @@ impl AppView { auth_state: &self.auth_state, trust_state: &self.trust_state, login_label: self.login_label.as_deref(), - auth_code_input: &self.auth_code_input, - clipboard_copied: self.auth_clipboard_copied, + auth_code_input: self.auth_code_input.text(), + auth_code_cursor_byte: self.auth_code_input.cursor_byte(), + clipboard_delivery: self.auth_clipboard_delivery, show_raw_url: self.auth_show_raw_url, announcement: hero_announcement, tip, @@ -5105,10 +5193,12 @@ pub(crate) mod tests { use ratatui::{TerminalOptions, Viewport}; test_support::install_counting_hook(); crate::memory_release::run_deferred_release(); - let (frame_tx, _frame_rx) = std::sync::mpsc::channel::>(); - let backend = ratatui::backend::CrosstermBackend::new( - crate::render::draw::TermWriter::new(frame_tx, crate::render::draw::WriterSync::new()), - ); + let (frame_tx, _frame_rx) = + std::sync::mpsc::channel::(); + let writer = + crate::render::draw::TermWriter::new(frame_tx, crate::render::draw::WriterSync::new()) + .expect("single test writer"); + let backend = ratatui::backend::CrosstermBackend::new(writer); let mut terminal = xai_ratatui_inline::Terminal::with_options( backend, TerminalOptions { @@ -5201,17 +5291,18 @@ pub(crate) mod tests { login_label: None, login_method_id: None, auth_start_mode: AuthMode::Pending, - auth_code_input: String::new(), + auth_code_input: LineEditor::default(), next_auth_request_seq: 1, auth_url_poll_handle: None, deferred_startup: Default::default(), auth_use_oauth: false, - auth_clipboard_copied: false, + auth_clipboard_delivery: None, + auth_clipboard_feedback_generation: 0, team_id: None, team_name: None, is_zdr: false, team_role: None, - coding_data_retention_opt_out: false, + coding_data_retention_opt_out: true, show_tips: None, auto_update: None, ask_user_question_timeout_enabled: None, @@ -5310,6 +5401,7 @@ pub(crate) mod tests { cancel_rewind_enabled: true, session_recap_available: false, dashboard: None, + dashboard_return: None, dashboard_persisted: None, keyboard_normalizer: KeyboardNormalizer::from_terminal_context(), voice_mode_enabled: false, @@ -5399,8 +5491,9 @@ pub(crate) mod tests { primary.active_view = ActiveView::AgentDashboard; primary.dashboard = Some(crate::views::dashboard::DashboardState::new()); crate::clipboard::set_clipboard_probe_hook(clipboard_hook()); - let outcome = primary.handle_input_with_paste_provenance( + let outcome = primary.handle_input_at_with_paste_provenance( &Event::Paste(PRIMARY.to_owned()), + Instant::now(), PasteProvenance::X11Primary, ); let probe_calls = crate::clipboard::clipboard_probe_call_count(); @@ -6856,6 +6949,77 @@ pub(crate) mod tests { "full TUI must keep the queue-pane toggle, got {out:?}" ); } + fn welcome_session_entry(id: &str) -> SessionPickerEntry { + SessionPickerEntry { + id: id.into(), + summary: id.into(), + updated_at: chrono::Utc::now(), + created_at: chrono::Utc::now(), + cwd: "/tmp/repo".into(), + hostname: None, + source: "local".into(), + model_id: None, + num_messages: 0, + last_active_at: None, + branch: None, + repo_name: "tmp-repo".into(), + worktree_label: None, + card_detail: None, + } + } + fn open_welcome_session_picker(app: &mut AppView) { + crate::appearance::cache::set_vim_mode(false); + app.session_picker_entries = Some(vec![welcome_session_entry("session-0")]); + app.session_picker_state.search_active = true; + } + #[test] + fn welcome_session_picker_ctrl_w_resumes_in_worktree_while_search_is_focused() { + let mut app = test_app(); + open_welcome_session_picker(&mut app); + app.session_picker_state.set_query("session"); + let outcome = app.handle_input(&key_event(KeyCode::Char('w'), KeyModifiers::CONTROL)); + assert!(matches!( + outcome, + InputOutcome::Action(Action::PickSessionInWorktree(0)) + )); + assert_eq!(app.session_picker_state.query(), "session"); + } + #[test] + fn welcome_session_picker_ctrl_d_keeps_global_quit_precedence() { + let mut app = test_app(); + open_welcome_session_picker(&mut app); + app.session_picker_state.set_query("session"); + let outcome = app.handle_input(&ctrl_d()); + assert!(matches!(outcome, InputOutcome::Changed)); + assert!(matches!( + app.pending_action.as_ref().map(|pending| &pending.action), + Some(Action::Quit) + )); + assert_eq!(app.session_picker_state.query(), "session"); + } + #[test] + fn welcome_session_picker_cursor_motion_does_not_trigger_deep_search() { + let mut app = test_app(); + open_welcome_session_picker(&mut app); + app.session_picker_state.set_query("session"); + let outcome = app.handle_input(&key_event(KeyCode::Left, KeyModifiers::NONE)); + assert!(matches!(outcome, InputOutcome::Changed)); + assert_eq!(app.session_picker_state.query(), "session"); + } + #[test] + fn welcome_session_picker_ctrl_u_kills_to_cursor_and_triggers_deep_search() { + let mut app = test_app(); + open_welcome_session_picker(&mut app); + app.session_picker_state.set_query("session"); + let _ = app.handle_input(&key_event(KeyCode::Left, KeyModifiers::NONE)); + let outcome = app.handle_input(&key_event(KeyCode::Char('u'), KeyModifiers::CONTROL)); + assert!(matches!( + outcome, + InputOutcome::Action(Action::TriggerDeepSearch) + )); + assert_eq!(app.session_picker_state.query(), "n"); + assert_eq!(app.session_picker_state.query_cursor(), 0); + } #[test] fn welcome_ctrl_w_opens_new_worktree_dialog() { let mut app = test_app(); @@ -8238,6 +8402,18 @@ pub(crate) mod tests { assert!(app.new_worktree_dialog.is_none()); } #[test] + fn worktree_dialog_modified_enter_is_ignored() { + let mut app = test_app(); + app.auth_state = AuthState::Done; + app.new_worktree_dialog = Some(NewWorktreeDialogState::new()); + let outcome = app.handle_input(&key_event(KeyCode::Enter, KeyModifiers::CONTROL)); + assert!(matches!(outcome, InputOutcome::Unchanged)); + assert!(app.new_worktree_dialog.is_some()); + let outcome = app.handle_input(&key_event(KeyCode::Char('w'), KeyModifiers::SHIFT)); + assert!(matches!(outcome, InputOutcome::Changed)); + assert_eq!(app.new_worktree_dialog.as_ref().unwrap().label(), "W"); + } + #[test] fn worktree_dialog_enter_threads_label() { let mut app = test_app(); app.auth_state = AuthState::Done; @@ -8269,21 +8445,56 @@ pub(crate) mod tests { app.new_worktree_dialog = Some(NewWorktreeDialogState::new()); let outcome = app.handle_input(&key_event(KeyCode::Char('h'), KeyModifiers::NONE)); assert!(matches!(outcome, InputOutcome::Changed)); - assert_eq!(app.new_worktree_dialog.as_ref().unwrap().label_input, "h"); + assert_eq!(app.new_worktree_dialog.as_ref().unwrap().label(), "h"); let outcome = app.handle_input(&key_event(KeyCode::Char('i'), KeyModifiers::NONE)); assert!(matches!(outcome, InputOutcome::Changed)); - assert_eq!(app.new_worktree_dialog.as_ref().unwrap().label_input, "hi"); + assert_eq!(app.new_worktree_dialog.as_ref().unwrap().label(), "hi"); } #[test] fn worktree_dialog_backspace_removes_char() { let mut app = test_app(); app.auth_state = AuthState::Done; - app.new_worktree_dialog = Some(NewWorktreeDialogState { - label_input: "test".into(), - }); + let mut dialog = NewWorktreeDialogState::new(); + dialog.set_label("test"); + app.new_worktree_dialog = Some(dialog); let outcome = app.handle_input(&key_event(KeyCode::Backspace, KeyModifiers::NONE)); assert!(matches!(outcome, InputOutcome::Changed)); - assert_eq!(app.new_worktree_dialog.as_ref().unwrap().label_input, "tes"); + assert_eq!(app.new_worktree_dialog.as_ref().unwrap().label(), "tes"); + } + #[test] + fn worktree_dialog_enforces_byte_cap_for_typing_and_middle_paste() { + let mut app = test_app(); + app.auth_state = AuthState::Done; + let mut dialog = NewWorktreeDialogState::new(); + dialog.set_label("a".repeat(98)); + let _ = dialog.set_cursor_byte(1); + app.new_worktree_dialog = Some(dialog); + let outcome = app.handle_input(&Event::Paste("éx".to_owned())); + assert!(matches!(outcome, InputOutcome::Changed)); + let dialog = app.new_worktree_dialog.as_ref().unwrap(); + assert_eq!(dialog.label().len(), 100); + assert_eq!(&dialog.label()[1.."aé".len()], "é"); + let outcome = app.handle_input(&key_event(KeyCode::Char('中'), KeyModifiers::NONE)); + assert!(matches!(outcome, InputOutcome::Changed)); + assert_eq!(app.new_worktree_dialog.as_ref().unwrap().label().len(), 100); + let mut dialog = NewWorktreeDialogState::new(); + dialog.set_label("a".repeat(99)); + app.new_worktree_dialog = Some(dialog); + let _ = app.handle_input(&key_event(KeyCode::Char('é'), KeyModifiers::NONE)); + assert_eq!(app.new_worktree_dialog.as_ref().unwrap().label().len(), 99); + } + #[test] + fn worktree_dialog_paste_is_scoped_away_from_welcome_prompt() { + let mut app = test_app(); + app.auth_state = AuthState::Done; + let mut dialog = NewWorktreeDialogState::new(); + dialog.set_label("ab"); + let _ = dialog.set_cursor_byte(1); + app.new_worktree_dialog = Some(dialog); + let outcome = app.handle_input(&Event::Paste("中".to_owned())); + assert!(matches!(outcome, InputOutcome::Changed)); + assert_eq!(app.new_worktree_dialog.as_ref().unwrap().label(), "a中b"); + assert!(app.welcome_prompt.text().is_empty()); } #[test] fn authenticating_loopback_esc_quits() { @@ -8325,7 +8536,7 @@ pub(crate) mod tests { matches!(outcome, InputOutcome::Changed), "typing 'q' must edit the auth code input, got {outcome:?}" ); - assert_eq!(app.auth_code_input, "q"); + assert_eq!(app.auth_code_input.text(), "q"); } /// Users reflex-type the displayed device code; bare 'q' must not abort. #[test] @@ -8380,7 +8591,40 @@ pub(crate) mod tests { }; let outcome = app.handle_input(&key_event(KeyCode::Char('a'), KeyModifiers::NONE)); assert!(matches!(outcome, InputOutcome::Changed)); - assert_eq!(app.auth_code_input, "a"); + assert_eq!(app.auth_code_input.text(), "a"); + } + #[test] + fn authenticating_loopback_readline_control_chords_are_ignored() { + for code in [KeyCode::Char('u'), KeyCode::Char('d')] { + let mut app = test_app(); + app.auth_state = AuthState::Authenticating { + request_seq: 1, + handle: None, + auth_url: None, + mode: AuthMode::Loopback, + }; + app.auth_code_input.set_text("token"); + let outcome = app.handle_input(&key_event(code, KeyModifiers::CONTROL)); + assert!(matches!(outcome, InputOutcome::Changed)); + assert_eq!(app.auth_code_input.text(), "token"); + } + } + #[cfg(target_os = "windows")] + #[test] + fn authenticating_loopback_altgr_char_mutates_input() { + let mut app = test_app(); + app.auth_state = AuthState::Authenticating { + request_seq: 1, + handle: None, + auth_url: None, + mode: AuthMode::Loopback, + }; + let outcome = app.handle_input(&key_event( + KeyCode::Char('@'), + KeyModifiers::CONTROL | KeyModifiers::ALT, + )); + assert!(matches!(outcome, InputOutcome::Changed)); + assert_eq!(app.auth_code_input.text(), "@"); } #[test] fn authenticating_loopback_backspace_removes_char() { @@ -8391,10 +8635,10 @@ pub(crate) mod tests { auth_url: None, mode: AuthMode::Loopback, }; - app.auth_code_input = "ab".to_string(); + app.auth_code_input.set_text("ab"); let outcome = app.handle_input(&key_event(KeyCode::Backspace, KeyModifiers::NONE)); assert!(matches!(outcome, InputOutcome::Changed)); - assert_eq!(app.auth_code_input, "a"); + assert_eq!(app.auth_code_input.text(), "a"); } #[test] fn authenticating_loopback_paste_appends_text() { @@ -8405,10 +8649,45 @@ pub(crate) mod tests { auth_url: None, mode: AuthMode::Loopback, }; - app.auth_code_input = "tok".to_string(); + app.auth_code_input.set_text("tok"); let outcome = app.handle_input(&Event::Paste("en_value".to_string())); assert!(matches!(outcome, InputOutcome::Changed)); - assert_eq!(app.auth_code_input, "token_value"); + assert_eq!(app.auth_code_input.text(), "token_value"); + } + #[test] + fn authenticating_loopback_cursor_edit_and_paste_stay_scoped() { + let mut app = test_app(); + app.auth_state = AuthState::Authenticating { + request_seq: 1, + handle: None, + auth_url: None, + mode: AuthMode::Loopback, + }; + app.auth_code_input.set_text("ab"); + let _ = app.handle_input(&key_event(KeyCode::Left, KeyModifiers::NONE)); + let _ = app.handle_input(&Event::Paste("中\r\n".to_owned())); + assert_eq!(app.auth_code_input.text(), "a中b"); + assert!(app.welcome_prompt.text().is_empty()); + let _ = app.handle_input(&key_event(KeyCode::Delete, KeyModifiers::NONE)); + assert_eq!(app.auth_code_input.text(), "a中"); + } + #[test] + fn authenticating_loopback_uses_canonical_super_v_paste() { + let mut app = test_app(); + app.auth_state = AuthState::Authenticating { + request_seq: 1, + handle: None, + auth_url: None, + mode: AuthMode::Loopback, + }; + crate::clipboard::set_clipboard_probe_hook( + crate::clipboard::ClipboardProbeHook::no_raster(Some("secret\r\n")), + ); + let outcome = app.handle_input(&key_event(KeyCode::Char('v'), KeyModifiers::SUPER)); + crate::clipboard::clear_clipboard_probe_hook(); + assert!(matches!(outcome, InputOutcome::Changed)); + assert_eq!(app.auth_code_input.text(), "secret"); + assert!(app.welcome_prompt.text().is_empty()); } #[test] fn authenticating_loopback_enter_empty_is_noop() { @@ -8419,7 +8698,7 @@ pub(crate) mod tests { auth_url: None, mode: AuthMode::Loopback, }; - app.auth_code_input = " ".to_string(); + app.auth_code_input.set_text(" "); let outcome = app.handle_input(&key_event(KeyCode::Enter, KeyModifiers::NONE)); assert!(matches!(outcome, InputOutcome::Unchanged)); } @@ -8432,7 +8711,7 @@ pub(crate) mod tests { auth_url: None, mode: AuthMode::Loopback, }; - app.auth_code_input = " token123 ".to_string(); + app.auth_code_input.set_text(" token123 "); let outcome = app.handle_input(&key_event(KeyCode::Enter, KeyModifiers::NONE)); match outcome { InputOutcome::Action(Action::SubmitAuthCode(code)) => { @@ -10433,7 +10712,8 @@ pub(crate) mod tests { "f must not cycle the hidden source filter under chat mode" ); assert_eq!( - app.session_picker_state.query, "f", + app.session_picker_state.query(), + "f", "under chat mode `f` keeps its normal typing/search meaning" ); app.session_picker_state.reset(); diff --git a/crates/codegen/xai-grok-pager/src/app/csi_filter.rs b/crates/codegen/xai-grok-pager/src/app/csi_filter.rs index b9b7512..c25f940 100644 --- a/crates/codegen/xai-grok-pager/src/app/csi_filter.rs +++ b/crates/codegen/xai-grok-pager/src/app/csi_filter.rs @@ -4,7 +4,7 @@ use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers}; -use super::event_loop::is_bare_esc_press; +use super::event_loop::{TimedInputEvent, is_bare_esc_press}; /// Persistent filter that reassembles CSI fragments leaked by crossterm when a /// control sequence splits across `read()` boundaries — SGR mouse reports @@ -17,7 +17,7 @@ use super::event_loop::is_bare_esc_press; /// whose `\e` was isolated in a prior batch still leaks. pub(super) struct CsiFragmentFilter { state: CsiFragmentState, - tentative: Vec, + tentative: Vec, } impl CsiFragmentFilter { @@ -31,13 +31,13 @@ impl CsiFragmentFilter { /// Process a batch of events, filtering any CSI fragments. /// Partial matches are held in `self.tentative` until the next call. /// The `esc_before_run` pop is per-call only (can't retract across batches). - pub(super) fn filter(&mut self, events: Vec) -> Vec { + pub(super) fn filter(&mut self, events: Vec) -> Vec { let mut result = Vec::with_capacity(self.tentative.len() + events.len()); let mut esc_before_run = false; let mut filtered_count = 0usize; for ev in events { - if is_bare_esc_press(&ev) { + if is_bare_esc_press(&ev.event) { result.append(&mut self.tentative); self.state = CsiFragmentState::Idle; result.push(ev); @@ -45,7 +45,7 @@ impl CsiFragmentFilter { continue; } - match csi_filterable_char(&ev) { + match csi_filterable_char(&ev.event) { Some(ch) => match self.state.advance(ch) { CsiAdvance::Continue(next) => { self.state = next; @@ -67,10 +67,13 @@ impl CsiFragmentFilter { self.tentative.clear(); result.pop(); // retract the bare Esc // translate the reassembled report into its focus event so focus-driven UX (prompt refocus, recap away-timer, /gboom key-release) still fires over SSH - result.push(if ch == 'I' { - Event::FocusGained - } else { - Event::FocusLost + result.push(TimedInputEvent { + event: if ch == 'I' { + Event::FocusGained + } else { + Event::FocusLost + }, + arrived_at: ev.arrived_at, }); esc_before_run = false; self.state = CsiFragmentState::Idle; @@ -180,23 +183,34 @@ fn csi_filterable_char(ev: &Event) -> Option { #[cfg(test)] mod tests { + use std::sync::OnceLock; + use std::time::Instant; + use super::*; use crossterm::event::{KeyEvent, KeyEventState}; - fn press_mods(code: KeyCode, modifiers: KeyModifiers) -> Event { - Event::Key(KeyEvent { - code, - modifiers, - kind: KeyEventKind::Press, - state: KeyEventState::NONE, - }) + fn test_instant() -> Instant { + static NOW: OnceLock = OnceLock::new(); + *NOW.get_or_init(Instant::now) } - fn press(code: KeyCode) -> Event { + fn press_mods(code: KeyCode, modifiers: KeyModifiers) -> TimedInputEvent { + TimedInputEvent { + event: Event::Key(KeyEvent { + code, + modifiers, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }), + arrived_at: test_instant(), + } + } + + fn press(code: KeyCode) -> TimedInputEvent { press_mods(code, KeyModifiers::NONE) } - fn press_shift(code: KeyCode) -> Event { + fn press_shift(code: KeyCode) -> TimedInputEvent { press_mods(code, KeyModifiers::SHIFT) } @@ -204,7 +218,7 @@ mod tests { /// Build key events matching crossterm's actual output for a fragmented /// SGR mouse report `[ Vec { + fn sgr_fragment(btn: &str, col: &str, row: &str, term: char) -> Vec { let mut events = vec![press(KeyCode::Char('[')), press(KeyCode::Char('<'))]; for c in btn.chars() { events.push(press(KeyCode::Char(c))); @@ -307,13 +321,19 @@ mod tests { #[test] fn csi_filter_non_key_events_preserved() { - let mut events = vec![Event::Resize(80, 24)]; + let mut events = vec![TimedInputEvent { + event: Event::Resize(80, 24), + arrived_at: test_instant(), + }]; events.extend(sgr_fragment("35", "261", "67", 'M')); - events.push(Event::Resize(100, 30)); + events.push(TimedInputEvent { + event: Event::Resize(100, 30), + arrived_at: test_instant(), + }); let result = CsiFragmentFilter::new().filter(events); assert_eq!(result.len(), 2); - assert!(matches!(result[0], Event::Resize(80, 24))); - assert!(matches!(result[1], Event::Resize(100, 30))); + assert!(matches!(result[0].event, Event::Resize(80, 24))); + assert!(matches!(result[1].event, Event::Resize(100, 30))); } #[test] @@ -574,6 +594,31 @@ mod tests { // ── CSI focus report filtering tests ───────────────────────────── + #[test] + fn csi_filter_focus_timestamp_comes_from_completing_fragment() { + let start = Instant::now(); + let complete = start + std::time::Duration::from_millis(7); + let events = vec![ + TimedInputEvent { + event: press(KeyCode::Esc).event, + arrived_at: start, + }, + TimedInputEvent { + event: press(KeyCode::Char('[')).event, + arrived_at: start + std::time::Duration::from_millis(3), + }, + TimedInputEvent { + event: press_shift(KeyCode::Char('I')).event, + arrived_at: complete, + }, + ]; + + let result = CsiFragmentFilter::new().filter(events); + assert_eq!(result.len(), 1); + assert_eq!(result[0].event, Event::FocusGained); + assert_eq!(result[0].arrived_at, complete); + } + #[test] fn csi_filter_focus_in_after_esc_translated() { // Split \e[I focus-in (Esc, [, I — uppercase I arrives with SHIFT) is @@ -585,7 +630,10 @@ mod tests { ]; assert_eq!( CsiFragmentFilter::new().filter(events), - vec![Event::FocusGained] + vec![TimedInputEvent { + event: Event::FocusGained, + arrived_at: test_instant() + }] ); } @@ -600,7 +648,10 @@ mod tests { ]; assert_eq!( CsiFragmentFilter::new().filter(events), - vec![Event::FocusLost] + vec![TimedInputEvent { + event: Event::FocusLost, + arrived_at: test_instant() + }] ); } @@ -661,7 +712,10 @@ mod tests { result, vec![ press(KeyCode::Char('a')), - Event::FocusGained, + TimedInputEvent { + event: Event::FocusGained, + arrived_at: test_instant() + }, press(KeyCode::Char('b')), ] ); diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/auth.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/auth.rs index 44ccee9..031c491 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/auth.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/auth.rs @@ -1,7 +1,7 @@ //! Login, logout, account switching, and auth-code submission dispatchers. use super::ctx::{restore_auth_return_view, show_welcome}; -use super::queue::{maybe_drain_queue, note_peek_page_flip_after_drain}; +use super::queue::{maybe_drain_queue, note_peek_page_flip}; use super::router::dispatch; use super::session::lifecycle::{clear_startup_actions, drain_startup_actions}; use crate::app::actions::{Action, Effect}; @@ -98,7 +98,7 @@ pub(super) fn dispatch_switch_account(app: &mut AppView) -> Vec { let request_seq = app.next_auth_request_seq; app.next_auth_request_seq += 1; - app.auth_code_input.clear(); + app.auth_code_input.reset(); app.auth_state = AuthState::Authenticating { request_seq, handle: None, @@ -230,7 +230,7 @@ pub(super) fn dispatch_login(app: &mut AppView) -> Vec { let request_seq = app.next_auth_request_seq; app.next_auth_request_seq += 1; - app.auth_code_input.clear(); + app.auth_code_input.reset(); app.auth_state = AuthState::Authenticating { request_seq, handle: None, @@ -270,7 +270,7 @@ pub(super) fn dispatch_cancel_login(app: &mut AppView) -> Vec { app.next_auth_request_seq += 1; app.auth_state = AuthState::Done; app.auth_show_raw_url = false; - app.auth_code_input.clear(); + app.auth_code_input.reset(); restore_auth_return_view(app, return_view); // The user bailed out of re-auth — drop stashed prompts and strip the // stale re-auth prompt from scrollback (on all agents: the login may @@ -324,7 +324,7 @@ pub(super) fn handle_auth_complete( app.auth_state = AuthState::Done; app.auth_show_raw_url = false; app.welcome_prompt_focused = !app.is_access_blocked(); - app.auth_code_input.clear(); + app.auth_code_input.reset(); // Mid-session re-auth (`/login` or a 401 prompt): restore the // view the user was on instead of running the startup @@ -346,7 +346,7 @@ pub(super) fn handle_auth_complete( // have been started from the dashboard, not the agent // that 401'd). let mut retry_effects = Vec::new(); - let mut drained_ids = Vec::new(); + let mut page_flips = Vec::new(); for agent in app.agents.values_mut() { strip_trailing_auth_error_blocks(agent); // Auto-resubmit the prompt that failed on the expired @@ -358,12 +358,13 @@ pub(super) fn handle_auth_complete( "Re-authenticated. Retrying\u{2026}".to_string(), )); agent.session.enqueue_in_flight_prompt_front(prompt); - retry_effects.extend(maybe_drain_queue(agent)); - drained_ids.push(agent.session.id); + let drain = maybe_drain_queue(agent); + retry_effects.extend(drain.effects); + page_flips.push((agent.session.id, drain.page_flip_entry)); } } - for id in drained_ids { - note_peek_page_flip_after_drain(app, id); + for (id, page_flip_entry) in page_flips { + note_peek_page_flip(app, id, page_flip_entry); } let mut effects = dispatch(Action::RequestBundleStatus, app); if app.usage_visible { diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/billing.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/billing.rs index 3f5fa08..f528746 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/billing.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/billing.rs @@ -1,6 +1,6 @@ //! Subscription tier checks, credit-limit upsells, and auto-topup handling. -use super::queue::{maybe_drain_queue, note_peek_page_flip_after_drain}; +use super::queue::{maybe_drain_queue, note_peek_page_flip}; use crate::app::actions::Effect; use crate::app::agent::AgentId; use crate::app::agent_view::AgentView; @@ -10,9 +10,6 @@ use std::time::Duration; use xai_grok_telemetry::events::{SuperGrokUpsell, SuperGrokUpsellClicked}; use xai_grok_telemetry::session_ctx::log_event; -// Free-usage detection lives in shell next to the well-known code + 429 copy. -pub(crate) use xai_grok_shell::sampling::error::is_free_usage_exhausted_error; - /// How long the pager auto-checks subscription status before stopping. /// After this, the user can still manually check via the [Refresh] button. pub(super) const PAYWALL_AUTO_CHECK_TIMEOUT: Duration = Duration::from_secs(10 * 60); @@ -523,13 +520,13 @@ pub(super) fn handle_credit_limit_recheck_complete( // Either way, drop the stashed prompt. agent.credit_limit_stashed_prompt = None; - let mut effects = maybe_drain_queue(agent); - effects.push(Effect::FetchBilling { + let mut drain = maybe_drain_queue(agent); + drain.effects.push(Effect::FetchBilling { agent_id, silent: true, }); - note_peek_page_flip_after_drain(app, agent_id); - effects + note_peek_page_flip(app, agent_id, drain.page_flip_entry); + drain.effects } // Action handlers. diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/dashboard.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/dashboard.rs index 10a2185..9f39746 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/dashboard.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/dashboard.rs @@ -6,7 +6,7 @@ use super::dashboard_telemetry::{ }; use super::modes::{dispatch_cycle_mode_and_sync, set_yolo_mode, yolo_enable_blocked}; use super::permissions::resolve_permission_queue_transition; -use super::queue::{maybe_drain_queue, note_peek_page_flip_after_drain}; +use super::queue::{maybe_drain_queue, note_peek_page_flip}; use super::router::dispatch; use super::session::lifecycle::{ dispatch_new_session_inner_with_id, dispatch_new_worktree_session, @@ -19,7 +19,7 @@ use super::voice::voice_stop_on_submit; use crate::app::actions::{Action, Effect}; use crate::app::agent::AgentId; use crate::app::agent_view::AgentView; -use crate::app::app_view::{ActiveView, AppView, TrustState}; +use crate::app::app_view::{ActiveView, AppView, DashboardReturn, TrustState}; use agent_client_protocol as acp; // --------------------------------------------------------------------------- @@ -139,6 +139,11 @@ pub(super) fn dispatch_open_dashboard(app: &mut AppView) -> Vec { if matches!(app.active_view, ActiveView::AgentDashboard) { return dispatch_exit_dashboard(app); } + // Stamp return target for this visit (clears any prior leftover). + app.dashboard_return = match app.active_view { + ActiveView::Agent(id) => Some(DashboardReturn::Agent(id)), + _ => None, + }; // Preserve in-memory state across reopen. // `app.dashboard.is_some()` means we've previously initialised // it; preserve the user's filter / dispatch text / hover / @@ -199,16 +204,12 @@ pub(super) fn dispatch_open_dashboard(app: &mut AppView) -> Vec { // configure_dashboard_state(app); app.active_view = ActiveView::AgentDashboard; - // Outside leader mode there is no live leader roster to poll, so the - // dashboard would only show this process's in-memory agents. Seed it with - // the local on-disk session list (dormant/idle sessions) so it isn't empty; - // the event loop keeps it fresh on the roster-poll timer while open. log_dashboard_opened(app); - if !app.leader_mode { - app.dashboard_sessions_loading = true; - return vec![Effect::FetchDashboardSessions]; + app.dashboard_sessions_loading = true; + if app.leader_mode { + return vec![Effect::FetchRoster]; } - vec![] + vec![Effect::FetchDashboardSessions] } /// Helper: produce a closure that answers "does this DashboardRowId @@ -242,9 +243,21 @@ pub(super) fn dispatch_exit_dashboard(app: &mut AppView) -> Vec { d.close_popup(); } log_dashboard_closed(app); - // Return to either Welcome or the most recently active agent. - if let Some(id) = app.agents.keys().next().copied() { + let preferred = app + .dashboard_return + .take() + .filter(|t| app.agents.contains_key(&t.agent_id())); + // Overlay chrome only when the preferred target is still alive — never + // on the insertion-order fallback after the return agent was closed. + let (return_id, rearm_overlay) = match preferred { + Some(t) => (Some(t.agent_id()), t.is_overlay()), + None => (app.agents.keys().next().copied(), false), + }; + if let Some(id) = return_id { app.active_view = ActiveView::Agent(id); + if rearm_overlay { + rearm_session_overlay(app, id); + } surface_yolo_launch_block_notice(app, id); } else { show_welcome(app); @@ -252,6 +265,34 @@ pub(super) fn dispatch_exit_dashboard(app: &mut AppView) -> Vec { vec![] } +/// Restore session-overlay chrome (`attached_agent` + row cursor). +/// Keeps a live subagent takeover; otherwise clears it and selects TopLevel. +fn rearm_session_overlay(app: &mut AppView, id: AgentId) { + use crate::views::dashboard::DashboardRowId; + let live_child = app.agents.get(&id).and_then(|a| { + a.active_subagent + .as_ref() + .filter(|c| a.subagent_sessions.contains_key(*c)) + .cloned() + }); + let row = match live_child { + Some(child_session_id) => DashboardRowId::Subagent { + parent: id, + child_session_id, + }, + None => { + if let Some(agent) = app.agents.get_mut(&id) { + agent.active_subagent = None; + } + DashboardRowId::TopLevel(id) + } + }; + if let Some(d) = app.dashboard.as_mut() { + d.focus_row(row); + d.attached_agent = Some(id); + } +} + pub(super) fn dispatch_dashboard_attach( app: &mut AppView, id: crate::views::dashboard::DashboardRowId, @@ -398,6 +439,10 @@ pub(super) fn dispatch_dashboard_attach( /// `[✗]` close from the older design but applied to the new /// fullscreen-with-frame layout. pub(super) fn dispatch_dashboard_overlay_exit(app: &mut AppView) -> Vec { + // Capture before close_popup() clears attached_agent. + if let ActiveView::Agent(id) = app.active_view { + app.dashboard_return = Some(DashboardReturn::Overlay(id)); + } if let Some(d) = app.dashboard.as_mut() { d.restore_peek_viewport(&mut app.agents); d.close_popup(); @@ -1671,7 +1716,7 @@ pub(super) fn dispatch_dashboard_peek_reply( return vec![]; } - let effects = { + let drain = { let Some(agent) = app.agents.get_mut(&agent_id) else { if let Some(d) = app.dashboard.as_mut() { d.set_peek(None); @@ -1691,8 +1736,8 @@ pub(super) fn dispatch_dashboard_peek_reply( } maybe_drain_queue(agent) }; - // Note page-flip before restore on attach. - note_peek_page_flip_after_drain(app, agent_id); + note_peek_page_flip(app, agent_id, drain.page_flip_entry); + let effects = drain.effects; // Clear the reply draft now that it's been accepted, and drop any // stale error toast. @@ -1742,10 +1787,7 @@ pub(super) fn dispatch_dashboard_begin_rename(app: &mut AppView) { // the user a hold-Backspace. Esc / empty-draft Enter cancel without // touching the existing name. if let Some(d) = app.dashboard.as_mut() { - d.rename = Some(crate::views::dashboard::state::RenameDraft { - row: sel, - draft: String::new(), - }); + d.rename = Some(crate::views::dashboard::state::RenameDraft::new(sel, "")); } } @@ -1757,7 +1799,7 @@ pub(super) fn dispatch_dashboard_commit_rename(app: &mut AppView) -> Vec return vec![]; }; // Edge case 5: empty/whitespace draft cancels without committing. - let trimmed = rn.draft.trim(); + let trimmed = rn.text().trim(); if trimmed.is_empty() { return vec![]; } diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/mod.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/mod.rs index 6397c45..31c7e58 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/mod.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/mod.rs @@ -36,19 +36,15 @@ mod transcript; mod turn; mod voice; -pub(crate) use billing::{ - UPSELL_URL_PAYG, UPSELL_URL_UPGRADE, is_credit_limit_error, is_free_usage_exhausted_error, -}; +pub(crate) use billing::{UPSELL_URL_PAYG, UPSELL_URL_UPGRADE, is_credit_limit_error}; pub(crate) use modes::{downgrade_displayed_auto_if_gated, effective_auto}; pub(crate) use notes::{recap_unavailable_toast, scrollback_has_user_messages}; pub(crate) use permissions::resolve_permission_queue_transition; pub(crate) use prompt::dispatch_initial_prompt; pub(in crate::app) use prompt::{show_small_screen_tip, show_ssh_wrap_tip}; -#[cfg(test)] -pub(crate) use queue::maybe_drain_queue; pub(super) use queue::{ apply_turn_start_shim, arm_send_now_and_paint, maybe_drain_queue_and_note_peek, - shim_renders_own_user_block, + note_peek_page_flip, shim_renders_own_user_block, }; pub(in crate::app) use rewind::{find_user_prompt_entry_for_shell_index, shell_prompt_index_at}; pub(crate) use router::dispatch; diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/modes.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/modes.rs index 47b695a..953af54 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/modes.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/modes.rs @@ -1,7 +1,7 @@ //! Plan, yolo, auto, and permission mode transitions and toasts. use super::ctx::with_active_agent; -use super::queue::{maybe_drain_queue, note_peek_page_flip_after_drain}; +use super::queue::{maybe_drain_queue, note_peek_page_flip}; use super::session::lifecycle::skip_picker_and_create_session; use super::settings::ui::{refresh_open_settings_modals, save_success_toast}; use crate::app::actions::Effect; @@ -80,9 +80,9 @@ pub(super) fn dispatch_enter_plan_mode( .session .enqueue_prompt_with_skill_tokens(desc, skill_token_ranges); let drain = maybe_drain_queue(agent); - note_peek_page_flip_after_drain(app, id); + note_peek_page_flip(app, id, drain.page_flip_entry); let mut effects = Vec::with_capacity(1); - for eff in drain { + for eff in drain.effects { match eff { Effect::SendPrompt { agent_id, diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/notes.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/notes.rs index 19e0827..caac4da 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/notes.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/notes.rs @@ -285,25 +285,46 @@ pub(super) fn dispatch_send_btw(app: &mut AppView, question: String) -> Vec, + minimal_request_id: Option, ) -> Vec { if let Some(agent) = app.agents.get_mut(&agent_id) { use crate::views::btw_overlay::BtwOverlayState; + if let Some(request_id) = minimal_request_id { + crate::minimal_api::finish_minimal_btw(agent, request_id, result); + return vec![]; + } let question = match &agent.btw_state { Some(BtwOverlayState::Loading { question }) => question.clone(), _ => String::new(), diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/prompt.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/prompt.rs index 14eebca..0f8201a 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/prompt.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/prompt.rs @@ -7,8 +7,7 @@ use super::interject; use super::permissions::drain_permission_queue; use super::queue::{ apply_turn_start_shim, drain_prompt_state_to_last_queued, immediate_server_send_eligible, - maybe_drain_queue, note_peek_page_flip_after_drain, push_server_queue_echo, - retire_optimistic_echo, + maybe_drain_queue, note_peek_page_flip, push_server_queue_echo, retire_optimistic_echo, }; use super::router::dispatch; use super::session::fork::open_project_question; @@ -796,7 +795,7 @@ pub(super) fn dispatch_send_prompt_inner( } } - { + let drain = { let Some(agent) = app.agents.get_mut(&id) else { return effects; }; @@ -804,9 +803,7 @@ pub(super) fn dispatch_send_prompt_inner( // Insert into local prompt history (move-to-front dedup, cap at 200). // Skipped for modal-driven dispatch: the user didn't type these // commands and shouldn't see them in up-arrow history. - if !consume_input { - effects.extend(maybe_drain_queue(agent)); - } else { + if consume_input { let trimmed_key = text.trim().to_string(); if !trimmed_key.is_empty() { agent @@ -818,10 +815,11 @@ pub(super) fn dispatch_send_prompt_inner( agent.session.prompt_history.truncate(200); } } - effects.extend(maybe_drain_queue(agent)); } - } - note_peek_page_flip_after_drain(app, id); + maybe_drain_queue(agent) + }; + effects.extend(drain.effects); + note_peek_page_flip(app, id, drain.page_flip_entry); effects } @@ -915,9 +913,9 @@ pub(super) fn dispatch_send_bash_command(app: &mut AppView, command: String) -> agent.session.enqueue_bash_command(command.clone()); agent.prompt.set_text(""); - let effects = maybe_drain_queue(agent); - note_peek_page_flip_after_drain(app, id); - effects + let drain = maybe_drain_queue(agent); + note_peek_page_flip(app, id, drain.page_flip_entry); + drain.effects } /// Whether a load-result handler must stand down because a reconnect reload @@ -1098,7 +1096,7 @@ pub(super) fn handle_prompt_response( || result .as_ref() .err() - .is_some_and(|e| super::billing::is_free_usage_exhausted_error(e)); + .is_some_and(|e| xai_grok_shell::sampling::error::is_free_usage_exhausted_error(e)); let model_incompatible = agent.session.model_incompatible; // Context overflow: the RetryState handler already pushed the actionable // block, so the generic TurnFailed + error toast are redundant. Derived @@ -1218,7 +1216,6 @@ pub(super) fn handle_prompt_response( agent, event, ending_prompt_id.as_deref(), - false, ); let notification = match (&result, was_cancelling) { @@ -1418,19 +1415,24 @@ pub(super) fn handle_prompt_response( // turn-start shim. This sets `TurnRunning`, so the // `maybe_drain_queue` below no-ops rather than draining a local // prompt — the leader owns the drain order. - if let Some(p) = pending_adoption + let adopted_page_flip = if let Some(p) = pending_adoption && agent.session.current_prompt_id.is_none() { if response_pid.as_deref() != Some(p.prompt_id.as_str()) && agent.should_adopt_running_prompt(&p.prompt_id) { - apply_turn_start_shim(agent, p.prompt_id, p.text, &p.kind); + apply_turn_start_shim(agent, p.prompt_id, p.text, &p.kind) } else { agent.discard_pending_adoption_updates(&p.prompt_id); + None } - } + } else { + None + }; - let mut effects = maybe_drain_queue(agent); + let drain = maybe_drain_queue(agent); + let page_flip_entry = adopted_page_flip.or(drain.page_flip_entry); + let mut effects = drain.effects; // Predicted-next-prompt (tab autocomplete): fetch a fresh suggestion // (the stale one was wiped above) — but only after a clean, non-bash @@ -1463,8 +1465,7 @@ pub(super) fn handle_prompt_response( agent_id, silent: true, }); - // Agent borrow ends here; note needs dashboard + agents together. - note_peek_page_flip_after_drain(app, agent_id); + note_peek_page_flip(app, agent_id, page_flip_entry); return effects; } vec![] @@ -1511,9 +1512,9 @@ pub(super) fn handle_compact_complete( if app.reconnect_pending { return vec![]; } - let effects = maybe_drain_queue(agent); - note_peek_page_flip_after_drain(app, agent_id); - return effects; + let drain = maybe_drain_queue(agent); + note_peek_page_flip(app, agent_id, drain.page_flip_entry); + return drain.effects; } vec![] } diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/queue.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/queue.rs index d57b6ea..8b37cbe 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/queue.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/queue.rs @@ -10,10 +10,15 @@ use crate::app::actions::Effect; use crate::app::agent::{AgentCommand, AgentId}; use crate::app::agent_view::{AgentView, PromptMode}; use crate::app::app_view::{ActiveView, AppView}; +use crate::scrollback::EntryId; use crate::scrollback::block::RenderBlock; use agent_client_protocol as acp; use std::time::Instant; +fn page_flip_on_send() -> bool { + crate::appearance::cache::load_page_flip_on_send() +} + /// Whether a prompt/command submitted right now should take the /// server-authoritative immediate-send path: the **server is busy** /// (running a turn or still holding queued prompts), the session exists, the @@ -172,7 +177,21 @@ fn format_cron_prompt(prompt: &str, task_id: &str, human_schedule: &str) -> Stri /// - **Command**: starts command, returns the appropriate `Effect` (e.g., `Effect::Compact`) /// - **BashCommand**: starts turn (no user block), returns `Effect::SendBashCommand` /// - **Cron**: pushes cron prompt block to scrollback, starts turn, returns `Effect::SendPrompt` -pub(crate) fn maybe_drain_queue(agent: &mut AgentView) -> Vec { +pub(super) struct QueueDrain { + pub(super) effects: Vec, + pub(super) page_flip_entry: Option, +} + +impl QueueDrain { + fn blocked() -> Self { + Self { + effects: Vec::new(), + page_flip_entry: None, + } + } +} + +pub(super) fn maybe_drain_queue(agent: &mut AgentView) -> QueueDrain { use crate::app::agent::QueueEntryKind; use crate::unified_log as ulog; @@ -191,17 +210,17 @@ pub(crate) fn maybe_drain_queue(agent: &mut AgentView) -> Vec { if !agent.session.state.is_idle() { log_blocked("turn_running", sid); - return vec![]; + return QueueDrain::blocked(); } // Hold the drain during an in-flight model switch. See the // `model_switch_pending` field doc for why a reconnect must clear it. if agent.session.model_switch_pending { log_blocked("model_switch_pending", sid); - return vec![]; + return QueueDrain::blocked(); } if agent.session.loading_replay { log_blocked("loading_replay", sid); - return vec![]; + return QueueDrain::blocked(); } // Server-owned next turn: a non-running server row (including this // client's own in-flight send-now echo) drains shell-side — the @@ -217,11 +236,11 @@ pub(crate) fn maybe_drain_queue(agent: &mut AgentView) -> Vec { .any(|e| Some(e.id.as_str()) != running) { log_blocked("server_queue_owns_next_turn", sid); - return vec![]; + return QueueDrain::blocked(); } let Some(session_id) = agent.session.session_id.clone() else { log_blocked("no_session_id", None); - return vec![]; + return QueueDrain::blocked(); }; // Block drain if the user is editing the front prompt. @@ -236,12 +255,12 @@ pub(crate) fn maybe_drain_queue(agent: &mut AgentView) -> Vec { // from under the user. The turn status line will show a // "waiting on your edit" indicator. log_blocked("user_editing_front", Some(&session_id.0)); - return vec![]; + return QueueDrain::blocked(); } let queued = match agent.session.dequeue_prompt() { Some(q) => q, - None => return vec![], + None => return QueueDrain::blocked(), }; // A new turn is starting: follow-up chips belong to the previous @@ -333,13 +352,11 @@ pub(crate) fn maybe_drain_queue(agent: &mut AgentView) -> Vec { } agent.turn_started_at = Some(Instant::now()); - // Scroll to the new prompt and engage follow mode. let prompt_idx = agent.scrollback.len().saturating_sub(1); - agent.scrollback.set_selected(Some(prompt_idx)); - agent.scrollback.scroll_to_entry_top(prompt_idx); - agent.scrollback.enable_follow_with_preserve(); + let flip = page_flip_on_send(); + agent.scrollback.follow_new_turn(Some(prompt_idx), flip); - if let Some(mut blocks) = queued.wire_blocks { + let effects = if let Some(mut blocks) = queued.wire_blocks { // Skill injection: send structured blocks. // Annotate the first text block's meta with the display text // so the pager can reconstruct the clean prompt on session @@ -395,20 +412,24 @@ pub(crate) fn maybe_drain_queue(agent: &mut AgentView) -> Vec { prompt_id, skill_token_ranges: queued.skill_token_ranges, }] + }; + QueueDrain { + effects, + page_flip_entry: flip.then_some(prompt_entry_id), } } QueueEntryKind::Command => { // Currently only `/compact` — future slash commands will branch here. - // `start_command` occupies the turn slot without `start_turn`, so - // it closes the between-turns status window itself. - agent.end_work_announced = false; agent.session.start_command(AgentCommand::Compact); agent.turn_started_at = Some(Instant::now()); - vec![Effect::Compact { - agent_id, - session_id, - }] + QueueDrain { + effects: vec![Effect::Compact { + agent_id, + session_id, + }], + page_flip_entry: None, + } } QueueEntryKind::BashCommand => { // Start turn but do NOT push a user prompt block. @@ -417,30 +438,31 @@ pub(crate) fn maybe_drain_queue(agent: &mut AgentView) -> Vec { agent.session.current_prompt_id = Some(prompt_id.clone()); agent.turn_started_at = Some(Instant::now()); - // Engage follow mode so streaming output scrolls into view. - agent.scrollback.enable_follow_with_preserve(); + agent.scrollback.follow_new_turn(None, page_flip_on_send()); - vec![Effect::SendBashCommand { - agent_id, - session_id, - command: queued.text, - prompt_id, - }] + QueueDrain { + effects: vec![Effect::SendBashCommand { + agent_id, + session_id, + command: queued.text, + prompt_id, + }], + page_flip_entry: None, + } } QueueEntryKind::Cron => { let prompt_id = format!("scheduler-fired-{prompt_id}"); agent.note_self_originated_prompt(&prompt_id); agent.start_turn_boundary(Some(&prompt_id)); agent.session.current_prompt_id = Some(prompt_id.clone()); - agent + let prompt_entry_id = agent .scrollback .push_block(RenderBlock::cron_prompt(&queued.text)); agent.turn_started_at = Some(Instant::now()); let prompt_idx = agent.scrollback.len().saturating_sub(1); - agent.scrollback.set_selected(Some(prompt_idx)); - agent.scrollback.scroll_to_entry_top(prompt_idx); - agent.scrollback.enable_follow_with_preserve(); + let flip = page_flip_on_send(); + agent.scrollback.follow_new_turn(Some(prompt_idx), flip); let framed_text = format_cron_prompt( &queued.text, @@ -461,12 +483,15 @@ pub(crate) fn maybe_drain_queue(agent: &mut AgentView) -> Vec { acp::TextContent::new(framed_text).meta(Some(meta_map)), )]; - vec![Effect::SendPromptBlocks { - agent_id, - session_id, - blocks, - prompt_id, - }] + QueueDrain { + effects: vec![Effect::SendPromptBlocks { + agent_id, + session_id, + blocks, + prompt_id, + }], + page_flip_entry: flip.then_some(prompt_entry_id), + } } } } @@ -595,7 +620,7 @@ pub(crate) fn apply_turn_start_shim( prompt_id: String, text: Option, kind: &str, -) { +) -> Option { // Re-derive the per-turn viewer flag (see the ACP gate). This shim adopts a // turn the leader drained into the running slot: if THIS client originated // it (its own queued/immediate prompt), it drives it; otherwise it is @@ -663,7 +688,7 @@ pub(crate) fn apply_turn_start_shim( "shim_renders_own_user_block must mirror apply_turn_start_shim's display_block" ); - if let Some(block) = display_block { + let page_flip_entry = if let Some(block) = display_block { // The block may already be painted: consume the send-now paint's // id-keyed entry, else reuse a trailing echo block by text — never // double-push the user-prompt row. @@ -723,12 +748,15 @@ pub(crate) fn apply_turn_start_shim( chip_elements: Vec::new(), }); } - agent.scrollback.set_selected(Some(prompt_idx)); if skip_entry_top { + // Send-now: follow at the tail; never entry-top jump. + agent.scrollback.set_selected(Some(prompt_idx)); agent.scrollback.enable_follow_mode(); + None } else { - agent.scrollback.scroll_to_entry_top(prompt_idx); - agent.scrollback.enable_follow_with_preserve(); + let flip = page_flip_on_send(); + agent.scrollback.follow_new_turn(Some(prompt_idx), flip); + flip.then_some(prompt_entry_id) } } else { // No local block to render — this is a synthetic/cron/bash adoption with @@ -739,8 +767,9 @@ pub(crate) fn apply_turn_start_shim( // `displayText`). Clear the skip so `handle_user_message` renders it // instead of dropping it (the cause of viewers missing the cron header). agent.session.tracker.clear_user_echo_skip(); - agent.scrollback.enable_follow_with_preserve(); - } + agent.scrollback.follow_new_turn(None, page_flip_on_send()); + None + }; agent.turn_started_at = Some(Instant::now()); @@ -756,35 +785,34 @@ pub(crate) fn apply_turn_start_shim( if let Some(tools) = agent.session.tracker.take_pending_acp_tools() { agent.session.available_tools = Some(tools.into_iter().collect()); } + page_flip_entry } -/// After a successful queue drain, record a preserve page-flip on a matching -/// TopLevel dashboard peek lease (if any). -pub(crate) fn note_peek_page_flip_after_drain(app: &mut AppView, agent_id: AgentId) { - let page_flipped = app - .agents - .get(&agent_id) - .is_some_and(|a| a.scrollback.is_follow_preserve_scroll()); - if !page_flipped { +pub(crate) fn note_peek_page_flip( + app: &mut AppView, + agent_id: AgentId, + page_flip_entry: Option, +) { + let Some(entry_id) = page_flip_entry else { return; - } + }; let Some(mut dash) = app.dashboard.take() else { return; }; - dash.note_page_flip_for_lease(agent_id, &mut app.agents); + dash.note_page_flip_for_lease(agent_id, entry_id, &app.agents); app.dashboard = Some(dash); } /// Drain the next queued prompt and, when that page-flips under a lease, note it. pub(crate) fn maybe_drain_queue_and_note_peek(app: &mut AppView, agent_id: AgentId) -> Vec { - let effects = { + let drain = { let Some(agent) = app.agents.get_mut(&agent_id) else { return vec![]; }; maybe_drain_queue(agent) }; - note_peek_page_flip_after_drain(app, agent_id); - effects + note_peek_page_flip(app, agent_id, drain.page_flip_entry); + drain.effects } /// Try to drain the next queued prompt (triggered after editing completes). @@ -967,6 +995,54 @@ mod tests { assert_eq!(app.agents[&id].session.queue_len(), 0); } + #[test] + fn drain_scroll_honors_page_flip_setting() { + fn app_at_bottom() -> AppView { + let mut app = test_app_with_agent(); + let agent = app.agents.get_mut(&AgentId(0)).unwrap(); + for i in 0..40 { + agent + .scrollback + .push_block(RenderBlock::agent_message(format!("filler {i}"))); + } + agent.scrollback.prepare_layout(80, 8); + agent.scrollback.goto_bottom(); + app + } + + crate::appearance::cache::set_page_flip_on_send(false); + let mut app = app_at_bottom(); + let bottom = app.agents[&AgentId(0)].scrollback.scroll_offset(); + dispatch(Action::SendPrompt("go".into()), &mut app); + let sb = &app.agents[&AgentId(0)].scrollback; + assert!(sb.is_follow_mode()); + assert!(!sb.is_follow_preserve_scroll()); + assert_eq!(sb.scroll_offset(), bottom); + assert_eq!(sb.selected(), Some(sb.len() - 1)); + + let mut app = app_at_bottom(); + let agent = app.agents.get_mut(&AgentId(0)).unwrap(); + agent.scrollback.scroll_up(10); + let reading = agent.scrollback.scroll_offset(); + dispatch(Action::SendPrompt("go".into()), &mut app); + let sb = &app.agents[&AgentId(0)].scrollback; + assert!(!sb.is_follow_mode()); + assert_eq!(sb.scroll_offset(), reading); + assert_eq!(sb.selected(), Some(sb.len() - 1)); + + crate::appearance::cache::set_page_flip_on_send(true); + let mut app = app_at_bottom(); + dispatch(Action::SendPrompt("go".into()), &mut app); + let sb = &app.agents[&AgentId(0)].scrollback; + assert!(sb.is_follow_mode()); + assert!(sb.is_follow_preserve_scroll()); + assert_eq!(sb.selected(), Some(sb.len() - 1)); + + crate::appearance::cache::set_page_flip_on_send( + xai_grok_shell::agent::config::UiConfig::PAGE_FLIP_ON_SEND_DEFAULT, + ); + } + #[test] fn drain_queue_when_empty_does_nothing() { let mut app = test_app_with_agent(); @@ -1124,8 +1200,10 @@ mod tests { let agent = app.agents.get_mut(&id).unwrap(); let before = agent.scrollback.len(); - apply_turn_start_shim(agent, "p1".to_string(), Some("ls -la".to_string()), "bash"); + let page_flip_entry = + apply_turn_start_shim(agent, "p1".to_string(), Some("ls -la".to_string()), "bash"); + assert!(page_flip_entry.is_none()); assert!(agent.bash_turn, "bash adoption must set bash_turn"); assert!(agent.session.state.is_turn_running()); assert_eq!(agent.session.current_prompt_id.as_deref(), Some("p1")); @@ -1134,6 +1212,25 @@ mod tests { assert!(agent.session.in_flight_prompt.is_none()); } + #[test] + fn drain_reports_page_flip_only_when_prompt_starts() { + crate::appearance::cache::set_page_flip_on_send(true); + let mut app = test_app_with_agent(); + let agent = app.agents.get_mut(&AgentId(0)).unwrap(); + agent.session.enqueue_prompt("first".into()); + let started = maybe_drain_queue(agent); + let entry_id = started.page_flip_entry.expect("prompt starts a page flip"); + assert_eq!( + agent.scrollback.index_of_id(entry_id), + agent.scrollback.selected() + ); + + agent.session.enqueue_prompt("queued".into()); + let blocked = maybe_drain_queue(agent); + assert!(blocked.effects.is_empty()); + assert!(blocked.page_flip_entry.is_none()); + } + /// Turn-start path: the leader/viewer adoption shim /// clears the previous response's follow-up chips. #[test] @@ -1265,6 +1362,9 @@ mod tests { #[test] fn send_now_shim_skips_scroll_to_entry_top() { + // This test exercises the send-now exception within the page-flip + // behavior, so pin the setting ON (the cache is thread-local). + crate::appearance::cache::set_page_flip_on_send(true); fn seed_tall_scrollback(agent: &mut crate::app::agent_view::AgentView) -> usize { for i in 0..40 { agent @@ -1494,7 +1594,7 @@ mod tests { )); agent .scrollback - .push_block(RenderBlock::system("1 command still running")); + .push_block(RenderBlock::system("connection restored")); apply_turn_start_shim(agent, "p-late".into(), Some("ty".into()), "prompt"); assert_eq!( user_prompt_count(agent, "ty"), @@ -1772,7 +1872,7 @@ mod tests { ); // Drain while loading_replay is true → must be blocked. - let effects = maybe_drain_queue(app.agents.get_mut(&id).unwrap()); + let effects = maybe_drain_queue(app.agents.get_mut(&id).unwrap()).effects; assert!( effects.is_empty(), "drain must be blocked during loading_replay" @@ -1787,7 +1887,7 @@ mod tests { app.agents.get_mut(&id).unwrap().session.loading_replay = false; // Drain again → should succeed now. - let effects = maybe_drain_queue(app.agents.get_mut(&id).unwrap()); + let effects = maybe_drain_queue(app.agents.get_mut(&id).unwrap()).effects; assert_eq!(effects.len(), 1); assert!( matches!(&effects[0], Effect::SendPromptBlocks { .. }), @@ -1949,11 +2049,12 @@ mod tests { assert_eq!(count_parked(agent), 1, "one marker per park"); } - /// A re-park re-pushes the marker when intervening content buried the - /// previous one — otherwise the second park renders as a dead session. + /// A re-park after new PARENT OUTPUT (streamed through the tracker, so + /// the agent-output epoch bumps) pushes a fresh marker for the new park + /// episode — otherwise the second park renders as a dead session. #[test] - fn parked_marker_repushes_on_repark_after_buried_marker() { - use crate::scrollback::block::RenderBlock; + fn parked_marker_repushes_on_repark_after_new_parent_output() { + use crate::acp::meta::NotificationMeta; let mut app = test_app_with_agent(); let id = AgentId(0); @@ -1964,24 +2065,32 @@ mod tests { agent.maybe_push_parked_marker(); assert_eq!(count_parked(agent), 1); agent.maybe_push_parked_marker(); - assert_eq!(count_parked(agent), 1, "current tail must dedupe"); + assert_eq!(count_parked(agent), 1, "same episode must dedupe"); complete_task_output_wait_call(agent, "wait-1"); assert!(!agent.renders_parked(), "no parked look between parks"); - agent - .scrollback - .push_block(RenderBlock::agent_message("between-parks content")); + // Between-parks content streams through the tracker (the production + // path), bumping the agent-output epoch. + assert!(agent.session.tracker.handle_update( + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(acp::ContentBlock::Text( + acp::TextContent::new("between-parks content") + ),)), + &NotificationMeta::default(), + &mut agent.scrollback, + )); simulate_task_output_wait_call(agent, "wait-2", "bg-1", 600_000); agent.maybe_push_parked_marker(); - assert_eq!(count_parked(agent), 2, "buried marker re-pushes on re-park"); + assert_eq!(count_parked(agent), 2, "new episode pushes a fresh marker"); assert!(agent.renders_parked()); } - /// Content landing during a park makes the tail stale — the marker - /// restates, then the restated tail dedupes again. + /// Rows landing during a park WITHOUT parent output (chips and other + /// direct scrollback pushes) stay in the same park episode — the marker + /// is never re-pushed under them; the "watching · …" status row carries + /// the ongoing-work story instead. #[test] - fn parked_marker_restates_when_tail_goes_stale_mid_park() { + fn parked_marker_stays_single_when_rows_land_mid_park() { use crate::scrollback::block::RenderBlock; let mut app = test_app_with_agent(); @@ -1993,13 +2102,15 @@ mod tests { agent.maybe_push_parked_marker(); assert_eq!(count_parked(agent), 1); - agent - .scrollback - .push_block(RenderBlock::agent_message("mid-park chip")); + agent.scrollback.push_block(RenderBlock::bg_task_completed( + "sleep 5", + "bg-2", + std::time::Duration::from_secs(5), + )); agent.maybe_push_parked_marker(); - assert_eq!(count_parked(agent), 2, "stale tail restates the marker"); + assert_eq!(count_parked(agent), 1, "chips never re-push the marker"); agent.maybe_push_parked_marker(); - assert_eq!(count_parked(agent), 2, "restated tail dedupes again"); + assert_eq!(count_parked(agent), 1, "still the same park episode"); } /// A re-park whose previous marker is still the transcript tail pushes @@ -2083,10 +2194,11 @@ mod tests { assert_eq!(count_parked(agent), 0, "forgone stays silent all turn"); } - /// A work-count change makes the tail's marker stale — it restates, and - /// the restatement dedupes an immediate re-park. + /// A work-count change never touches the marker — the counts live on the + /// status row's "watching · …" cue, so the transcript stays quiet while + /// work finishes mid-park. #[test] - fn count_change_restates_marker_and_dedupes_repark() { + fn count_change_never_restates_marker() { let mut app = test_app_with_agent(); let id = AgentId(0); dispatch(Action::SendPrompt("first".into()), &mut app); @@ -2103,19 +2215,12 @@ mod tests { simulate_task_output_wait_call(agent, "wait-1", "bg-1", 30_000); agent.maybe_push_parked_marker(); assert_eq!(count_parked(agent), 1); + assert_eq!(agent.watchers().commands, 2); agent.session.bg_tasks.remove("bg-2"); agent.maybe_push_parked_marker(); - assert_eq!(count_parked(agent), 2, "count change restates the marker"); - - complete_task_output_wait_call(agent, "wait-1"); - simulate_task_output_wait_call(agent, "wait-2", "bg-1", 30_000); - agent.maybe_push_parked_marker(); - assert_eq!( - count_parked(agent), - 2, - "the restated tail dedupes the immediate re-park" - ); + assert_eq!(count_parked(agent), 1, "count changes never restate"); + assert_eq!(agent.watchers().commands, 1, "the cue counts down instead"); } #[test] @@ -2671,11 +2776,12 @@ mod tests { } /// The parked push is the unified marker: a static `TurnCompleted` event - /// block flagged `parked`, stamped with the turn's pid and carrying a - /// snapshot of the wait's work counts. The real final marker later + /// block flagged `parked`, stamped with the turn's pid. It carries no + /// work counts — the persistent "watching · …" status row above the + /// prompt tracks the still-running work. The real final marker later /// pushes separately (two static lines — main's park shape). #[test] - fn parked_marker_is_static_completed_snapshot_with_counts() { + fn parked_marker_is_static_completed_snapshot() { use crate::scrollback::block::RenderBlock; use crate::scrollback::blocks::SessionEvent; @@ -2707,9 +2813,7 @@ mod tests { block.stop_hooks.is_empty(), "a parked marker carries no hooks" ); - let work = block.end_work.expect("park snapshots the work counts"); - assert_eq!(work.running_commands, 1); - assert_eq!(work.running_monitors, 0); - assert_eq!(work.running_subagents, 0); + // The running bg command shows in the watchers cue, not the marker. + assert_eq!(agent.watchers().commands, 1); } } diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/router.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/router.rs index ed2f0af..66ef9d6 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/router.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/router.rs @@ -81,7 +81,7 @@ use super::settings::setters::{ set_contextual_hint_undo, set_contextual_hint_word_select, set_default_model, set_default_selected_permission, set_display_refresh_auto_cadence, set_fork_secondary_model, set_group_tool_verbs, set_hunk_tracker_mode, set_invert_scroll, set_keep_text_selection, - set_max_thoughts_width, set_multiline_mode, set_prompt_suggestions, + set_max_thoughts_width, set_multiline_mode, set_page_flip_on_send, set_prompt_suggestions, set_remember_tool_approvals, set_render_mermaid, set_respect_manual_folds, set_screen_mode, set_scroll_lines, set_scroll_mode, set_scroll_speed, set_show_thinking_blocks, set_show_tips, set_simple_mode, set_theme, set_timeline, set_timestamps, set_vim_mode, set_voice_capture_mode, @@ -117,8 +117,22 @@ use crate::app::app_view::{ActiveView, AppView, AuthState}; use crate::scrollback::types::DisplayMode; use crate::views::session_picker::CONTENT_EXPAND_OFFSET; use xai_grok_telemetry::session_ctx::log_event; -pub(super) fn auth_copy_was_confirmed(delivery: crate::clipboard::ClipboardDelivery) -> bool { - delivery == crate::clipboard::ClipboardDelivery::Confirmed +pub(super) fn dispatch_copy_auth_url( + app: &mut AppView, + copy: impl FnOnce(&str) -> crate::clipboard::ClipboardDelivery, +) -> Vec { + let AuthState::Authenticating { + auth_url: Some(url), + .. + } = &app.auth_state + else { + return vec![]; + }; + app.auth_clipboard_delivery = Some(copy(url)); + app.auth_clipboard_feedback_generation = app.auth_clipboard_feedback_generation.wrapping_add(1); + vec![Effect::ScheduleClearAuthCopyFeedback { + generation: app.auth_clipboard_feedback_generation, + }] } /// Dispatch an action: mutate state, return effects to execute. /// @@ -949,6 +963,7 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec { Action::SetCompactMode(v) => set_compact_mode(app, v), Action::SetTimestamps(v) => set_timestamps(app, v), Action::SetTimeline(v) => set_timeline(app, v), + Action::SetPageFlipOnSend(v) => set_page_flip_on_send(app, v), Action::SetSimpleMode(v) => set_simple_mode(app, v), Action::SetContextualHintUndo(v) => set_contextual_hint_undo(app, v), Action::SetContextualHintPlanMode(v) => set_contextual_hint_plan_mode(app, v), @@ -1035,19 +1050,7 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec { Action::CancelLogin => dispatch_cancel_login(app), Action::SubmitAuthCode(code) => dispatch_submit_auth_code(app, code), Action::CopyAuthUrl => { - if let AuthState::Authenticating { - auth_url: Some(url), - .. - } = &app.auth_state - { - app.auth_clipboard_copied = - auth_copy_was_confirmed(crate::clipboard::SystemClipboard::try_set(url)); - } - if app.auth_clipboard_copied { - vec![Effect::ScheduleClearAuthCopied] - } else { - vec![] - } + dispatch_copy_auth_url(app, crate::clipboard::SystemClipboard::try_set) } Action::ShowRawAuthUrl => { app.auth_show_raw_url = true; @@ -1187,14 +1190,6 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec { } vec![] } - Action::DashboardRenameInput(text) => { - if let Some(d) = app.dashboard.as_mut() - && let Some(rn) = d.rename.as_mut() - { - rn.draft = text; - } - vec![] - } Action::DashboardStop => dispatch_dashboard_stop(app), Action::DashboardCycleMode => { let policy_block = app.yolo_policy_block; diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/session/foreign.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/session/foreign.rs index c227fd5..5cd4447 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/session/foreign.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/session/foreign.rs @@ -30,7 +30,7 @@ impl PickerSurface<'_> { self.entries.as_deref(), self.content_results.as_deref(), self.state, - effective_filter_query(&self.state.query, self.entries_query.as_deref()), + effective_filter_query(self.state.query(), self.entries_query.as_deref()), self.grouped, *self.content_loading, self.source_filter, @@ -40,7 +40,7 @@ impl PickerSurface<'_> { fn restore_selection(&mut self, anchor: PickerSelectionAnchor) { let filter_query = - effective_filter_query(&self.state.query, self.entries_query.as_deref()).to_owned(); + effective_filter_query(self.state.query(), self.entries_query.as_deref()).to_owned(); restore_picker_selection( anchor, self.entries.as_deref(), @@ -141,8 +141,7 @@ pub(in crate::app::dispatch) fn dispatch_fetch_session_list(app: &mut AppView) - app.session_picker_loading = true; app.session_picker_entries = None; app.session_picker_state.selected = 0; - app.session_picker_state.query.clear(); - app.session_picker_state.query_cursor = 0; + app.session_picker_state.set_query(""); app.session_picker_state.search_active = false; app.session_picker_state.expanded.clear(); app.session_picker_content_results = None; diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/session/lifecycle.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/session/lifecycle.rs index e69d829..8f75b09 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/session/lifecycle.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/session/lifecycle.rs @@ -13,7 +13,7 @@ use crate::app::dispatch::ctx::{ }; use crate::app::dispatch::modes::inherit_auto_mode; use crate::app::dispatch::prompt::{consume_chat_kind, dispatch_initial_prompt}; -use crate::app::dispatch::queue::{maybe_drain_queue, note_peek_page_flip_after_drain}; +use crate::app::dispatch::queue::{QueueDrain, maybe_drain_queue, note_peek_page_flip}; use crate::app::dispatch::router::dispatch; use crate::app::dispatch::status::notify_session_ready; use crate::app::dispatch::task_result::unregister_session_effect; @@ -832,11 +832,15 @@ pub(in crate::app::dispatch) fn handle_session_created( if deferred.is_some() { agent.session.model_switch_pending = true; } - let mut effects = if app.reconnect_pending { - vec![] + let mut drain = if app.reconnect_pending { + QueueDrain { + effects: vec![], + page_flip_entry: None, + } } else { maybe_drain_queue(agent) }; + let mut effects = std::mem::take(&mut drain.effects); agent.session.prompt_history_loading = true; effects.push(Effect::FetchPromptHistory { agent_id, @@ -888,7 +892,7 @@ pub(in crate::app::dispatch) fn handle_session_created( cwd: agent.session.cwd.display().to_string(), }); notify_session_ready(&app.notification_service, agent); - note_peek_page_flip_after_drain(app, agent_id); + note_peek_page_flip(app, agent_id, drain.page_flip_entry); return effects; } vec![] @@ -923,11 +927,15 @@ pub(in crate::app::dispatch) fn handle_worktree_session_created( if deferred.is_some() { agent.session.model_switch_pending = true; } - let mut effects = if app.reconnect_pending { - vec![] + let mut drain = if app.reconnect_pending { + QueueDrain { + effects: vec![], + page_flip_entry: None, + } } else { maybe_drain_queue(agent) }; + let mut effects = std::mem::take(&mut drain.effects); agent.session.prompt_history_loading = true; effects.push(Effect::FetchPromptHistory { agent_id, @@ -979,7 +987,7 @@ pub(in crate::app::dispatch) fn handle_worktree_session_created( cwd: agent.session.cwd.display().to_string(), }); notify_session_ready(&app.notification_service, agent); - note_peek_page_flip_after_drain(app, agent_id); + note_peek_page_flip(app, agent_id, drain.page_flip_entry); return effects; } vec![] @@ -1093,8 +1101,9 @@ pub(in crate::app::dispatch) fn handle_switch_model_complete( vec![] } }; - effects.extend(maybe_drain_queue(agent)); - note_peek_page_flip_after_drain(app, agent_id); + let drain = maybe_drain_queue(agent); + effects.extend(drain.effects); + note_peek_page_flip(app, agent_id, drain.page_flip_entry); effects } else { vec![] diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/session/load.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/session/load.rs index ea47a7f..8ae64c4 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/session/load.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/session/load.rs @@ -15,7 +15,7 @@ use crate::app::dispatch::ctx::{ }; use crate::app::dispatch::modes::inherit_auto_mode; use crate::app::dispatch::prompt::{defer_to_open_reload_window, supersede_open_reload_window}; -use crate::app::dispatch::queue::{maybe_drain_queue, note_peek_page_flip_after_drain}; +use crate::app::dispatch::queue::{maybe_drain_queue, note_peek_page_flip}; use crate::app::dispatch::router::dispatch; use crate::app::dispatch::status::notify_session_ready; use crate::app::dispatch::transcript::extensions_modal_tab_fetches; @@ -57,7 +57,7 @@ pub(in crate::app::dispatch) fn clear_stale_session_id( let sid = acp::SessionId::new(session_id); for agent in app.agents.values_mut() { if agent.session.session_id.as_ref() == Some(&sid) { - agent.session.session_id = None; + agent.unbind_session_id(); } } sid @@ -280,8 +280,7 @@ pub(in crate::app::dispatch) fn dispatch_pick_session( }; let d = (entry.id.clone(), entry.source.clone(), entry.cwd.clone()); app.session_picker_loading = false; - app.session_picker_state.query.clear(); - app.session_picker_state.query_cursor = 0; + app.session_picker_state.set_query(""); app.session_picker_state.search_active = false; app.session_picker_state.expanded.clear(); app.session_picker_content_results = None; @@ -397,8 +396,7 @@ pub(in crate::app::dispatch) fn dispatch_pick_session_in_worktree( }; let d = (entry.id.clone(), entry.source.clone()); app.session_picker_loading = false; - app.session_picker_state.query.clear(); - app.session_picker_state.query_cursor = 0; + app.session_picker_state.set_query(""); app.session_picker_state.search_active = false; app.session_picker_state.expanded.clear(); d @@ -455,7 +453,7 @@ pub(in crate::app::dispatch) fn remove_session_from_pickers( entries.as_deref(), content_results.as_deref(), crate::views::session_picker::effective_filter_query( - &state.query, + state.query(), entries_query.as_deref(), ), true, @@ -477,7 +475,7 @@ pub(in crate::app::dispatch) fn remove_session_from_pickers( app.session_picker_entries.as_deref(), app.session_picker_content_results.as_deref(), crate::views::session_picker::effective_filter_query( - &app.session_picker_state.query, + app.session_picker_state.query(), app.session_picker_entries_query.as_deref(), ), app.session_picker_grouped, @@ -581,7 +579,7 @@ pub(in crate::app::dispatch) fn dispatch_trigger_deep_search( state.expanded.clear(); return vec![]; } - let query = state.query.trim().to_string(); + let query = state.query().trim().to_string(); *deep_search_seq += 1; let seq = *deep_search_seq; if query.len() < 2 { @@ -602,7 +600,7 @@ pub(in crate::app::dispatch) fn dispatch_trigger_deep_search( app.session_picker_state.expanded.clear(); return vec![]; } - let query = app.session_picker_state.query.trim().to_string(); + let query = app.session_picker_state.query().trim().to_string(); app.session_picker_deep_search_seq += 1; let seq = app.session_picker_deep_search_seq; if query.len() < 2 { @@ -628,9 +626,9 @@ fn dispatch_chat_search_refetch(app: &mut AppView, force: bool) -> Vec { let query = if let Some(agent) = get_active_agent(app) && let Some(ActiveModal::SessionPicker { state, .. }) = agent.active_modal.as_ref() { - state.query.trim().to_string() + state.query().trim().to_string() } else { - app.session_picker_state.query.trim().to_string() + app.session_picker_state.query().trim().to_string() }; app.session_picker_list_seq += 1; let seq = app.session_picker_list_seq; @@ -947,7 +945,9 @@ pub(in crate::app::dispatch) fn handle_session_loaded( if let Some(directive) = agent.pending_first_prompt.take() { agent.session.enqueue_prompt_front(directive); } - effects.extend(maybe_drain_queue(agent)); + let drain = maybe_drain_queue(agent); + let page_flip_entry = drain.page_flip_entry; + effects.extend(drain.effects); let cwd = agent.session.cwd.clone(); effects.push(Effect::HydrateSessionTitleFromDisk { agent_id, @@ -993,7 +993,7 @@ pub(in crate::app::dispatch) fn handle_session_loaded( }); notify_session_ready(&app.notification_service, agent); crate::memory_release::release_retained_memory_with("session-load-replay"); - note_peek_page_flip_after_drain(app, agent_id); + note_peek_page_flip(app, agent_id, page_flip_entry); return effects; } vec![] diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/settings/setters.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/settings/setters.rs index 4d16527..378d64c 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/settings/setters.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/settings/setters.rs @@ -921,6 +921,28 @@ pub(in crate::app::dispatch) fn set_timeline(app: &mut AppView, new: bool) -> Ve }] } +pub(super) fn set_page_flip_on_send_inner(app: &mut AppView, new: bool) { + app.current_ui.page_flip_on_send = Some(new); + crate::appearance::cache::set_page_flip_on_send(new); +} + +/// SHARED: cache + `[ui].page_flip_on_send` via `Effect::PersistSetting`. +pub(in crate::app::dispatch) fn set_page_flip_on_send(app: &mut AppView, new: bool) -> Vec { + let prev = crate::appearance::cache::load_page_flip_on_send(); + if prev == new { + return vec![]; + } + set_page_flip_on_send_inner(app, new); + refresh_open_settings_modals(app); + tracing::info!(target: "settings", key = "page_flip_on_send", value = new, "setting changed"); + app.show_toast(&save_success_toast("Snap prompt to top on send", new)); + vec![Effect::PersistSetting { + key: "page_flip_on_send", + value: crate::settings::SettingValue::Bool(new), + rollback_value: crate::settings::SettingValue::Bool(prev), + }] +} + /// State-only mutation for `simple_mode`. /// /// Propagates to every agent's `input_mode` so the toggle takes diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/settings/ui.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/settings/ui.rs index 2eb2c40..1d0f939 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/settings/ui.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/settings/ui.rs @@ -7,9 +7,9 @@ use super::setters::{ set_default_selected_permission_inner, set_display_refresh_auto_cadence_inner, set_fork_secondary_model_inner, set_group_tool_verbs_inner, set_hunk_tracker_mode_inner, set_invert_scroll_inner, set_keep_text_selection_inner, set_max_thoughts_width_inner, - set_multiline_mode, set_prompt_suggestions_inner, set_remember_tool_approvals_inner, - set_render_mermaid_inner, set_respect_manual_folds_inner, set_screen_mode_inner, - set_scroll_lines_inner, set_scroll_mode_inner, set_scroll_speed_inner, + set_multiline_mode, set_page_flip_on_send_inner, set_prompt_suggestions_inner, + set_remember_tool_approvals_inner, set_render_mermaid_inner, set_respect_manual_folds_inner, + set_screen_mode_inner, set_scroll_lines_inner, set_scroll_mode_inner, set_scroll_speed_inner, set_show_thinking_blocks_inner, set_show_tips_inner, set_simple_mode_inner, set_theme_inner, set_timeline_inner, set_timestamps, set_timestamps_inner, set_vim_mode_inner, set_voice_capture_mode_inner, set_voice_stt_language_inner, @@ -688,6 +688,7 @@ pub(in crate::app::dispatch) fn action_for_reset( ("compact_mode", SettingValue::Bool(b)) => Some(Action::SetCompactMode(*b)), ("show_timestamps", SettingValue::Bool(b)) => Some(Action::SetTimestamps(*b)), ("show_timeline", SettingValue::Bool(b)) => Some(Action::SetTimeline(*b)), + ("page_flip_on_send", SettingValue::Bool(b)) => Some(Action::SetPageFlipOnSend(*b)), ("simple_mode", SettingValue::Bool(b)) => Some(Action::SetSimpleMode(*b)), ("contextual_hints.undo", SettingValue::Bool(b)) => Some(Action::SetContextualHintUndo(*b)), ("contextual_hints.plan_mode", SettingValue::Bool(b)) => { @@ -803,7 +804,7 @@ pub(in crate::app::dispatch) fn action_for_reset( // max_thoughts_width: direct round-trip. ("max_thoughts_width", SettingValue::Int(i)) => Some(Action::SetMaxThoughtsWidth(*i)), // coding_data_sharing: "opt-in" / "opt-out" → bool. - // "opt-out" arm is a skew guard (default is "opt-in"). + // Both arms needed (registry default is "opt-out"). ("coding_data_sharing", SettingValue::Enum("opt-in")) => { Some(Action::SetCodingDataSharing { opted_in: true }) } @@ -877,6 +878,7 @@ pub(in crate::app::dispatch) fn apply_setting_rollback( ("compact_mode", SettingValue::Bool(b)) => set_compact_mode_inner(app, *b), ("show_timestamps", SettingValue::Bool(b)) => set_timestamps_inner(app, *b), ("show_timeline", SettingValue::Bool(b)) => set_timeline_inner(app, *b), + ("page_flip_on_send", SettingValue::Bool(b)) => set_page_flip_on_send_inner(app, *b), ("simple_mode", SettingValue::Bool(b)) => set_simple_mode_inner(app, *b), ("contextual_hints.undo", SettingValue::Bool(b)) => { set_contextual_hint_inner(app, |h, v| h.undo = v, *b) diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/status.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/status.rs index 1b85a5f..4eaeada 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/status.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/status.rs @@ -71,6 +71,9 @@ pub(super) fn dispatch_show_session_info(app: &mut AppView) -> Vec { /// Three-state display: Enterprise ZDR, coding data sharing opted out, /// or opted in. Labels align with `CODING_DATA_SHARING_CHOICES` in /// `settings/defs.rs` and the `coding_data_sharing_toast` format. +/// +/// Also lists config knobs that `/privacy` does not change (technical +/// pointers only; no policy claims). pub(super) fn dispatch_show_privacy_info(app: &mut AppView) -> Vec { let mut lines = Vec::new(); @@ -92,6 +95,12 @@ pub(super) fn dispatch_show_privacy_info(app: &mut AppView) -> Vec { lines.push(" Use /privacy opt-out to enable privacy mode."); } + // Config keys only; do not describe retention/training/analytics policy here. + lines.push(""); + lines.push(" Other settings (not changed by /privacy):"); + lines.push(" - [features] telemetry / GROK_TELEMETRY_ENABLED"); + lines.push(" - [telemetry] trace_upload / GROK_TELEMETRY_TRACE_UPLOAD"); + lines.push(" - GROK_EXTERNAL_OTEL / OTEL_*"); lines.push(""); lines.push(" Learn more: https://x.ai/legal"); let text = lines.join("\n"); diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/task_result.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/task_result.rs index dbc87cd..73e6082 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/task_result.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/task_result.rs @@ -335,10 +335,12 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec } TaskResult::RosterLoaded { sessions } => { app.leader_roster = sessions; + app.dashboard_sessions_loading = false; vec![] } TaskResult::RosterFailed { error } => { tracing::debug!(error = % error, "leader roster fetch failed"); + app.dashboard_sessions_loading = false; vec![] } TaskResult::DashboardSessionsLoaded { sessions } => { @@ -559,7 +561,7 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec && *current_seq == request_seq { app.auth_state = AuthState::Pending { error: Some(error) }; - app.auth_code_input.clear(); + app.auth_code_input.reset(); } vec![] } @@ -868,7 +870,11 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec } vec![] } - TaskResult::BtwResponse { agent_id, result } => handle_btw_response(app, agent_id, result), + TaskResult::BtwResponse { + agent_id, + result, + minimal_request_id, + } => handle_btw_response(app, agent_id, result, minimal_request_id), TaskResult::InterjectQueued { .. } => vec![], TaskResult::RecapRequested { session_id, @@ -926,8 +932,10 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec } vec![] } - TaskResult::AuthCopiedTimeout => { - app.auth_clipboard_copied = false; + TaskResult::AuthCopyFeedbackTimeout { generation } => { + if generation == app.auth_clipboard_feedback_generation { + app.auth_clipboard_delivery = None; + } vec![] } TaskResult::PaywallCheckTick => { @@ -959,7 +967,7 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec app.last_subscription_check_at = None; app.login_method_id = None; ensure_login_method(app); - app.auth_clipboard_copied = false; + app.auth_clipboard_delivery = None; let effects = dispatch_exit_session(app); app.welcome_prompt_focused = false; effects diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/billing.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/billing.rs index 538e0f8..358b126 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/billing.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/billing.rs @@ -1,6 +1,7 @@ //! Tests for credit-limit upsells, paywall gating, and auto-topup. use super::*; +use xai_grok_shell::sampling::error::is_free_usage_exhausted_error; // ── Credit-limit upsell / max-tier tests ─────────────────────────── diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/dashboard.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/dashboard.rs index f05fe90..3ee1e26 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/dashboard.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/dashboard.rs @@ -1,6 +1,7 @@ //! Tests for dashboard dispatchers: attach, overlays, rows, and permissions. use super::*; +use crate::app::dispatch::queue::maybe_drain_queue; #[test] fn voice_final_appends_to_dashboard_dispatch() { @@ -1089,7 +1090,7 @@ fn dashboard_image_dispatch_cancel_rewind_resends_attachment() { agent.session.session_id = Some(acp::SessionId::new("dashboard-image")); agent.session.state = AgentState::Idle; assert!(matches!( - maybe_drain_queue(agent).as_slice(), + maybe_drain_queue(agent).effects.as_slice(), [Effect::SendPromptBlocks { .. }] )); } @@ -1546,21 +1547,59 @@ fn dashboard_open_without_leader_fetches_local_sessions() { ); } -/// In leader mode the live FleetView roster is the source (polled by the -/// event loop), so opening must NOT also fetch the local list. +/// In leader mode the live FleetView roster is the source, so opening must +/// fetch that roster immediately (not wait for the poll tick) and must NOT +/// also fetch the local on-disk list. #[serial_test::serial(GROK_AGENT_DASHBOARD)] #[test] -fn dashboard_open_with_leader_skips_local_session_fetch() { +fn dashboard_open_with_leader_fetches_roster_not_local_sessions() { let mut app = test_app_with_agent(); app.leader_mode = true; app.active_view = ActiveView::Agent(AgentId(0)); let effects = dispatch_open_dashboard(&mut app); + assert!( + effects.iter().any(|e| matches!(e, Effect::FetchRoster)), + "leader dashboard open must fetch the live roster immediately", + ); assert!( !effects .iter() .any(|e| matches!(e, Effect::FetchDashboardSessions)), - "leader dashboard open polls the live roster, not the local list", + "leader dashboard open must not fetch the local on-disk list", ); + assert!( + app.dashboard_sessions_loading, + "leader open must show Loading sessions until RosterLoaded", + ); +} + +#[test] +fn roster_loaded_clears_dashboard_sessions_loading() { + let mut app = test_app(); + app.leader_mode = true; + app.dashboard_sessions_loading = true; + let _ = dispatch( + Action::TaskComplete(TaskResult::RosterLoaded { + sessions: vec![idle_roster_entry("sess-live", "Working agent")], + }), + &mut app, + ); + assert!(!app.dashboard_sessions_loading); + assert_eq!(app.leader_roster.len(), 1); +} + +#[test] +fn roster_failed_clears_dashboard_sessions_loading() { + let mut app = test_app(); + app.leader_mode = true; + app.dashboard_sessions_loading = true; + let _ = dispatch( + Action::TaskComplete(TaskResult::RosterFailed { + error: "timeout".into(), + }), + &mut app, + ); + assert!(!app.dashboard_sessions_loading); } /// `DashboardSessionsLoaded` stores the local idle sessions, and @@ -2919,6 +2958,148 @@ fn dashboard_ctrl_backslash_exits_dashboard() { ); } +fn insert_second_agent(app: &mut AppView) -> AgentId { + let id = AgentId(1); + let session = make_test_agent_session(app, id, "second"); + let mut agent = AgentView::new(session, ScrollbackState::new()); + agent.generated_session_title = Some("Second".into()); + app.agents.insert(id, agent); + mark_agent_nonempty(app, id); + id +} + +/// Multi-agent: Ctrl+\ out of the dashboard restores the agent we left, +/// not insertion-order first (empty older sessions under leader mode). +#[serial_test::serial(GROK_AGENT_DASHBOARD)] +#[test] +fn dashboard_ctrl_backslash_returns_to_same_agent() { + let mut app = test_app_with_agent(); + mark_agent_nonempty(&mut app, AgentId(0)); + let id2 = insert_second_agent(&mut app); + + app.active_view = ActiveView::Agent(id2); + let _ = dispatch_open_dashboard(&mut app); + let _ = dispatch_open_dashboard(&mut app); + + assert_eq!(app.active_view, ActiveView::Agent(id2)); + assert!(app.dashboard_return.is_none()); + assert_eq!(app.dashboard.as_ref().and_then(|d| d.attached_agent), None); +} + +/// Open from Welcome replaces any leftover return target (e.g. after /home). +#[serial_test::serial(GROK_AGENT_DASHBOARD)] +#[test] +fn dashboard_open_from_welcome_clears_stale_return_agent() { + use crate::app::app_view::DashboardReturn; + let mut app = test_app_with_agent(); + mark_agent_nonempty(&mut app, AgentId(0)); + let id2 = insert_second_agent(&mut app); + + app.dashboard_return = Some(DashboardReturn::Agent(id2)); + app.active_view = ActiveView::Welcome; + let _ = dispatch_open_dashboard(&mut app); + let _ = dispatch_exit_dashboard(&mut app); + + assert_eq!(app.active_view, ActiveView::Agent(AgentId(0))); +} + +/// Attach → overlay exit → dashboard exit restores agent + overlay chrome. +#[serial_test::serial(GROK_AGENT_DASHBOARD)] +#[test] +fn dashboard_overlay_exit_then_exit_returns_to_attached_agent() { + let mut app = test_app_with_agent(); + mark_agent_nonempty(&mut app, AgentId(0)); + let id2 = insert_second_agent(&mut app); + + open_dashboard(&mut app); + let _ = dispatch_dashboard_attach( + &mut app, + crate::views::dashboard::DashboardRowId::TopLevel(id2), + ); + let _ = dispatch_dashboard_overlay_exit(&mut app); + let _ = dispatch_exit_dashboard(&mut app); + + assert_eq!(app.active_view, ActiveView::Agent(id2)); + assert_eq!( + app.dashboard.as_ref().and_then(|d| d.attached_agent), + Some(id2) + ); +} + +/// Subagent attach round-trip keeps child takeover and Subagent row cursor. +#[serial_test::serial(GROK_AGENT_DASHBOARD)] +#[test] +fn dashboard_overlay_exit_then_exit_restores_subagent_row() { + let mut app = test_app_with_agent(); + open_dashboard(&mut app); + let parent = AgentId(0); + mark_agent_nonempty(&mut app, parent); + let child_sid = "child-return".to_string(); + app.agents + .get_mut(&parent) + .unwrap() + .subagent_sessions + .insert(child_sid.clone(), make_test_subagent(&child_sid, "sa-ret")); + let child_view = AgentView::new( + make_test_agent_session(&app, AgentId(1), "child-session"), + ScrollbackState::new(), + ); + app.agents + .get_mut(&parent) + .unwrap() + .subagent_views + .insert(child_sid.clone(), Box::new(child_view)); + + let _ = dispatch_dashboard_attach( + &mut app, + crate::views::dashboard::DashboardRowId::Subagent { + parent, + child_session_id: child_sid.clone(), + }, + ); + let _ = dispatch_dashboard_overlay_exit(&mut app); + let _ = dispatch_exit_dashboard(&mut app); + + assert_eq!(app.active_view, ActiveView::Agent(parent)); + assert_eq!( + app.dashboard.as_ref().and_then(|d| d.attached_agent), + Some(parent) + ); + assert_eq!( + app.agents[&parent].active_subagent.as_deref(), + Some(child_sid.as_str()) + ); + assert_eq!( + app.dashboard.as_ref().and_then(|d| d.selected.clone()), + Some(crate::views::dashboard::DashboardRowId::Subagent { + parent, + child_session_id: child_sid, + }) + ); +} + +/// Dead overlay return target: fall back without painting overlay chrome. +#[serial_test::serial(GROK_AGENT_DASHBOARD)] +#[test] +fn dashboard_exit_does_not_overlay_fallback_when_return_agent_dead() { + let mut app = test_app_with_agent(); + mark_agent_nonempty(&mut app, AgentId(0)); + let id2 = insert_second_agent(&mut app); + + open_dashboard(&mut app); + let _ = dispatch_dashboard_attach( + &mut app, + crate::views::dashboard::DashboardRowId::TopLevel(id2), + ); + let _ = dispatch_dashboard_overlay_exit(&mut app); + app.agents.shift_remove(&id2); + + let _ = dispatch_exit_dashboard(&mut app); + + assert_eq!(app.active_view, ActiveView::Agent(AgentId(0))); + assert_eq!(app.dashboard.as_ref().and_then(|d| d.attached_agent), None); +} + /// `DashboardOverlayExit` returns the user to /// the dashboard from an attached agent view and clears the /// overlay state. @@ -3771,16 +3952,24 @@ fn dashboard_rename_end_to_end_top_level_row() { .rename .as_ref() .expect("begin_rename must arm the rename overlay") - .draft, + .text(), "", "rename draft must start empty (no prefilled title)", ); - // Simulate typing a new title via `Action::DashboardRenameInput`. - let effects = dispatch( - Action::DashboardRenameInput("My renamed session".to_string()), - &mut app, - ); - assert!(effects.is_empty(), "rename input takes no effects"); + let registry = crate::actions::ActionRegistry::defaults(); + for character in "My renamed session".chars() { + let outcome = app.dashboard.as_mut().unwrap().handle_input( + &crossterm::event::Event::Key(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char(character), + crossterm::event::KeyModifiers::NONE, + )), + ®istry, + ); + assert!(matches!( + outcome, + crate::app::app_view::InputOutcome::Changed + )); + } assert_eq!( app.dashboard .as_ref() @@ -3788,7 +3977,7 @@ fn dashboard_rename_end_to_end_top_level_row() { .rename .as_ref() .unwrap() - .draft, + .text(), "My renamed session", ); // Commit — emits a RenameSession effect and stamps the agent @@ -3829,7 +4018,11 @@ fn dashboard_rename_cancel_action_emits_no_effect() { d.selected = Some(crate::views::dashboard::DashboardRowId::TopLevel(id)); } dispatch_dashboard_begin_rename(&mut app); - let _ = dispatch(Action::DashboardRenameInput("scratch".into()), &mut app); + app.dashboard + .as_mut() + .and_then(|dashboard| dashboard.rename.as_mut()) + .expect("rename draft") + .set_text("scratch"); let effects = dispatch(Action::DashboardCancelRename, &mut app); assert!(effects.is_empty(), "cancel must not emit effects"); assert!( @@ -3861,10 +4054,7 @@ fn dashboard_rename_esc_keystroke_routes_to_cancel() { let mut state = DashboardState::new(); let id = crate::views::dashboard::DashboardRowId::TopLevel(AgentId(0)); state.selected = Some(id.clone()); - state.rename = Some(RenameDraft { - row: id.clone(), - draft: "draft".into(), - }); + state.rename = Some(RenameDraft::new(id.clone(), "draft")); // Synthesise an Esc keystroke and feed it to `handle_input`. let esc = Event::Key(KeyEvent { code: KeyCode::Esc, @@ -4067,10 +4257,10 @@ fn dashboard_commit_rename_empty_does_not_emit_effect() { d.selected = Some(crate::views::dashboard::DashboardRowId::TopLevel(AgentId( 0, ))); - d.rename = Some(crate::views::dashboard::state::RenameDraft { - row: crate::views::dashboard::DashboardRowId::TopLevel(AgentId(0)), - draft: " ".to_string(), - }); + d.rename = Some(crate::views::dashboard::state::RenameDraft::new( + crate::views::dashboard::DashboardRowId::TopLevel(AgentId(0)), + " ", + )); } let effects = dispatch_dashboard_commit_rename(&mut app); assert!( @@ -4328,8 +4518,7 @@ fn dashboard_open_shortcuts_help_builds_modal_idempotently() { .as_mut() .unwrap() .state - .query - .push_str("nav"); + .set_query("nav"); let _ = dispatch(Action::DashboardOpenShortcutsHelp, &mut app); assert_eq!( app.dashboard @@ -4339,7 +4528,7 @@ fn dashboard_open_shortcuts_help_builds_modal_idempotently() { .as_ref() .unwrap() .state - .query, + .query(), "nav", "re-dispatch must NOT rebuild the modal — user's query would vanish", ); @@ -4901,7 +5090,18 @@ fn dashboard_peek_reply_to_idle_agent_sends() { #[test] fn dashboard_peek_reply_to_running_agent_queues() { let mut app = test_app_with_agent(); - app.agents.get_mut(&AgentId(0)).unwrap().session.state = AgentState::TurnRunning; + { + let agent = app.agents.get_mut(&AgentId(0)).unwrap(); + agent.session.state = AgentState::TurnRunning; + agent + .scrollback + .push_block(RenderBlock::user_prompt("current turn")); + agent.scrollback.prepare_layout(80, 24); + let current = agent.scrollback.len().saturating_sub(1); + agent.scrollback.set_selected(Some(current)); + agent.scrollback.scroll_to_entry_top(current); + agent.scrollback.enable_follow_with_preserve(); + } open_dashboard(&mut app); if let Some(d) = app.dashboard.as_mut() { d.selected = Some(crate::views::dashboard::DashboardRowId::TopLevel(AgentId( @@ -4921,6 +5121,10 @@ fn dashboard_peek_reply_to_running_agent_queues() { }, )); d.peek_reply.set_text("after this"); + d.begin_peek_viewport( + crate::views::dashboard::DashboardRowId::TopLevel(AgentId(0)), + &mut app.agents, + ); } let effects = dispatch_dashboard_peek_reply( &mut app, @@ -4932,7 +5136,17 @@ fn dashboard_peek_reply_to_running_agent_queues() { assert!(effects.is_empty()); assert_eq!(app.agents[&AgentId(0)].session.queue_len(), 1); assert!(app.agents[&AgentId(0)].session.state.is_turn_running()); - assert!(app.dashboard.as_ref().unwrap().peek_reply.text().is_empty()); + let dashboard = app.dashboard.as_ref().unwrap(); + assert!(dashboard.peek_reply.text().is_empty()); + assert!( + dashboard + .peek_viewport + .as_ref() + .unwrap() + .page_flip_entry + .is_none(), + "a blocked drain must not claim the current turn as the queued reply" + ); } /// Peek reply with an attached image drains into the queued diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/mod.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/mod.rs index 7233ab3..ff2a066 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/mod.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/mod.rs @@ -142,12 +142,13 @@ fn test_app() -> AppView { login_label: None, login_method_id: None, auth_start_mode: AuthMode::Pending, - auth_code_input: String::new(), + auth_code_input: Default::default(), next_auth_request_seq: 1, auth_url_poll_handle: None, deferred_startup: Default::default(), auth_use_oauth: false, - auth_clipboard_copied: false, + auth_clipboard_delivery: None, + auth_clipboard_feedback_generation: 0, team_id: None, team_name: None, is_zdr: false, @@ -250,6 +251,7 @@ fn test_app() -> AppView { cancel_rewind_enabled: true, session_recap_available: false, dashboard: None, + dashboard_return: None, dashboard_persisted: None, keyboard_normalizer: crate::input::KeyboardNormalizer::from_terminal_context(), has_claude_import: false, diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/notes.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/notes.rs index aff68e0..6690d01 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/notes.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/notes.rs @@ -3,6 +3,25 @@ use super::*; use crate::app::dispatch::{recap_unavailable_toast, scrollback_has_user_messages}; +fn send_minimal_btw(app: &mut AppView, question: &str) -> uuid::Uuid { + match dispatch(Action::SendBtw(question.into()), app).as_slice() { + [ + Effect::SendBtw { + minimal_request_id: Some(id), + .. + }, + ] => *id, + other => panic!("expected correlated minimal /btw effect, got {other:?}"), + } +} + +fn esc() -> crossterm::event::Event { + crossterm::event::Event::Key(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Esc, + crossterm::event::KeyModifiers::NONE, + )) +} + #[test] fn recap_unavailable_toast_empty_vs_with_messages() { assert_eq!(recap_unavailable_toast(false), "No messages yet"); @@ -193,3 +212,211 @@ fn recap_request_transport_failure_with_turns_uses_generic_toast() { Some("Couldn't generate recap") ); } + +#[test] +fn minimal_btw_response_after_esc_is_ignored() { + let mut app = test_app_with_agent(); + app.screen_mode = crate::app::ScreenMode::Minimal; + let id = AgentId(0); + app.agents.get_mut(&id).unwrap().active_pane = crate::app::agent_view::AgentPane::Prompt; + let request_id = send_minimal_btw(&mut app, "side question"); + + let _ = app.handle_input(&esc()); + assert!(app.agents[&id].btw_state.is_none()); + + dispatch( + Action::TaskComplete(TaskResult::BtwResponse { + agent_id: id, + result: Ok("late".into()), + minimal_request_id: Some(request_id), + }), + &mut app, + ); + + assert!(app.agents[&id].btw_state.is_none()); +} + +#[test] +fn minimal_done_dismisses_to_exactly_one_btw_block() { + let mut app = test_app_with_agent(); + app.screen_mode = crate::app::ScreenMode::Minimal; + let id = AgentId(0); + app.agents.get_mut(&id).unwrap().active_pane = ActivePane::Prompt; + let request_id = send_minimal_btw(&mut app, "original question"); + dispatch( + Action::TaskComplete(TaskResult::BtwResponse { + agent_id: id, + result: Ok("original answer".into()), + minimal_request_id: Some(request_id), + }), + &mut app, + ); + + let _ = app.handle_input(&esc()); + + let btw_blocks: Vec<_> = app.agents[&id] + .scrollback + .iter_entries() + .filter_map(|(_, entry)| match &entry.block { + RenderBlock::Btw(block) => Some(block), + _ => None, + }) + .collect(); + assert_eq!(btw_blocks.len(), 1); + assert_eq!(btw_blocks[0].question, "original question"); + assert_eq!(btw_blocks[0].content().text(), "original answer"); +} + +#[test] +fn minimal_btw_requests_stay_independent_across_two_agents() { + let mut app = test_app_with_agent(); + app.screen_mode = crate::app::ScreenMode::Minimal; + let first = AgentId(0); + let second = AgentId(1); + insert_placeholder_agent(&mut app, second); + + let first_old = send_minimal_btw(&mut app, "first old"); + let first_current = send_minimal_btw(&mut app, "first new"); + + switch_to_agent(&mut app, second, SwitchCause::Picker); + let second_request = send_minimal_btw(&mut app, "second"); + + // Deliver the background first-agent responses while the second agent is active. + dispatch( + Action::TaskComplete(TaskResult::BtwResponse { + agent_id: first, + result: Ok("stale first answer".into()), + minimal_request_id: Some(first_old), + }), + &mut app, + ); + assert!(matches!( + app.agents[&first].btw_state, + Some(crate::views::btw_overlay::BtwOverlayState::Loading { ref question }) + if question == "first new" + )); + dispatch( + Action::TaskComplete(TaskResult::BtwResponse { + agent_id: first, + result: Ok("current first answer".into()), + minimal_request_id: Some(first_current), + }), + &mut app, + ); + assert!(matches!( + app.agents[&first].btw_state, + Some(crate::views::btw_overlay::BtwOverlayState::Done { ref question, .. }) + if question == "first new" + )); + assert!(matches!( + app.agents[&second].btw_state, + Some(crate::views::btw_overlay::BtwOverlayState::Loading { ref question }) + if question == "second" + )); + + // Dismiss the active second request, then its later response must be ignored. + app.agents.get_mut(&second).unwrap().active_pane = ActivePane::Prompt; + let _ = app.handle_input(&esc()); + dispatch( + Action::TaskComplete(TaskResult::BtwResponse { + agent_id: second, + result: Ok("late second answer".into()), + minimal_request_id: Some(second_request), + }), + &mut app, + ); + assert!(app.agents[&second].btw_state.is_none()); + assert!(app.agents[&second].minimal_btw_lifecycle.is_none()); + assert!(matches!( + app.agents[&first].btw_state, + Some(crate::views::btw_overlay::BtwOverlayState::Done { ref question, .. }) + if question == "first new" + )); + + // Reverse delivery order on fresh requests: active second completes first, + // then the background first response still resolves only the first panel. + switch_to_agent(&mut app, first, SwitchCause::Picker); + let first_request = send_minimal_btw(&mut app, "first reverse"); + switch_to_agent(&mut app, second, SwitchCause::Picker); + let second_request = send_minimal_btw(&mut app, "second reverse"); + dispatch( + Action::TaskComplete(TaskResult::BtwResponse { + agent_id: second, + result: Ok("second reverse answer".into()), + minimal_request_id: Some(second_request), + }), + &mut app, + ); + dispatch( + Action::TaskComplete(TaskResult::BtwResponse { + agent_id: first, + result: Ok("first reverse answer".into()), + minimal_request_id: Some(first_request), + }), + &mut app, + ); + assert!(matches!( + app.agents[&second].btw_state, + Some(crate::views::btw_overlay::BtwOverlayState::Done { ref question, .. }) + if question == "second reverse" + )); + assert!(matches!( + app.agents[&first].btw_state, + Some(crate::views::btw_overlay::BtwOverlayState::Done { ref question, .. }) + if question == "first reverse" + )); +} + +#[test] +fn fullscreen_btw_response_after_dismiss_keeps_existing_behavior() { + let mut app = test_app_with_agent(); + let id = AgentId(0); + let effects = dispatch(Action::SendBtw("side question".into()), &mut app); + assert!(matches!( + effects.as_slice(), + [Effect::SendBtw { + minimal_request_id: None, + .. + }] + )); + app.agents.get_mut(&id).unwrap().btw_state = None; + + dispatch( + Action::TaskComplete(TaskResult::BtwResponse { + agent_id: id, + result: Ok("late".into()), + minimal_request_id: None, + }), + &mut app, + ); + + assert!(matches!( + app.agents[&id].btw_state, + Some(crate::views::btw_overlay::BtwOverlayState::Done { ref question, .. }) + if question.is_empty() + )); +} + +#[test] +fn btw_no_session_feedback_is_mode_specific() { + let id = AgentId(0); + + let mut minimal = test_app_with_agent(); + minimal.screen_mode = crate::app::ScreenMode::Minimal; + minimal.agents.get_mut(&id).unwrap().session.session_id = None; + assert!(dispatch(Action::SendBtw("q".into()), &mut minimal).is_empty()); + assert!(minimal.agents[&id].toast.is_none()); + assert!(last_system_text(&minimal, id).contains("No active session")); + + let mut fullscreen = test_app_with_agent(); + fullscreen.agents.get_mut(&id).unwrap().session.session_id = None; + assert!(dispatch(Action::SendBtw("q".into()), &mut fullscreen).is_empty()); + assert_eq!( + fullscreen.agents[&id] + .toast + .as_ref() + .map(|(text, _)| text.as_str()), + Some("No active session") + ); + assert_eq!(fullscreen.agents[&id].scrollback.len(), 0); +} diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/prompt.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/prompt.rs index 259089d..28bdc37 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/prompt.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/prompt.rs @@ -3202,7 +3202,7 @@ fn local_drain_holds_while_server_row_queued() { let agent = app.agents.get_mut(&id).unwrap(); assert!(agent.session.state.is_idle()); - let effects = maybe_drain_queue(agent); + let effects = maybe_drain_queue(agent).effects; assert!( effects.is_empty(), "local drain must hold while the server owns the next turn, got {effects:?}" @@ -3217,7 +3217,7 @@ fn local_drain_holds_while_server_row_queued() { // turn, not a queued one) — once it's marked running and the turn ends, // the local row drains normally. agent.session.current_prompt_id = Some("srv-1".into()); - let effects = maybe_drain_queue(agent); + let effects = maybe_drain_queue(agent).effects; assert!( matches!(effects.as_slice(), [Effect::SendPrompt { .. }]), "a running-only shared queue must not hold the local drain, got {effects:?}" diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/router.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/router.rs index ac0f417..27d7763 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/router.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/router.rs @@ -1,17 +1,30 @@ //! Tests for the action router, model switching, slash commands, and other cross-cutting dispatch behavior. use super::*; #[test] -fn auth_copy_success_requires_confirmed_delivery() { - use crate::clipboard::ClipboardDelivery; - assert!(crate::app::dispatch::router::auth_copy_was_confirmed( - ClipboardDelivery::Confirmed - )); - assert!(!crate::app::dispatch::router::auth_copy_was_confirmed( - ClipboardDelivery::Unverified - )); - assert!(!crate::app::dispatch::router::auth_copy_was_confirmed( - ClipboardDelivery::Failed - )); +fn auth_copy_dispatch_preserves_all_delivery_states() { + for delivery in [ + crate::clipboard::ClipboardDelivery::Confirmed, + crate::clipboard::ClipboardDelivery::Unverified, + crate::clipboard::ClipboardDelivery::Failed, + ] { + let mut app = test_app(); + app.auth_state = AuthState::Authenticating { + request_seq: 1, + handle: None, + auth_url: Some("https://grok.com/auth".to_owned()), + mode: AuthMode::Command, + }; + let effects = crate::app::dispatch::router::dispatch_copy_auth_url(&mut app, |url| { + assert_eq!(url, "https://grok.com/auth"); + delivery + }); + assert_eq!(app.auth_clipboard_delivery, Some(delivery)); + assert_eq!(app.auth_clipboard_feedback_generation, 1); + assert!(matches!( + effects.as_slice(), + [Effect::ScheduleClearAuthCopyFeedback { generation: 1 }] + )); + } } fn seed_foreign_resume_hint( app: &mut AppView, diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/session/foreign.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/session/foreign.rs index 44de376..36e498b 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/session/foreign.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/session/foreign.rs @@ -571,7 +571,7 @@ fn modal_external_filter_clears_native_content_and_blocks_forced_search() { *source_filter = SourceFilter::Remote; *content_results = Some(vec![content_hit("native-hit")]); *content_loading = true; - state.query = "native".into(); + state.set_query("native"); state.expanded.insert(0); } diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/session/load.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/session/load.rs index a5a1789..cdcf1ad 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/session/load.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/session/load.rs @@ -790,23 +790,16 @@ fn session_load_failed_clears_pending_first_prompt() { fn reanchor_grouped_selection_lands_on_a_row() { use crate::views::picker::PickerState; let map: Vec> = vec![None, Some(()), Some(())]; - let mut st = PickerState { - selected: 9, - ..PickerState::default() - }; + let mut st = PickerState::default(); + st.selected = 9; reanchor_grouped_selection(&mut st, &map); assert_eq!(st.selected, 2); - let mut st = PickerState { - selected: 0, - ..PickerState::default() - }; + let mut st = PickerState::default(); reanchor_grouped_selection(&mut st, &map); assert_eq!(st.selected, 1); let empty: Vec> = vec![]; - let mut st = PickerState { - selected: 5, - ..PickerState::default() - }; + let mut st = PickerState::default(); + st.selected = 5; reanchor_grouped_selection(&mut st, &empty); assert_eq!(st.selected, 0); } @@ -1038,6 +1031,47 @@ fn resume_conversation_does_not_focus_build_id_collision() { ); assert!(!app.agents[&agent_0].chat_kind); } +#[test] +fn duplicate_load_unbind_invalidates_old_minimal_btw_response() { + let mut app = test_app(); + app.screen_mode = crate::app::ScreenMode::Minimal; + dispatch(Action::NewSession, &mut app); + let old_owner = AgentId(0); + dispatch( + Action::TaskComplete(TaskResult::SessionCreated { + agent_id: old_owner, + session_id: "shared-id".into(), + models: None, + }), + &mut app, + ); + let request_id = match dispatch(Action::SendBtw("old question".into()), &mut app).as_slice() { + [ + Effect::SendBtw { + minimal_request_id: Some(id), + .. + }, + ] => *id, + other => panic!("expected correlated minimal /btw effect, got {other:?}"), + }; + dispatch( + Action::LoadSession("shared-id".into(), None, true), + &mut app, + ); + assert!(app.agents[&old_owner].session.session_id.is_none()); + assert!(app.agents[&old_owner].btw_state.is_none()); + assert!(app.agents[&old_owner].minimal_btw_lifecycle.is_none()); + dispatch( + Action::TaskComplete(TaskResult::BtwResponse { + agent_id: old_owner, + result: Ok("old answer".into()), + minimal_request_id: Some(request_id), + }), + &mut app, + ); + assert!(app.agents[&old_owner].btw_state.is_none()); + assert!(app.agents[&old_owner].minimal_btw_lifecycle.is_none()); +} /// Under sticky `--chat`, agents stamp `chat_kind=true` even for build loads; /// resume with conversation-entry false must still focus the open agent. #[test] @@ -1521,7 +1555,7 @@ fn pick_session_in_worktree_refuses_conversation_row() { fn chat_mode_query_change_schedules_debounced_search() { let mut app = test_app(); app.session_picker_entries = Some(vec![make_conversation_entry("conv-ds-1")]); - app.session_picker_state.query = "abc".into(); + app.session_picker_state.set_query("abc"); app.chat_mode = true; let effects = dispatch(Action::TriggerDeepSearch, &mut app); assert!( @@ -1551,7 +1585,7 @@ fn chat_mode_query_change_schedules_debounced_search() { fn chat_mode_debounce_expiry_fetches_current_and_drops_stale() { let mut app = test_app(); app.chat_mode = true; - app.session_picker_state.query = "abc".into(); + app.session_picker_state.set_query("abc"); let _ = dispatch(Action::TriggerDeepSearch, &mut app); let effects = dispatch( Action::TaskComplete(TaskResult::SessionSearchDebounceExpired { @@ -1565,7 +1599,7 @@ fn chat_mode_debounce_expiry_fetches_current_and_drops_stale() { if q == "abc"), "current debounce expiry must fetch with the query, got {effects:?}" ); - app.session_picker_state.query = "abcd".into(); + app.session_picker_state.set_query("abcd"); let _ = dispatch(Action::TriggerDeepSearch, &mut app); let effects = dispatch( Action::TaskComplete(TaskResult::SessionSearchDebounceExpired { @@ -1591,7 +1625,7 @@ fn build_mode_query_arms_debounce_despite_title_hits_and_force_skips_it() { make_picker_entry("prost-2", "/r"), make_picker_entry("prost-3", "/r"), ]); - app.session_picker_state.query = "prost".into(); + app.session_picker_state.set_query("prost"); let effects = dispatch(Action::TriggerDeepSearch, &mut app); assert!( matches!(& effects[..], [Effect::DebounceSessionSearch { query, seq : 1 }] if @@ -1620,9 +1654,9 @@ fn build_mode_query_arms_debounce_despite_title_hits_and_force_skips_it() { fn build_mode_short_query_clears_results_and_invalidates_armed_debounce() { let mut app = test_app(); app.session_picker_content_results = Some(vec![]); - app.session_picker_state.query = "ab".into(); + app.session_picker_state.set_query("ab"); let _ = dispatch(Action::TriggerDeepSearch, &mut app); - app.session_picker_state.query = "a".into(); + app.session_picker_state.set_query("a"); let effects = dispatch(Action::TriggerDeepSearch, &mut app); assert!( effects.is_empty(), @@ -1647,7 +1681,7 @@ fn build_mode_short_query_clears_results_and_invalidates_armed_debounce() { #[test] fn build_mode_debounce_expiry_searches_current_and_drops_stale() { let mut app = test_app(); - app.session_picker_state.query = "abc".into(); + app.session_picker_state.set_query("abc"); let _ = dispatch(Action::TriggerDeepSearch, &mut app); let effects = dispatch( Action::TaskComplete(TaskResult::SessionSearchDebounceExpired { @@ -1661,7 +1695,7 @@ fn build_mode_debounce_expiry_searches_current_and_drops_stale() { == "abc"), "current expiry must dispatch the deep search, got {effects:?}" ); - app.session_picker_state.query = "abcd".into(); + app.session_picker_state.set_query("abcd"); let _ = dispatch(Action::TriggerDeepSearch, &mut app); let effects = dispatch( Action::TaskComplete(TaskResult::SessionSearchDebounceExpired { @@ -1687,7 +1721,7 @@ fn build_mode_modal_debounce_expiry_validates_modal_seq() { .active_modal .as_mut() { - state.query = "abc".into(); + state.set_query("abc"); } let effects = dispatch(Action::TriggerDeepSearch, &mut app); assert!( @@ -1713,7 +1747,7 @@ fn build_mode_modal_debounce_expiry_validates_modal_seq() { #[test] fn build_mode_picker_close_invalidates_armed_debounce() { let mut app = test_app(); - app.session_picker_state.query = "abc".into(); + app.session_picker_state.set_query("abc"); let _ = dispatch(Action::TriggerDeepSearch, &mut app); assert!(app.session_picker_content_loading); let _ = dispatch(Action::SessionPickerClosed, &mut app); @@ -1746,7 +1780,7 @@ fn build_mode_modal_close_drops_armed_debounce_despite_seq_collision() { .active_modal .as_mut() { - state.query = "abc".into(); + state.set_query("abc"); } let _ = dispatch(Action::TriggerDeepSearch, &mut app); assert_eq!( @@ -1780,7 +1814,7 @@ fn build_mode_modal_close_drops_armed_debounce_despite_seq_collision() { fn chat_mode_force_search_fetches_immediately_and_empty_query_unfilters() { let mut app = test_app(); app.chat_mode = true; - app.session_picker_state.query = "abc".into(); + app.session_picker_state.set_query("abc"); let effects = dispatch(Action::ForceDeepSearch, &mut app); assert!( matches!(& effects[..], [Effect::FetchSessionList { query : Some(q), seq : 1 }] @@ -1791,7 +1825,7 @@ fn chat_mode_force_search_fetches_immediately_and_empty_query_unfilters() { app.session_picker_content_loading, "search fetch must raise the in-flight indicator" ); - app.session_picker_state.query.clear(); + app.session_picker_state.set_query(""); let effects = dispatch(Action::TriggerDeepSearch, &mut app); assert!( matches!( @@ -1815,14 +1849,14 @@ fn chat_mode_search_reads_modal_query_first() { use crate::views::modal::ActiveModal; let mut app = test_app_with_agent(); app.chat_mode = true; - app.session_picker_state.query = "welcome-query".into(); + app.session_picker_state.set_query("welcome-query"); open_session_picker_with(&mut app, vec![make_conversation_entry("conv-mq-1")]); if let Some(ActiveModal::SessionPicker { state, .. }) = get_active_agent_mut(&mut app) .expect("active agent") .active_modal .as_mut() { - state.query = "modal-query".into(); + state.set_query("modal-query"); } let effects = dispatch(Action::ForceDeepSearch, &mut app); assert!( @@ -1837,9 +1871,9 @@ fn chat_mode_search_reads_modal_query_first() { fn stale_session_list_responses_are_dropped() { let mut app = test_app_with_agent(); app.chat_mode = true; - app.session_picker_state.query = "abc".into(); + app.session_picker_state.set_query("abc"); let _ = dispatch(Action::ForceDeepSearch, &mut app); - app.session_picker_state.query = "abcd".into(); + app.session_picker_state.set_query("abcd"); let _ = dispatch(Action::ForceDeepSearch, &mut app); let _ = dispatch( Action::TaskComplete(TaskResult::SessionListLoaded { @@ -1907,7 +1941,7 @@ fn modal_search_response_lands_and_stale_is_dropped() { .active_modal .as_mut() { - state.query = "hit".into(); + state.set_query("hit"); state.selected = 3; } let _ = dispatch(Action::ForceDeepSearch, &mut app); @@ -1946,7 +1980,7 @@ fn modal_search_response_lands_and_stale_is_dropped() { .active_modal .as_mut() { - state.query = "hits".into(); + state.set_query("hits"); } let _ = dispatch(Action::ForceDeepSearch, &mut app); let _ = dispatch( @@ -1992,7 +2026,7 @@ fn modal_close_drops_in_flight_search_response() { .active_modal .as_mut() { - state.query = "hit".into(); + state.set_query("hit"); } let _ = dispatch(Action::ForceDeepSearch, &mut app); let seq = app.session_picker_list_seq; @@ -2037,7 +2071,7 @@ fn modal_pick_drops_in_flight_search_response() { .active_modal .as_mut() { - state.query = "hit".into(); + state.set_query("hit"); } let _ = dispatch(Action::ForceDeepSearch, &mut app); let seq = app.session_picker_list_seq; @@ -2145,7 +2179,7 @@ fn build_mode_modal_close_does_not_invalidate_plain_fetch() { fn zero_hit_search_shows_empty_list_without_toast() { let mut app = test_app_with_agent(); app.chat_mode = true; - app.session_picker_state.query = "zzz".into(); + app.session_picker_state.set_query("zzz"); let _ = dispatch(Action::ForceDeepSearch, &mut app); let _ = dispatch( Action::TaskComplete(TaskResult::SessionListLoaded { @@ -2194,7 +2228,7 @@ fn welcome_server_search_hit_with_unrelated_title_is_pickable() { let mut e = make_conversation_entry("conv-content-w1"); e.summary = "Quarterly roadmap notes".into(); app.session_picker_entries = Some(vec![e.clone()]); - app.session_picker_state.query = "hit".into(); + app.session_picker_state.set_query("hit"); app.session_picker_entries_query = Some("hit".into()); app.session_picker_state.selected = 0; let out = app.handle_input(&enter); @@ -2208,7 +2242,7 @@ fn welcome_server_search_hit_with_unrelated_title_is_pickable() { let mut app = test_app(); app.chat_mode = true; app.session_picker_entries = Some(vec![e]); - app.session_picker_state.query = "hit".into(); + app.session_picker_state.set_query("hit"); app.session_picker_state.selected = 0; let out = app.handle_input(&enter); assert!( @@ -2227,7 +2261,7 @@ fn current_seq_failed_search_clears_indicator_and_stamp() { let mut app = test_app_with_agent(); app.chat_mode = true; app.session_picker_entries_query = Some("old".into()); - app.session_picker_state.query = "hit".into(); + app.session_picker_state.set_query("hit"); let _ = dispatch(Action::ForceDeepSearch, &mut app); assert!(app.session_picker_content_loading); let _ = dispatch( @@ -2267,7 +2301,7 @@ fn modal_failed_search_clears_indicator_and_plain_failure_preserves_spinner() { .active_modal .as_mut() { - state.query = "hit".into(); + state.set_query("hit"); *entries_query = Some("old".into()); } let _ = dispatch(Action::ForceDeepSearch, &mut app); @@ -2311,7 +2345,7 @@ fn modal_failed_search_clears_indicator_and_plain_failure_preserves_spinner() { .active_modal .as_mut() { - state.query = "abc".into(); + state.set_query("abc"); } let effects = dispatch(Action::ForceDeepSearch, &mut app); assert!( @@ -2345,7 +2379,7 @@ fn modal_failed_search_clears_indicator_and_plain_failure_preserves_spinner() { fn build_mode_list_response_preserves_deep_search_spinner() { let mut app = test_app_with_agent(); let _ = dispatch(Action::FetchSessionList, &mut app); - app.session_picker_state.query = "abc".into(); + app.session_picker_state.set_query("abc"); let effects = dispatch(Action::ForceDeepSearch, &mut app); assert!( matches!(&effects[..], [Effect::DeepSearchSessions { .. }]), @@ -2444,7 +2478,7 @@ fn build_mode_rapid_plain_fetches_keep_last_write_wins() { fn plain_picker_fetch_carries_no_query_and_bumps_seq() { let mut app = test_app(); app.chat_mode = true; - app.session_picker_state.query = "abc".into(); + app.session_picker_state.set_query("abc"); let _ = dispatch(Action::ForceDeepSearch, &mut app); let effects = dispatch(Action::FetchSessionList, &mut app); assert!( diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/settings.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/settings.rs index cc11b09..66811b0 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/settings.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/settings.rs @@ -595,6 +595,32 @@ fn set_timeline_toggles_displayed_state_when_current_ui_diverges() { assert_eq!(app.current_ui.show_timeline, Some(false)); } #[test] +fn set_page_flip_on_send_emits_persist_setting_with_correct_payload() { + use crate::settings::SettingValue; + let mut app = test_app_with_agent(); + let default_on = app.current_ui.page_flip_on_send_enabled(); + crate::appearance::cache::set_page_flip_on_send(default_on); + let effects = dispatch(Action::SetPageFlipOnSend(!default_on), &mut app); + assert_eq!(effects.len(), 1); + match &effects[0] { + Effect::PersistSetting { + key, + value, + rollback_value, + } => { + assert_eq!(*key, "page_flip_on_send"); + assert_eq!(value, &SettingValue::Bool(!default_on)); + assert_eq!(rollback_value, &SettingValue::Bool(default_on)); + } + other => panic!("expected PersistSetting, got {other:?}"), + } + assert_eq!(app.current_ui.page_flip_on_send, Some(!default_on)); + assert_eq!( + crate::appearance::cache::load_page_flip_on_send(), + !default_on + ); +} +#[test] fn set_simple_mode_emits_persist_setting_with_correct_payload() { use crate::settings::SettingValue; let mut app = test_app_with_agent(); @@ -680,10 +706,10 @@ fn dispatch_confirm_reset_setting_cancel_preserves_modal_state() { { let agent = app.agents.get_mut(&AgentId(0)).expect("agent must exist"); if let Some(ActiveModal::Settings { state }) = &mut agent.active_modal { - state.query.push_str("stamp"); + state.set_query("stamp"); state.selected = 3; state.scroll_offset = 1; - state.mode = SettingsModalMode::FilterFocused; + state.focus_filter(); } else { panic!("expected Settings modal"); } @@ -712,11 +738,11 @@ fn dispatch_confirm_reset_setting_cancel_preserves_modal_state() { match &agent.active_modal { Some(ActiveModal::Settings { state }) => { assert!(state.ui_snapshot.compact_mode, "ui_snapshot.compact_mode"); - assert_eq!(state.query, "stamp", "query preserved"); + assert_eq!(state.query(), "stamp", "query preserved"); assert_eq!(state.selected, 3, "selected preserved"); assert_eq!(state.scroll_offset, 1, "scroll_offset preserved"); assert!( - matches!(state.mode, SettingsModalMode::FilterFocused), + matches!(state.mode(), SettingsModalMode::FilterFocused), "mode preserved (FilterFocused)" ); } @@ -1191,6 +1217,10 @@ fn move_setting_away_from_default(app: &mut AppView, key: crate::settings::Setti let away = !app.current_ui.show_timeline_enabled(); let _ = dispatch(Action::SetTimeline(away), app); } + "page_flip_on_send" => { + let away = !crate::appearance::cache::load_page_flip_on_send(); + let _ = dispatch(Action::SetPageFlipOnSend(away), app); + } "simple_mode" => { let _ = dispatch(Action::SetSimpleMode(false), app); } @@ -1252,7 +1282,7 @@ fn move_setting_away_from_default(app: &mut AppView, key: crate::settings::Setti let _ = dispatch(Action::SetMaxThoughtsWidth(200), app); } "coding_data_sharing" => { - let _ = dispatch(Action::SetCodingDataSharing { opted_in: false }, app); + let _ = dispatch(Action::SetCodingDataSharing { opted_in: true }, app); } "plan_mode" => { let _ = dispatch( diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/status.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/status.rs index 09995aa..32958d1 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/status.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/status.rs @@ -68,6 +68,14 @@ fn show_privacy_info_zdr() { assert!(effects.is_empty()); let text = last_system_text(&app, AgentId(0)); assert!(text.contains("Zero Data Retention")); + assert!( + text.contains("Other settings (not changed by /privacy)"), + "must list other settings knobs: {text}", + ); + assert!( + text.contains("GROK_TELEMETRY_ENABLED") && text.contains("GROK_EXTERNAL_OTEL"), + "must list telemetry/OTEL config keys: {text}", + ); } /// `/privacy` info-print uses the desktop-aligned "privacy mode" / @@ -84,6 +92,13 @@ fn show_privacy_info_opted_out() { "info-print must use 'Privacy: privacy mode' (desktop-aligned label): {text}", ); assert!(text.contains("/privacy opt-in")); + assert!( + text.contains("Other settings (not changed by /privacy)") + && text.contains("GROK_TELEMETRY_ENABLED") + && text.contains("trace_upload") + && text.contains("GROK_EXTERNAL_OTEL"), + "must list config knobs not changed by /privacy: {text}", + ); } #[test] diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/task_result.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/task_result.rs index 53c49b8..3348163 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/task_result.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/task_result.rs @@ -5,6 +5,66 @@ use super::super::task_result::{ }; use super::*; +#[test] +fn stale_auth_copy_timeout_does_not_clear_newer_feedback() { + let mut app = test_app(); + app.auth_state = AuthState::Authenticating { + request_seq: 1, + handle: None, + auth_url: Some("https://grok.com/auth".to_owned()), + mode: AuthMode::Command, + }; + + let first_effects = crate::app::dispatch::router::dispatch_copy_auth_url(&mut app, |_| { + crate::clipboard::ClipboardDelivery::Failed + }); + let [ + Effect::ScheduleClearAuthCopyFeedback { + generation: first_generation, + }, + ] = first_effects.as_slice() + else { + panic!("first copy must schedule feedback clear"); + }; + + let second_effects = crate::app::dispatch::router::dispatch_copy_auth_url(&mut app, |_| { + crate::clipboard::ClipboardDelivery::Confirmed + }); + let [ + Effect::ScheduleClearAuthCopyFeedback { + generation: second_generation, + }, + ] = second_effects.as_slice() + else { + panic!("second copy must schedule feedback clear"); + }; + assert_ne!(first_generation, second_generation); + assert_eq!( + app.auth_clipboard_delivery, + Some(crate::clipboard::ClipboardDelivery::Confirmed) + ); + + dispatch_task_result( + TaskResult::AuthCopyFeedbackTimeout { + generation: *first_generation, + }, + &mut app, + ); + assert_eq!( + app.auth_clipboard_delivery, + Some(crate::clipboard::ClipboardDelivery::Confirmed), + "the first copy's stale timeout must preserve the second feedback" + ); + + dispatch_task_result( + TaskResult::AuthCopyFeedbackTimeout { + generation: *second_generation, + }, + &mut app, + ); + assert_eq!(app.auth_clipboard_delivery, None); +} + fn foreign_resume_hint( tool: xai_grok_workspace::foreign_sessions::ForeignSessionTool, ) -> xai_grok_workspace::foreign_sessions::RecentForeignSession { @@ -1335,11 +1395,11 @@ fn delete_both_session_clears_modal_and_welcome_content_hits() { .active_modal .as_mut() { - state.query = "shared".into(); + state.set_query("shared"); *content_results = Some(vec![hit.clone()]); } app.session_picker_entries = Some(vec![both, foreign]); - app.session_picker_state.query = "shared".into(); + app.session_picker_state.set_query("shared"); app.session_picker_content_results = Some(vec![hit]); let _ = dispatch_task_result( @@ -1382,7 +1442,7 @@ fn delete_both_session_clears_modal_and_welcome_content_hits() { .iter() .any(|item| matches!(item, Some(PickerItem::Content { .. }))) ); - assert_eq!(modal_state.query.as_str(), "shared"); + assert_eq!(modal_state.query(), "shared"); let welcome_entries = app.session_picker_entries.as_deref().unwrap(); let welcome_hits = app.session_picker_content_results.as_deref().unwrap(); diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/turn.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/turn.rs index 1539cf7..4552acc 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/turn.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/turn.rs @@ -2,7 +2,7 @@ use super::ctx::find_agent_by_session_id; use super::permissions::drain_permission_queue; -use super::queue::{apply_turn_start_shim, maybe_drain_queue, note_peek_page_flip_after_drain}; +use super::queue::{apply_turn_start_shim, maybe_drain_queue, note_peek_page_flip}; use crate::app::actions::Effect; use crate::app::agent::AgentId; use crate::app::agent_view::ActivePane; @@ -391,7 +391,6 @@ pub(crate) fn reconcile_overdue_turn_ends(app: &mut AppView) -> Option Option String { if i32::from(err.code) == RATE_LIMITED_ERROR_CODE { let detail = err.data.as_ref().and_then(error_detail_from_data); - return format_rate_limited_user_message(detail.as_deref(), is_api_key_auth); + return sanitize_user_error( + &format_rate_limited_user_message(detail.as_deref(), is_api_key_auth), + ); } if err.code == acp::ErrorCode::InvalidParams && let Some(data) = &err.data && let Some(msg) = error_detail_from_data(data) && !msg.is_empty() @@ -790,6 +792,14 @@ pub(crate) async fn persist_setting( .await .map_err(|e| e.to_string()) } + "page_flip_on_send" => { + let SettingValue::Bool(b) = value else { + return Err(kind_mismatch("page_flip_on_send", "Bool", &value)); + }; + xai_grok_shell::util::config::set_page_flip_on_send(b) + .await + .map_err(|e| e.to_string()) + } "show_timeline" => { let SettingValue::Bool(b) = value else { return Err(kind_mismatch("show_timeline", "Bool", &value)); diff --git a/crates/codegen/xai-grok-pager/src/app/effects/mod.rs b/crates/codegen/xai-grok-pager/src/app/effects/mod.rs index 97c5247..7d08f1f 100644 --- a/crates/codegen/xai-grok-pager/src/app/effects/mod.rs +++ b/crates/codegen/xai-grok-pager/src/app/effects/mod.rs @@ -66,11 +66,13 @@ pub(crate) fn execute( tracing::warn!(error = % e, "project picker: failed to set_current_dir"); } } - Effect::ScheduleClearAuthCopied => { + Effect::ScheduleClearAuthCopyFeedback { generation } => { tasks - .spawn(async { + .spawn(async move { tokio::time::sleep(std::time::Duration::from_secs(2)).await; - TaskResult::AuthCopiedTimeout + TaskResult::AuthCopyFeedbackTimeout { + generation, + } }); } Effect::Logout => { @@ -3302,7 +3304,7 @@ pub(crate) fn execute( } }); } - Effect::SendBtw { agent_id, session_id, question } => { + Effect::SendBtw { agent_id, session_id, question, minimal_request_id } => { let tx = acp_tx.clone(); tasks .spawn(async move { @@ -3332,6 +3334,7 @@ pub(crate) fn execute( TaskResult::BtwResponse { agent_id, result: Ok(answer), + minimal_request_id, } } Err(e) => { @@ -3340,6 +3343,7 @@ pub(crate) fn execute( result: Err( sanitize_user_error(&format!("side question failed: {e}")), ), + minimal_request_id, } } } diff --git a/crates/codegen/xai-grok-pager/src/app/effects/tests.rs b/crates/codegen/xai-grok-pager/src/app/effects/tests.rs index 139dd18..299b777 100644 --- a/crates/codegen/xai-grok-pager/src/app/effects/tests.rs +++ b/crates/codegen/xai-grok-pager/src/app/effects/tests.rs @@ -22,26 +22,37 @@ fn format_acp_error_rate_limit_surfaces_detail_or_fallback() { FREE_USAGE_USER_MESSAGE, RATE_LIMITED_ERROR_CODE, RATE_LIMITED_USER_MESSAGE_API_KEY, RATE_LIMITED_USER_MESSAGE_OAUTH, }; + let cap_body = "The service is temporarily at capacity. Please retry your request shortly."; let capacity = acp::Error::new(RATE_LIMITED_ERROR_CODE, "Rate limited") - .data( - "The service is temporarily at capacity. Please retry your request shortly.", - ); - let cap = "The service is temporarily at capacity. Please retry your request shortly."; - assert_eq!(format_acp_error(& capacity, false), cap); - assert_eq!(format_acp_error(& capacity, true), cap); + .data(format!("API error (status 429 Too Many Requests): {cap_body}")); + assert_eq!(format_acp_error(& capacity, false), cap_body); + assert_eq!(format_acp_error(& capacity, true), cap_body); + let rpm_body = "You are sending requests too quickly. Please slow down, or upgrade to a Grok subscription for higher limits: https://grok.com/supergrok"; let rpm = acp::Error::new(RATE_LIMITED_ERROR_CODE, "Rate limited") - .data( - "You are sending requests too quickly. Please slow down, or upgrade to a Grok subscription for higher limits: https://grok.com/supergrok", - ); + .data(format!("API error (status 429 Too Many Requests): {rpm_body}")); assert!(format_acp_error(& rpm, false).contains("grok.com/supergrok")); assert_eq!(format_acp_error(& rpm, true), RATE_LIMITED_USER_MESSAGE_API_KEY); let empty = acp::Error::new(RATE_LIMITED_ERROR_CODE, "Rate limited"); assert_eq!(format_acp_error(& empty, false), RATE_LIMITED_USER_MESSAGE_OAUTH); assert_eq!(format_acp_error(& empty, true), RATE_LIMITED_USER_MESSAGE_API_KEY); let free = acp::Error::new(RATE_LIMITED_ERROR_CODE, "Rate limited") - .data("subscription:free-usage-exhausted: You have used all your free usage."); + .data( + "API error (status 429 Too Many Requests): \ + subscription:free-usage-exhausted: You have used all your free usage.", + ); assert_eq!(format_acp_error(& free, false), FREE_USAGE_USER_MESSAGE); assert_eq!(format_acp_error(& free, true), FREE_USAGE_USER_MESSAGE); + let free_wrapped = acp::Error::new(RATE_LIMITED_ERROR_CODE, "Rate limited") + .data( + serde_json::json!( + { "message" : + "API error (status 429 Too Many Requests): \ + subscription:free-usage-exhausted: You have used all your free usage.", + "promptUsage" : { "inputTokens" : 12, "outputTokens" : 0, "numTurns" : 1 + } } + ), + ); + assert_eq!(format_acp_error(& free_wrapped, false), FREE_USAGE_USER_MESSAGE); } /// Non-empty token ranges ride the wire block meta as `skillTokenRanges` /// byte pairs; the text itself is untouched. @@ -701,6 +712,16 @@ async fn persist_setting_type_mismatch_errors_show_timeline() { "error message must mention key + expected kind, got: {err}", ); } +#[tokio::test] +async fn persist_setting_type_mismatch_errors_page_flip_on_send() { + use crate::settings::SettingValue; + let r = persist_setting("page_flip_on_send", SettingValue::String("nope".into())) + .await; + let err = r.expect_err("page_flip_on_send with String payload must return Err"); + assert!( + err.contains("persist_setting(page_flip_on_send) expected Bool"), "got: {err}", + ); +} /// Type-mismatch for `simple_mode`. #[tokio::test] async fn persist_setting_type_mismatch_errors_simple_mode() { diff --git a/crates/codegen/xai-grok-pager/src/app/event_loop.rs b/crates/codegen/xai-grok-pager/src/app/event_loop.rs index 79c7981..dddedd4 100644 --- a/crates/codegen/xai-grok-pager/src/app/event_loop.rs +++ b/crates/codegen/xai-grok-pager/src/app/event_loop.rs @@ -7,6 +7,7 @@ use std::time::Duration; +use anyhow::Context as _; use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use tokio::task::JoinSet; use tokio::time::{Instant, sleep_until}; @@ -25,6 +26,21 @@ use super::app_view::{ }; use super::{PagerArgs, PagerTerminal, acp_handler, dispatch, effects}; +#[derive(Clone, Debug, PartialEq)] +pub(super) struct TimedInputEvent { + pub(super) event: Event, + pub(super) arrived_at: std::time::Instant, +} + +impl TimedInputEvent { + fn now(event: Event) -> Self { + Self { + event, + arrived_at: std::time::Instant::now(), + } + } +} + /// Values resolved before `init_terminal` and consumed by the event loop. /// /// All fields must be computed while stdin is still in cooked mode and @@ -94,6 +110,15 @@ struct ReconnectLoadPlan { meta: serde_json::Value, } +fn restore_dashboard_peek_before_reload( + dashboard: &mut Option, + agents: &mut indexmap::IndexMap, +) { + if let Some(dashboard) = dashboard.as_mut() { + dashboard.restore_peek_viewport(agents); + } +} + fn plan_reconnect_load( agent: &super::agent_view::AgentView, fallback_cwd: &std::path::Path, @@ -192,59 +217,63 @@ fn seed_trust_state( }; } -/// Suspend the inline TUI, run a blocking child that takes over the tty -/// (`$EDITOR`, `$PAGER`, …), then restore. Shared by the editor and transcript -/// suspend paths so the subtle reader-park + raw-mode + alt-screen handoff lives -/// in one place. -/// -/// The reader thread is parked first so the child (which inherits this tty) -/// keeps every keystroke instead of racing the reader; on return, buffered -/// terminal query replies and any pre-park keystroke are drained before the -/// reader resumes. -/// -/// The frame **writer** thread is then drained (bounded) before the child -/// starts: frames are written to the tty asynchronously, and the frame that -/// armed this suspend (e.g. minimal's final `/transcript` pump slice) is -/// typically queued microseconds before we get here. Un-drained, those bytes -/// race the child's own output — they can land on the child's alternate -/// screen (so the main screen never receives them; on an inline viewport a -/// commit's scroll then leaves every following row off by its height) or tear -/// around the alt-screen switch, printing escape fragments (`[`…) that the -/// renderer's diff can't see and thus never repairs. -/// -/// In minimal mode the physical cursor is probed (`ESC[6n`) right before the -/// child runs and right after it exits — while the reader thread is still -/// parked, so the replies can't be stolen. The returned "cursor after the -/// child, iff it moved" tells the caller whether the child *printed to the -/// main screen* (cat-style pager: cursor left below its output) or *restored -/// it* (less-style alt-screen pager: `rmcup` puts the cursor back exactly -/// where it was): the caller re-anchors the live region below the new output -/// in the first case and repaints in place in the second. -fn suspend_for_child( - screen_mode: crate::app::ScreenMode, - writer_sync: &crate::render::draw::WriterSync, +/// Pause terminal input and wait up to `timeout` for the reader to acknowledge. +/// Returns with the pause still asserted; the handoff owner resumes the reader. +fn park_input_reader( input_paused: &std::sync::atomic::AtomicBool, reader_parked: &std::sync::atomic::AtomicBool, - input_rx: &mut tokio::sync::mpsc::UnboundedReceiver, - run_child: impl FnOnce(), -) -> Option<(u16, u16)> { + timeout: Duration, +) -> bool { use std::sync::atomic::Ordering; - // Pause the reader thread, then wait for a FRESH park (reader provably out of - // crossterm) so the main thread is the sole poll/read caller; bounded so a - // dead reader can't hang us. - input_paused.store(true, Ordering::Release); + // Storing `reader_parked = false` before `input_paused = true` is + // intentionally ordered to prevent accepting a stale parked acknowledgement. reader_parked.store(false, Ordering::Release); - let park_deadline = std::time::Instant::now() + Duration::from_millis(500); - while !reader_parked.load(Ordering::Acquire) && std::time::Instant::now() < park_deadline { + input_paused.store(true, Ordering::Release); + let deadline = std::time::Instant::now() + timeout; + while !reader_parked.load(Ordering::Acquire) && std::time::Instant::now() < deadline { std::thread::sleep(Duration::from_millis(5)); } - // Every queued frame must be ON the tty before the child takes it (and, in - // fullscreen, before LeaveAlternateScreen below — mirroring teardown's - // "no late frame after LeaveAlternateScreen" drain). Bounded like the - // reader park so a wedged pty can't hang the suspend. - if !writer_sync.wait_drained(Duration::from_millis(750)) { - tracing::warn!("suspend: frame writer not drained within 750ms; proceeding"); + reader_parked.load(Ordering::Acquire) +} + +/// Suspend the TUI, let a blocking child own the tty, then restore it. +/// +/// Input is parked before the asynchronous frame writer is drained with a +/// bounded wait, so neither the reader nor a queued frame can race the child. +/// A park or drain timeout returns without starting the child; the caller keeps +/// the request pending and retries it later. +fn suspend_for_child( + screen_mode: crate::app::ScreenMode, + terminal: &mut PagerTerminal, + input_paused: &std::sync::atomic::AtomicBool, + reader_parked: &std::sync::atomic::AtomicBool, + input_rx: &mut tokio::sync::mpsc::UnboundedReceiver, + run_child: impl FnOnce(), +) -> std::io::Result> { + use std::sync::atomic::Ordering; + if !park_input_reader(input_paused, reader_parked, Duration::from_millis(500)) { + input_paused.store(false, Ordering::Release); + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "terminal input reader did not park before suspend", + )); } + let writer_sync = terminal.backend_mut().writer_mut().writer_sync().clone(); + match writer_sync.wait_drained(Duration::from_millis(750)) { + Ok(crate::render::draw::WriterDrain::Drained) => {} + Ok(crate::render::draw::WriterDrain::TimedOut) => { + input_paused.store(false, Ordering::Release); + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "terminal writer did not drain before suspend", + )); + } + Err(error) => { + input_paused.store(false, Ordering::Release); + return Err(error); + } + } + // Pre-child cursor probe (minimal only — minimal's startup already proved // this terminal answers CPR). Reader is parked, so the reply is ours. let pre_cursor = screen_mode @@ -270,37 +299,211 @@ fn suspend_for_child( let _ = crossterm::event::read(); } // Post-child cursor probe: `Some` iff the child left the cursor somewhere - // other than where it found it (see the doc comment). + // other than where it found it; restore_after_child uses that to re-anchor + // minimal mode after main-screen output. let moved_cursor = pre_cursor.and_then(|pre| { let post = crossterm::cursor::position().ok()?; (post != pre).then_some(post) }); - // Discard any keystroke the reader read during the brief pre-park window: it - // lands in the channel, not the tty. + // Only the pre-park race can reach this channel; later input stays in the tty. while input_rx.try_recv().is_ok() {} input_paused.store(false, Ordering::Release); - moved_cursor + Ok(moved_cursor) } -/// Restore the inline/minimal live region after a tty-taking child exited. +/// Coalesces draw requests, gates in-flight frames, and owns draw cadence. +#[derive(Debug)] +struct Presenter { + dirty: bool, + force_full_repaint: bool, + in_flight_target: Option, + last_draw_at: Instant, + draw_scheduled_at: Option, +} + +impl Presenter { + fn new() -> Self { + Self { + dirty: false, + force_full_repaint: false, + in_flight_target: None, + last_draw_at: Instant::now(), + draw_scheduled_at: None, + } + } + + fn acknowledge(&mut self, sequence: u64) { + if self + .in_flight_target + .is_some_and(|target| sequence >= target) + { + self.in_flight_target = None; + } + } + + fn try_present( + &mut self, + queued_before: u64, + draw: impl FnOnce(bool), + queued_after: impl FnOnce() -> u64, + ) -> bool { + if self.in_flight_target.is_some() || !self.dirty { + return false; + } + let force_full_repaint = std::mem::take(&mut self.force_full_repaint); + self.dirty = false; + draw(force_full_repaint); + let target = queued_after(); + if target > queued_before { + self.in_flight_target = Some(target); + } + true + } + + fn request(&mut self, force_full_repaint: bool) { + self.dirty = true; + self.force_full_repaint |= force_full_repaint; + } + + /// Request now when cadence permits; otherwise schedule the earliest draw. + fn request_throttled(&mut self, now: Instant, min_draw_interval: Duration) -> bool { + if now.duration_since(self.last_draw_at) < min_draw_interval { + if self.draw_scheduled_at.is_none() { + self.draw_scheduled_at = Some(self.last_draw_at + min_draw_interval); + } + return false; + } + self.request(false); + true + } + + fn mark_drawn(&mut self, now: Instant) { + self.last_draw_at = now; + self.draw_scheduled_at = None; + } + + fn present_if_dirty(&mut self, app: &mut AppView, terminal: &mut PagerTerminal) { + let sync = terminal.backend_mut().writer_mut().writer_sync().clone(); + let queued_before = sync.queued(); + let drew = self.try_present( + queued_before, + |force| { + if force { + let _ = terminal.clear(); + } + app.draw(terminal); + }, + || sync.queued(), + ); + if drew { + self.mark_drawn(Instant::now()); + } + } + + fn request_presentation( + &mut self, + app: &mut AppView, + terminal: &mut PagerTerminal, + force_full_repaint: bool, + ) { + self.request(force_full_repaint); + self.present_if_dirty(app, terminal); + } +} + +fn writer_event_sequence(event: crate::render::draw::WriterEvent) -> std::io::Result { + match event { + crate::render::draw::WriterEvent::Written(sequence) => Ok(sequence), + crate::render::draw::WriterEvent::Failed(error) => Err(error), + } +} + +const SUSPEND_RETRY_DELAY: Duration = Duration::from_millis(250); + +fn suspend_retry_ready(retry_after: Option, now: Instant) -> bool { + retry_after.is_none_or(|deadline| now >= deadline) +} + +#[derive(Debug, Default)] +struct SuspendWaitReports { + editor_reported: bool, + pager_reported: bool, +} + +impl SuspendWaitReports { + fn reset_missing(&mut self, editor_pending: bool, pager_pending: bool) { + if !editor_pending { + self.editor_reported = false; + } + if !pager_pending { + self.pager_reported = false; + } + } +} + +/// Arm the deferred retry and return whether this pending handoff needs feedback. +fn defer_suspend_retry( + retry_after: &mut Option, + wait_reported: &mut bool, + now: Instant, +) -> bool { + debug_assert!(retry_after.is_none()); + *retry_after = Some(now + SUSPEND_RETRY_DELAY); + let should_report = !*wait_reported; + *wait_reported = true; + should_report +} + +const EDITOR_SUSPEND_WAIT: &str = "Editor is waiting for a safe terminal handoff"; +const TRANSCRIPT_SUSPEND_WAIT: &str = "Transcript is waiting for a safe terminal handoff"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SuspendWaitSink { + Toast, + SystemBlock, +} + +fn suspend_wait_sink(screen_mode: crate::app::ScreenMode) -> SuspendWaitSink { + if screen_mode.is_minimal() { + SuspendWaitSink::SystemBlock + } else { + SuspendWaitSink::Toast + } +} + +/// Report a handoff wait through the sink visible in the current screen mode. +/// The caller deduplicates reports across retries per handoff request. +fn report_suspend_wait(app: &mut AppView, message: &str) { + match suspend_wait_sink(app.screen_mode) { + SuspendWaitSink::Toast => app.show_toast(message), + SuspendWaitSink::SystemBlock => { + if let ActiveView::Agent(id) = app.active_view + && let Some(agent) = app.agents.get_mut(&id) + { + let block = crate::scrollback::block::RenderBlock::system(message); + if let Some(child_sid) = agent.active_subagent.clone() + && let Some(child) = agent.subagent_views.get_mut(&child_sid) + { + child.scrollback.push_block(block); + } else { + agent.scrollback.push_block(block); + } + } + } + } +} + +fn requeue_after_suspend_timeout(pending: &mut Option, request: T) { + // The child never started, so preserve the one-shot request. + *pending = Some(request); +} + +/// Restore presentation after a child releases the tty. /// -/// Two child behaviors, two restores (common post-suspend handling): -/// -/// - **Screen-restoring child** (`less` & friends: alt screen + `rmcup`, or a -/// child that printed nothing): the cursor is back where it was, the main -/// screen still shows the pre-suspend frame. Repaint in place — `clear()` -/// resets the back buffer so the next draw rewrites every viewport cell -/// (the restored screen may still differ subtly, e.g. a lost cell attribute). -/// - **Inline-printing child** (`PAGER=cat`, an editor that dumps to the tty): -/// its output scrolled the main screen and the cursor sits below it. The old -/// viewport rows are gone (scrolled up or overwritten) — re-anchor the -/// viewport at the cursor row, scrolling the screen up first when there -/// isn't a full viewport of room left (the same make-room dance as the -/// startup inline anchor), so the live region redraws below the child's -/// output instead of overpainting it. -/// -/// Fullscreen mode needs neither: it re-enters the alternate screen, and -/// `clear()` + redraw repaints the whole thing. +/// A cat-style child leaves minimal mode's cursor below appended main-screen +/// output, so re-anchor the live viewport there. An alternate-screen child +/// restores the original cursor and needs no re-anchor. The caller then requests +/// a full repaint because the child's writes bypassed ratatui's diff. fn restore_after_child( terminal: &mut PagerTerminal, screen_mode: crate::app::ScreenMode, @@ -313,59 +516,81 @@ fn restore_after_child( let screen = terminal.last_known_area(); let cur = terminal.viewport_area(); let vh = cur.height.max(1).min(screen.height.max(1)); - // Newlines printed from the cursor row scroll the screen exactly when - // fewer than `vh` rows remain below it (append_lines is buffered on - // the frame writer, so these bytes stay ordered before the clear + - // redraw below). + // Buffered append stays ordered before the gated repaint. let _ = terminal.backend_mut().append_lines(vh.saturating_sub(1)); let available = screen.height.saturating_sub(y).saturating_sub(1); - let missing = vh.saturating_sub(1).saturating_sub(available); - let top = y.saturating_sub(missing); + let top = y.saturating_sub(vh.saturating_sub(1).saturating_sub(available)); terminal.set_viewport_area(ratatui::layout::Rect { y: top, height: vh, ..cur }); } - let _ = terminal.clear(); } /// Consume a pending `$EDITOR` / `$PAGER` suspend request, if any. /// -/// Called at the TOP of every event-loop iteration — not from one specific -/// select arm — because the requests can be armed from ANY arm: a keypress -/// (`$EDITOR` from the agents modal), but also an animation tick (minimal's -/// incremental `/transcript` build finishes inside a tick-arm draw and arms -/// `pending_pager_path`). When consumption lived only in the input arm, a -/// build finishing on a tick sat armed until the next unrelated event — the -/// "progress hits done, then multi-second wait before `less` opens" lag. -#[allow(clippy::too_many_arguments)] +/// Called at the top of every event-loop iteration because any select arm can +/// queue one of these requests, including transcript completion during a draw. +/// Each attempt uses a bounded safe-handoff wait; timeout leaves the one-shot +/// request pending, reports once, and gates the next attempt behind a deferred +/// timer so the feedback frame cannot trigger an immediate blocking retry. fn run_pending_suspends( app: &mut AppView, terminal: &mut PagerTerminal, input_paused: &std::sync::atomic::AtomicBool, reader_parked: &std::sync::atomic::AtomicBool, - input_rx: &mut tokio::sync::mpsc::UnboundedReceiver, - last_draw_at: &mut Instant, - draw_scheduled_at: &mut Option, -) { + input_rx: &mut tokio::sync::mpsc::UnboundedReceiver, + presenter: &mut Presenter, + suspend_retry_after: &mut Option, + suspend_wait_reports: &mut SuspendWaitReports, +) -> anyhow::Result<()> { + let editor_pending = app.pending_editor_path.is_some(); + let pager_pending = app.pending_pager_path.is_some(); + suspend_wait_reports.reset_missing(editor_pending, pager_pending); + if !suspend_retry_ready(*suspend_retry_after, Instant::now()) { + return Ok(()); + } + // The gate is consumed before any blocking park/drain attempt. A timeout + // must arm a fresh deadline before this function returns. + if !editor_pending && !pager_pending { + *suspend_retry_after = None; + return Ok(()); + } + *suspend_retry_after = None; + // $EDITOR suspend: leave alt screen, disable raw mode, spawn // editor, wait for exit, then restore. if let Some(path) = app.pending_editor_path.take() { let editor = std::env::var("VISUAL") .or_else(|_| std::env::var("EDITOR")) .unwrap_or_else(|_| "vi".to_string()); - let writer_sync = terminal.backend_mut().writer_mut().writer_sync().clone(); - let moved_cursor = suspend_for_child( + let moved_cursor = match suspend_for_child( app.screen_mode, - &writer_sync, + terminal, input_paused, reader_parked, input_rx, || { let _ = std::process::Command::new(&editor).arg(&path).status(); }, - ); + ) { + Ok(moved_cursor) => moved_cursor, + Err(error) if error.kind() == std::io::ErrorKind::TimedOut => { + requeue_after_suspend_timeout(&mut app.pending_editor_path, path); + let first_timeout = defer_suspend_retry( + suspend_retry_after, + &mut suspend_wait_reports.editor_reported, + Instant::now(), + ); + if first_timeout { + report_suspend_wait(app, EDITOR_SUSPEND_WAIT); + presenter.request_presentation(app, terminal, false); + } + return Ok(()); + } + Err(error) => return Err(error.into()), + }; if let Some(tab) = app.pending_agents_modal_refresh.take() && let ActiveView::Agent(id) = app.active_view && let Some(agent) = app.agents.get_mut(&id) @@ -377,9 +602,8 @@ fn run_pending_suspends( // repaint the full viewport rather than diffing against a screen // state we can no longer vouch for. restore_after_child(terminal, app.screen_mode, moved_cursor); - app.draw(terminal); - *last_draw_at = Instant::now(); - *draw_scheduled_at = None; + presenter.request_presentation(app, terminal, true); + suspend_wait_reports.editor_reported = false; } // /transcript suspend: open the rendered transcript in $PAGER, @@ -391,10 +615,9 @@ fn run_pending_suspends( .ok() .filter(|p| !p.trim().is_empty()) .unwrap_or_else(|| "less".to_string()); - let writer_sync = terminal.backend_mut().writer_mut().writer_sync().clone(); - let moved_cursor = suspend_for_child( + let moved_cursor = match suspend_for_child( app.screen_mode, - &writer_sync, + terminal, input_paused, reader_parked, input_rx, @@ -436,16 +659,33 @@ fn run_pending_suspends( .status(); } }, - ); + ) { + Ok(moved_cursor) => moved_cursor, + Err(error) if error.kind() == std::io::ErrorKind::TimedOut => { + app.pending_pager_ansi = ansi; + requeue_after_suspend_timeout(&mut app.pending_pager_path, path); + let first_timeout = defer_suspend_retry( + suspend_retry_after, + &mut suspend_wait_reports.pager_reported, + Instant::now(), + ); + if first_timeout { + report_suspend_wait(app, TRANSCRIPT_SUSPEND_WAIT); + presenter.request_presentation(app, terminal, false); + } + return Ok(()); + } + Err(error) => return Err(error.into()), + }; let _ = std::fs::remove_file(&path); // The pager owned the screen; re-anchor if it printed inline (cat) and // repaint the full viewport rather than diffing against a screen state // we can no longer vouch for. restore_after_child(terminal, app.screen_mode, moved_cursor); - app.draw(terminal); - *last_draw_at = Instant::now(); - *draw_scheduled_at = None; + presenter.request_presentation(app, terminal, true); + suspend_wait_reports.pager_reported = false; } + Ok(()) } /// Run the main event loop until quit. @@ -469,6 +709,7 @@ pub(crate) async fn run( bg_update_rx: Option< tokio::sync::oneshot::Receiver>, >, + mut writer_event_rx: tokio::sync::mpsc::UnboundedReceiver, ) -> anyhow::Result { // Initialize tracing capture. The channel `rx` will be wired to a // TracingModel (and ultimately a tracing pane) once integrated. @@ -1006,6 +1247,9 @@ pub(crate) async fn run( config.show_timeline = show_timeline; app.set_appearance(config); } + // Single-key load so a malformed unrelated `[ui]` field cannot wipe this. + let page_flip_on_send = crate::appearance::cache::load_page_flip_on_send(); + app.current_ui.page_flip_on_send = Some(page_flip_on_send); // Disk load replaces `current_ui`. Assign one policy-clamped resolved // launch mode unconditionally (CLI > TOML > remote > Ask) so disk Auto // cannot win over `--permission-mode ask`, and a policy-clamped remote @@ -1111,9 +1355,10 @@ pub(crate) async fn run( // input on an idle screen was not serviced until an unrelated arm happened // to re-poll (every ~20s via recap_poll). The always-on tracing_rx tick // used to mask this by re-polling ~30Hz; this removes that dependency. - let (input_tx, mut input_rx) = tokio::sync::mpsc::unbounded_channel::(); + let (input_tx, mut input_rx) = tokio::sync::mpsc::unbounded_channel::(); // Set true around tty handoffs (e.g. $EDITOR) so the reader stops touching - // stdin and the inheriting child process keeps every keystroke. + // stdin and the inheriting child process keeps every keystroke. The handoff + // does not proceed until `reader_parked` acknowledges this pause. let input_paused = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let reader_paused = input_paused.clone(); // Set by the reader once it has parked (stopped calling crossterm) so the @@ -1155,7 +1400,8 @@ pub(crate) async fn run( match event { Ok(ev) => { consecutive_event_errors = 0; - if input_tx.send(ev).is_err() { + let timed = TimedInputEvent::now(ev); + if input_tx.send(timed).is_err() { break; // event loop has shut down } } @@ -1236,8 +1482,15 @@ pub(crate) async fn run( // this stays `TrustState::Done`. seed_trust_state(&mut app, remote_settings.as_ref()); + let mut presenter = Presenter::new(); + // A timed-out handoff stays queued but cannot synchronously retry until + // this deadline fires. Feedback is one-shot per editor/pager request, even + // across multiple deferred attempts. + let mut suspend_retry_after: Option = None; + let mut suspend_wait_reports = SuspendWaitReports::default(); + // Initial render - app.draw(terminal); + presenter.request_presentation(&mut app, terminal, false); // status only; shell auto-syncs post-auth if matches!(app.auth_state, AuthState::Done) { @@ -1336,7 +1589,7 @@ pub(crate) async fn run( if process_effects(effs, &mut tasks, &mut app, &progress_tx) { return Ok(make_run_result(&app)); } - app.draw(terminal); + presenter.request_presentation(&mut app, terminal, false); } else if args.worktree.is_some() { // --worktree only: create worktree + new session. let effs = dispatch::dispatch( @@ -1350,7 +1603,7 @@ pub(crate) async fn run( if process_effects(effs, &mut tasks, &mut app, &progress_tx) { return Ok(make_run_result(&app)); } - app.draw(terminal); + presenter.request_presentation(&mut app, terminal, false); } // Initial prompt from the CLI positional (`grok "fix the bug"`). When @@ -1367,7 +1620,7 @@ pub(crate) async fn run( if process_effects(effs, &mut tasks, &mut app, &progress_tx) { return Ok(make_run_result(&app)); } - app.draw(terminal); + presenter.request_presentation(&mut app, terminal, false); } } @@ -1382,7 +1635,7 @@ pub(crate) async fn run( if process_effects(effs, &mut tasks, &mut app, &progress_tx) { return Ok(make_run_result(&app)); } - app.draw(terminal); + presenter.request_presentation(&mut app, terminal, false); } else { // Not signed in yet — the env var is already consumed, so // without a stash the request would be silently dropped and @@ -1410,7 +1663,7 @@ pub(crate) async fn run( if process_effects(effs, &mut tasks, &mut app, &progress_tx) { return Ok(make_run_result(&app)); } - app.draw(terminal); + presenter.request_presentation(&mut app, terminal, false); } else { // Sign-in (or folder-trust) still pending: minimal renders the // device / external sign-in flow in its live region. Defer the @@ -1447,8 +1700,6 @@ pub(crate) async fn run( // loop-top work (suspends, deadline re-derivation) never waits on an // unbounded drain during a token firehose. const ACP_DRAIN_BATCH_MAX: usize = 32; - let mut last_draw_at = Instant::now(); - let mut draw_scheduled_at: Option = None; let mut reconnect_reinit: Option = None; let mut reconnect_abort_handle: Option = None; @@ -1497,9 +1748,10 @@ pub(crate) async fn run( &input_paused, &reader_parked, &mut input_rx, - &mut last_draw_at, - &mut draw_scheduled_at, - ); + &mut presenter, + &mut suspend_retry_after, + &mut suspend_wait_reports, + )?; // Lazy voice pipeline: only after `/voice` or Ctrl+Space while gates // allow. Consume the queued cold-start, carrying its hold-ownership and @@ -1549,7 +1801,7 @@ pub(crate) async fn run( // drew (with capture still off). Render now so the recording banner // appears immediately instead of waiting for the next input or // network event to wake the select! loop. - app.draw(terminal); + presenter.request_presentation(&mut app, terminal, false); } // Stop voice if the user has left the recording session (see method). @@ -1635,8 +1887,23 @@ pub(crate) async fn run( }; // Future that sleeps until a throttled draw fires, or waits forever. - let deferred_draw = async { - match draw_scheduled_at { + let deferred_draw_at = presenter.draw_scheduled_at; + let deferred_draw = async move { + match deferred_draw_at { + Some(at) => sleep_until(at).await, + None => std::future::pending().await, + } + }; + + // Wake a deferred suspend retry without requiring unrelated input. + let suspend_retry_at = + if app.pending_editor_path.is_some() || app.pending_pager_path.is_some() { + suspend_retry_after + } else { + None + }; + let suspend_retry = async move { + match suspend_retry_at { Some(at) => sleep_until(at).await, None => std::future::pending().await, } @@ -1695,13 +1962,18 @@ pub(crate) async fn run( break; } - // Biased order: the ACP stream, task/progress completions, background - // update, terminal/keyboard input, and all render/poll timers are polled - // before the voice STT arm, which is deliberately LAST (see its note at - // the bottom of this select). A hot mic streams interim transcripts at - // ~5–20 Hz and can keep `voice_rx` effectively always-ready, so voice - // must sit below everything or it would starve keypresses, the agent - // stream, and animation ticks. + writer_event = writer_event_rx.recv() => { + let Some(writer_event) = writer_event else { + return Err(anyhow::anyhow!("terminal writer stopped")); + }; + let sequence = writer_event_sequence(writer_event) + .context("terminal output failed")?; + presenter.acknowledge(sequence); + } + + // Biased order: cancellation/quit, writer acks/failures, ACP, + // task/progress results, updates, input, and render/poll timers all + // precede the deliberately-last voice STT arm (see its note below). // Gated on empty terminal input: a token firehose keeps this arm // ready at every biased poll, so without the gate buffered @@ -1747,13 +2019,8 @@ pub(crate) async fn run( // Cap paint rate so terminal input isn't starved during // heavy ACP streaming. let now = Instant::now(); - if now.duration_since(last_draw_at) >= min_draw_interval { + if presenter.request_throttled(now, min_draw_interval) { app.update_notifications(); - app.draw(terminal); - last_draw_at = now; - draw_scheduled_at = None; - } else if draw_scheduled_at.is_none() { - draw_scheduled_at = Some(last_draw_at + min_draw_interval); } } } @@ -1780,9 +2047,7 @@ pub(crate) async fn run( gate_poll_at = None; } - app.draw(terminal); - last_draw_at = Instant::now(); - draw_scheduled_at = None; + presenter.request(false); } Err(join_err) => { // Task was aborted (e.g., auth cancel) or panicked. @@ -1804,9 +2069,7 @@ pub(crate) async fn run( if process_effects(effs, &mut tasks, &mut app, &progress_tx) { break; } - app.draw(terminal); - last_draw_at = Instant::now(); - draw_scheduled_at = None; + presenter.request(false); } // Background update check completed. @@ -1831,9 +2094,7 @@ pub(crate) async fn run( if term_state.screen_mode.is_minimal() { dispatch::commit_minimal_update_notice(&mut app, &latest); } - app.draw(terminal); - last_draw_at = Instant::now(); - draw_scheduled_at = None; + presenter.request(false); } } @@ -1866,10 +2127,7 @@ pub(crate) async fn run( // Refocus heal wins over the resize debounce: a coalesced same-size // resize wouldn't autoresize-clear, so clear + full repaint now. resize_debounce_at = None; - let _ = terminal.clear(); - app.draw(terminal); - last_draw_at = Instant::now(); - draw_scheduled_at = None; + presenter.request(true); } else if result.resize_only && !tip_shown { // Debounce: schedule a single draw after the size stabilizes. // Each new resize resets the timer so we only rebuild layout once. @@ -1878,9 +2136,7 @@ pub(crate) async fn run( // Non-resize change (or a shown tip): draw immediately // (picks up any pending resize too). resize_debounce_at = None; - app.draw(terminal); - last_draw_at = Instant::now(); - draw_scheduled_at = None; + presenter.request(false); } } @@ -1891,17 +2147,20 @@ pub(crate) async fn run( // Debounced resize: draw once the terminal size has stabilized. _ = resize_debounce => { resize_debounce_at = None; - app.draw(terminal); - last_draw_at = Instant::now(); - draw_scheduled_at = None; + presenter.request(false); schedule_tick(&mut animation_tick_at, &app, tick_interval); } // Deferred draw: fires when an ACP-triggered draw was throttled. _ = deferred_draw => { - draw_scheduled_at = None; - app.draw(terminal); - last_draw_at = Instant::now(); + presenter.draw_scheduled_at = None; + presenter.request(false); + } + + // Only opens the gate; the next loop-top attempt owns the blocking + // handoff so no select arm performs it inline. + _ = suspend_retry => { + suspend_retry_after = None; } // Scroll clock: flush residual wheel/trackpad lines and detect @@ -1910,9 +2169,7 @@ pub(crate) async fn run( // from the post-tick scroll state. _ = scroll_tick => { if app.tick_scroll() { - app.draw(terminal); - last_draw_at = Instant::now(); - draw_scheduled_at = None; + presenter.request(false); } // Scroll dispatch can start work that animates (e.g. viewport // state), so keep the animation arm in sync too. @@ -1936,13 +2193,9 @@ pub(crate) async fn run( if process_effects(effs, &mut tasks, &mut app, &progress_tx) { break; } - app.draw(terminal); - last_draw_at = Instant::now(); - draw_scheduled_at = None; + presenter.request(false); } else if app.tick() { - app.draw(terminal); - last_draw_at = Instant::now(); - draw_scheduled_at = None; + presenter.request(false); } // Keep ticking as long as there are running animations // or pending actions waiting to expire. @@ -2040,9 +2293,7 @@ pub(crate) async fn run( // Reload the scroll settings from the pager caches (resynced // when a setting changes via the settings registry). app.scroll_config = crate::input::mouse::ScrollConfig::from_settings(); - app.draw(terminal); - last_draw_at = Instant::now(); - draw_scheduled_at = None; + presenter.request(false); } // System appearance changed (auto-theme mode). @@ -2071,9 +2322,7 @@ pub(crate) async fn run( previous_theme = %current.display_name(), "system appearance changed, switching theme" ); - app.draw(terminal); - last_draw_at = Instant::now(); - draw_scheduled_at = None; + presenter.request(false); } } } @@ -2110,9 +2359,7 @@ pub(crate) async fn run( app.show_toast(&format!( "Disconnected. Reconnecting... (attempt {attempt})" )); - app.draw(terminal); - last_draw_at = Instant::now(); - draw_scheduled_at = None; + presenter.request(false); } ConnectionStatus::Connected { generation } if generation > last_leader_generation => @@ -2144,6 +2391,10 @@ pub(crate) async fn run( handle.abort(); } if let Some(prev) = reconnect_reinit.take() { + restore_dashboard_peek_before_reload( + &mut app.dashboard, + &mut app.agents, + ); for prev_id in prev.agent_ids { if let Some(agent) = app.agents.get_mut(&prev_id) { agent.finish_session_reload(prev.generation, false); @@ -2170,6 +2421,10 @@ pub(crate) async fn run( agent_ids.sort_by_key(|id| Some(*id) != active_agent_id); let mut reload_agent_ids = Vec::new(); let mut load_plans = Vec::new(); + restore_dashboard_peek_before_reload( + &mut app.dashboard, + &mut app.agents, + ); for id in agent_ids { let Some(agent) = app.agents.get_mut(&id) else { continue; @@ -2290,15 +2545,11 @@ pub(crate) async fn run( } else { "Reconnected. Re-initializing..." }); - app.draw(terminal); - last_draw_at = Instant::now(); - draw_scheduled_at = None; + presenter.request(false); } ConnectionStatus::Failed { ref error } => { app.show_toast(&format!("Connection failed: {error}")); - app.draw(terminal); - last_draw_at = Instant::now(); - draw_scheduled_at = None; + presenter.request(false); } _ => {} } @@ -2349,6 +2600,7 @@ pub(crate) async fn run( &loads, active_agent_id, ); + restore_dashboard_peek_before_reload(&mut app.dashboard, &mut app.agents); for id in &pending.agent_ids { let (ok, running_prompt_id) = loads.remove(id).unwrap_or((false, None)); if let Some(agent) = app.agents.get_mut(id) { @@ -2383,9 +2635,7 @@ pub(crate) async fn run( } } - app.draw(terminal); - last_draw_at = Instant::now(); - draw_scheduled_at = None; + presenter.request(false); } // Voice STT — DELIBERATELY THE LAST (lowest-priority) arm. In a @@ -2409,13 +2659,8 @@ pub(crate) async fn run( if needs_draw { schedule_tick(&mut animation_tick_at, &app, tick_interval); let now = Instant::now(); - if now.duration_since(last_draw_at) >= min_draw_interval { + if presenter.request_throttled(now, min_draw_interval) { app.update_notifications(); - app.draw(terminal); - last_draw_at = now; - draw_scheduled_at = None; - } else if draw_scheduled_at.is_none() { - draw_scheduled_at = Some(last_draw_at + min_draw_interval); } } if !app.pending_effects.is_empty() { @@ -2435,13 +2680,13 @@ pub(crate) async fn run( if was_listening { app.show_toast("Voice stopped — pipeline ended"); } - app.draw(terminal); - last_draw_at = Instant::now(); - draw_scheduled_at = None; + presenter.request(false); } } } } + + presenter.present_if_dirty(&mut app, terminal); } app.notification_service.shutdown(); @@ -2449,8 +2694,6 @@ pub(crate) async fn run( Ok(make_run_result(&app)) } -/// Schedule the next animation tick if there are running entries and none is pending. -/// /// Load `UiConfig` from the shell's layered config at startup. /// Falls back to `UiConfig::default()` on any failure. pub(crate) fn load_initial_ui_config() -> xai_grok_shell::agent::config::UiConfig { @@ -2531,6 +2774,7 @@ fn should_pregenerate_away_recap(app: &AppView) -> bool { }) } +/// Schedule the next animation tick when demanded and none is pending. fn schedule_tick(tick_at: &mut Option, app: &AppView, interval: Duration) { if tick_at.is_none() { let interval = match app.tick_demand() { @@ -2616,10 +2860,12 @@ struct DrainResult { struct RoutedInputEvent { event: Event, + arrived_at: std::time::Instant, paste_provenance: PasteProvenance, } -fn normalize_input_event(event: Event) -> RoutedInputEvent { +fn normalize_input_event(timed: TimedInputEvent) -> RoutedInputEvent { + let TimedInputEvent { event, arrived_at } = timed; #[cfg(target_os = "linux")] { use crossterm::event::{MouseButton, MouseEventKind}; @@ -2635,12 +2881,14 @@ fn normalize_input_event(event: Event) -> RoutedInputEvent { { return RoutedInputEvent { event: Event::Paste(text), + arrived_at, paste_provenance: PasteProvenance::X11Primary, }; } } RoutedInputEvent { event, + arrived_at, paste_provenance: PasteProvenance::Terminal, } } @@ -2657,8 +2905,8 @@ fn normalize_input_event(event: Event) -> RoutedInputEvent { /// processing to fix paste on terminals without bracketed paste (e.g. /// Windows PowerShell) and filter leaked CSI fragments (SGR mouse and focus reports). async fn drain_and_process( - first: Event, - input_rx: &mut tokio::sync::mpsc::UnboundedReceiver, + first: TimedInputEvent, + input_rx: &mut tokio::sync::mpsc::UnboundedReceiver, app: &mut AppView, tasks: &mut JoinSet, progress_tx: &tokio::sync::mpsc::UnboundedSender, @@ -2841,7 +3089,11 @@ async fn drain_and_process( return false; } let is_resize = matches!(ev, Event::Resize(_, _)); - match app.handle_input_with_paste_provenance(ev, routed.paste_provenance) { + match app.handle_input_at_with_paste_provenance( + ev, + routed.arrived_at, + routed.paste_provenance, + ) { InputOutcome::Action(action) => { let effs = dispatch::dispatch(action, app); if process_effects(effs, tasks, app, progress_tx) { @@ -2858,9 +3110,11 @@ async fn drain_and_process( if process_effects(effs, tasks, app, progress_tx) { return true; } - if let InputOutcome::Action(follow_up) = - app.handle_input_with_paste_provenance(ev, routed.paste_provenance) - { + if let InputOutcome::Action(follow_up) = app.handle_input_at_with_paste_provenance( + ev, + routed.arrived_at, + routed.paste_provenance, + ) { let effs = dispatch::dispatch(follow_up, app); if process_effects(effs, tasks, app, progress_tx) { return true; @@ -2934,24 +3188,26 @@ const PASTE_EXTEND_MAX_EVENTS: usize = 5_000; /// Returns `true` when the batch contains pasteable key events but no /// `Event::Paste` (i.e. bracketed paste is not handling it). -fn should_extend_for_paste(events: &[Event]) -> bool { - !events.iter().any(|e| matches!(e, Event::Paste(_))) - && events.iter().any(is_pasteable_key_event) +fn should_extend_for_paste(events: &[TimedInputEvent]) -> bool { + !events.iter().any(|e| matches!(e.event, Event::Paste(_))) + && events.iter().any(|e| is_pasteable_key_event(&e.event)) } /// Wait [`PASTE_DETECT_TIMEOUT`] for a follow-up event. Returns `true` /// if a **pasteable key event** arrives within the window. Non-key events /// (mouse, focus, releases) are collected but do not count as paste evidence. async fn detect_paste( - batch: &mut Vec, - input_rx: &mut tokio::sync::mpsc::UnboundedReceiver, + batch: &mut Vec, + input_rx: &mut tokio::sync::mpsc::UnboundedReceiver, ) -> bool { match tokio::time::timeout(PASTE_DETECT_TIMEOUT, input_rx.recv()).await { Ok(Some(ev)) => { let prev_len = batch.len(); batch.push(ev); drain_immediate(batch, input_rx); - batch[prev_len..].iter().any(is_pasteable_key_event) + batch[prev_len..] + .iter() + .any(|e| is_pasteable_key_event(&e.event)) } _ => false, } @@ -2961,8 +3217,8 @@ async fn detect_paste( /// Only pasteable key events extend the timeout; non-key events are /// collected but do not keep the loop alive. async fn collect_remaining_paste( - batch: &mut Vec, - input_rx: &mut tokio::sync::mpsc::UnboundedReceiver, + batch: &mut Vec, + input_rx: &mut tokio::sync::mpsc::UnboundedReceiver, ) { let mut extended = 0usize; loop { @@ -2975,7 +3231,10 @@ async fn collect_remaining_paste( batch.push(ev); extended += 1; drain_immediate(batch, input_rx); - if !batch[prev_len..].iter().any(is_pasteable_key_event) { + if !batch[prev_len..] + .iter() + .any(|e| is_pasteable_key_event(&e.event)) + { continue; } } @@ -2986,8 +3245,8 @@ async fn collect_remaining_paste( /// Non-blocking drain of all immediately available events. pub(super) fn drain_immediate( - batch: &mut Vec, - input_rx: &mut tokio::sync::mpsc::UnboundedReceiver, + batch: &mut Vec, + input_rx: &mut tokio::sync::mpsc::UnboundedReceiver, ) { while let Ok(ev) = input_rx.try_recv() { batch.push(ev); @@ -3083,7 +3342,7 @@ fn is_voice_chord(ke: &KeyEvent) -> bool { /// instead of a bracketed paste; this branch recovers them. /// /// No-op when bracketed paste already arrives as `Event::Paste`. -fn coalesce_rapid_keys(events: Vec) -> Vec { +fn coalesce_rapid_keys(events: Vec) -> Vec { // Fast path: not enough events for coalescing to trigger. if events.len() < PASTE_COALESCE_THRESHOLD { return events; @@ -3093,9 +3352,9 @@ fn coalesce_rapid_keys(events: Vec) -> Vec { // Terminal can split a large bracketed paste across read boundaries), // merge everything into a single Event::Paste. let (mut has_paste, mut has_keys) = (false, false); - for e in events.iter() { - has_paste |= matches!(e, Event::Paste(_)); - has_keys |= is_pasteable_key_event(e); + for e in &events { + has_paste |= matches!(e.event, Event::Paste(_)); + has_keys |= is_pasteable_key_event(&e.event); } if has_paste { return if has_keys { @@ -3107,10 +3366,10 @@ fn coalesce_rapid_keys(events: Vec) -> Vec { // Remove Release events — handlers ignore them and they'd break run // detection. Exception: voice-chord releases (needed for hold-to-talk). - let events: Vec = events + let events: Vec = events .into_iter() .filter(|ev| { - !matches!(ev, Event::Key(ke) + !matches!(&ev.event, Event::Key(ke) if ke.kind == KeyEventKind::Release && !is_voice_chord(ke)) }) .collect(); @@ -3119,14 +3378,15 @@ fn coalesce_rapid_keys(events: Vec) -> Vec { let mut i = 0; while i < events.len() { - if is_pasteable_key_event(&events[i]) { + if is_pasteable_key_event(&events[i].event) { let run_start = i; + let arrived_at = events[i].arrived_at; let mut text = String::new(); let mut seen_enter = false; let mut has_char_after_enter = false; - while i < events.len() && is_pasteable_key_event(&events[i]) { - if let Event::Key(ke) = &events[i] { + while i < events.len() && is_pasteable_key_event(&events[i].event) { + if let Event::Key(ke) = &events[i].event { match ke.code { KeyCode::Char(c) => { text.push(c); @@ -3168,7 +3428,10 @@ fn coalesce_rapid_keys(events: Vec) -> Vec { path_shape = path_shaped_drop, "coalesced rapid key events into paste" ); - result.push(Event::Paste(text)); + result.push(TimedInputEvent { + event: Event::Paste(text), + arrived_at, + }); } else { for ev in &events[run_start..i] { result.push(ev.clone()); @@ -3195,25 +3458,37 @@ pub(super) fn is_bare_esc_press(ev: &Event) -> bool { /// Merge `Event::Paste` fragments and interleaved key events into a /// single `Event::Paste`. Non-paste, non-key events (Resize, Mouse, /// Focus) are preserved in order around the merged paste. -fn merge_paste_fragments(events: Vec) -> Vec { +fn merge_paste_fragments(events: Vec) -> Vec { let mut result = Vec::new(); let mut merged_text = String::new(); + let mut merged_arrived_at = None; for ev in events { - match &ev { - Event::Paste(text) => merged_text.push_str(text), - Event::Key(ke) if is_pasteable_key_event(&ev) => match ke.code { - KeyCode::Char(c) => merged_text.push(c), - KeyCode::Enter => merged_text.push('\n'), - KeyCode::Tab => merged_text.push('\t'), - _ => {} - }, + match &ev.event { + Event::Paste(text) => { + merged_arrived_at.get_or_insert(ev.arrived_at); + merged_text.push_str(text); + } + Event::Key(ke) if is_pasteable_key_event(&ev.event) => { + merged_arrived_at.get_or_insert(ev.arrived_at); + match ke.code { + KeyCode::Char(c) => merged_text.push(c), + KeyCode::Enter => merged_text.push('\n'), + KeyCode::Tab => merged_text.push('\t'), + _ => {} + } + } // Non-pasteable keys (Ctrl+C, Backspace, arrows, Release // events, etc.) are artifacts of paste fragmentation — drop. Event::Key(_) => {} _ => { if !merged_text.is_empty() { - result.push(Event::Paste(std::mem::take(&mut merged_text))); + result.push(TimedInputEvent { + event: Event::Paste(std::mem::take(&mut merged_text)), + arrived_at: merged_arrived_at + .take() + .expect("non-empty merged paste has an arrival time"), + }); } result.push(ev); } @@ -3221,7 +3496,10 @@ fn merge_paste_fragments(events: Vec) -> Vec { } if !merged_text.is_empty() { - result.push(Event::Paste(merged_text)); + result.push(TimedInputEvent { + event: Event::Paste(merged_text), + arrived_at: merged_arrived_at.expect("non-empty merged paste has an arrival time"), + }); } result @@ -3437,6 +3715,37 @@ mod tests { ); } + #[test] + fn reconnect_restores_dashboard_peek_before_replacing_scrollback() { + use crate::scrollback::block::RenderBlock; + use crate::views::dashboard::{DashboardRowId, DashboardState}; + use indexmap::IndexMap; + + let id = super::super::agent::AgentId(0); + let mut agent = crate::test_util::make_agent_view(Some("sess-1"), "/work"); + agent + .scrollback + .push_block(RenderBlock::user_prompt("before reconnect")); + agent.scrollback.prepare_layout(80, 24); + agent.scrollback.set_selected(Some(0)); + agent.scrollback.set_scroll_offset(0); + let mut agents = IndexMap::new(); + agents.insert(id, agent); + let mut dashboard = Some(DashboardState::new()); + dashboard + .as_mut() + .unwrap() + .begin_peek_viewport(DashboardRowId::TopLevel(id), &mut agents); + assert!(dashboard.as_ref().unwrap().peek_viewport.is_some()); + assert!(agents[&id].scrollback.is_follow_mode()); + + restore_dashboard_peek_before_reload(&mut dashboard, &mut agents); + + assert!(dashboard.as_ref().unwrap().peek_viewport.is_none()); + assert_eq!(agents[&id].scrollback.selected(), Some(0)); + assert!(!agents[&id].scrollback.is_follow_mode()); + } + // ── reconnect_restore_outcome ──────────────────────────────────────── /// The regression guard: one background tab fails, the active tab @@ -3535,50 +3844,407 @@ mod tests { ); } - fn press(code: KeyCode) -> Event { - Event::Key(KeyEvent { - code, - modifiers: KeyModifiers::NONE, - kind: KeyEventKind::Press, - state: KeyEventState::NONE, - }) + fn timed(event: Event, arrived_at: std::time::Instant) -> TimedInputEvent { + TimedInputEvent { event, arrived_at } } - fn release(code: KeyCode) -> Event { - Event::Key(KeyEvent { + fn key_event(code: KeyCode, modifiers: KeyModifiers, kind: KeyEventKind) -> TimedInputEvent { + TimedInputEvent::now(Event::Key(KeyEvent { code, - modifiers: KeyModifiers::NONE, - kind: KeyEventKind::Release, + modifiers, + kind, state: KeyEventState::NONE, - }) + })) } - fn press_shift(code: KeyCode) -> Event { - Event::Key(KeyEvent { - code, - modifiers: KeyModifiers::SHIFT, - kind: KeyEventKind::Press, - state: KeyEventState::NONE, - }) + fn scroll_event( + kind: crossterm::event::MouseEventKind, + arrived_at: std::time::Instant, + ) -> TimedInputEvent { + timed( + Event::Mouse(crossterm::event::MouseEvent { + kind, + column: 7, + row: 11, + modifiers: KeyModifiers::NONE, + }), + arrived_at, + ) } - fn press_ctrl(code: KeyCode) -> Event { - Event::Key(KeyEvent { - code, - modifiers: KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - state: KeyEventState::NONE, - }) + fn press(code: KeyCode) -> TimedInputEvent { + key_event(code, KeyModifiers::NONE, KeyEventKind::Press) + } + + fn release(code: KeyCode) -> TimedInputEvent { + key_event(code, KeyModifiers::NONE, KeyEventKind::Release) + } + + fn press_shift(code: KeyCode) -> TimedInputEvent { + key_event(code, KeyModifiers::SHIFT, KeyEventKind::Press) + } + + fn press_ctrl(code: KeyCode) -> TimedInputEvent { + key_event(code, KeyModifiers::CONTROL, KeyEventKind::Press) } #[cfg(target_os = "linux")] - fn mouse_event(kind: crossterm::event::MouseEventKind, modifiers: KeyModifiers) -> Event { - Event::Mouse(crossterm::event::MouseEvent { + fn mouse_event( + kind: crossterm::event::MouseEventKind, + modifiers: KeyModifiers, + ) -> TimedInputEvent { + TimedInputEvent::now(Event::Mouse(crossterm::event::MouseEvent { kind, column: 7, row: 11, modifiers, - }) + })) + } + + #[test] + fn park_input_reader_timeout_clears_stale_acknowledgement() { + use std::sync::atomic::{AtomicBool, Ordering}; + + let input_paused = AtomicBool::new(false); + let reader_parked = AtomicBool::new(true); + let acknowledged = park_input_reader(&input_paused, &reader_parked, Duration::ZERO); + + assert!(!acknowledged); + assert!(!reader_parked.load(Ordering::Acquire)); + assert!(input_paused.load(Ordering::Acquire)); + } + + #[test] + fn suspend_retry_gate_blocks_until_deadline() { + let now = Instant::now(); + let mut retry_after = None; + let mut wait_reported = false; + + assert!(defer_suspend_retry( + &mut retry_after, + &mut wait_reported, + now + )); + assert!(!suspend_retry_ready(retry_after, now)); + assert_eq!(retry_after, Some(now + SUSPEND_RETRY_DELAY)); + assert!(suspend_retry_ready(retry_after, now + SUSPEND_RETRY_DELAY)); + assert!(wait_reported); + + // Mirrors the timer arm: expiry opens the gate for the next loop top. + retry_after = None; + assert!(suspend_retry_ready(retry_after, now)); + assert!(!defer_suspend_retry( + &mut retry_after, + &mut wait_reported, + now + )); + assert_eq!(retry_after, Some(now + SUSPEND_RETRY_DELAY)); + assert!(!suspend_retry_ready(retry_after, now)); + } + + #[test] + fn suspend_timeout_requeues_request() { + let mut pending = None; + + requeue_after_suspend_timeout(&mut pending, "request"); + + assert_eq!(pending, Some("request")); + } + + #[test] + fn suspend_wait_feedback_is_reported_only_once_across_retries() { + let now = Instant::now(); + let mut retry_after = None; + let mut reports = SuspendWaitReports::default(); + + assert!(defer_suspend_retry( + &mut retry_after, + &mut reports.editor_reported, + now + )); + retry_after = None; + assert!(!defer_suspend_retry( + &mut retry_after, + &mut reports.editor_reported, + now + )); + + reports.reset_missing(false, false); + assert!(!reports.editor_reported); + retry_after = None; + assert!(defer_suspend_retry( + &mut retry_after, + &mut reports.editor_reported, + now + )); + } + + #[test] + fn editor_report_then_success_does_not_suppress_pager_first_timeout() { + let now = Instant::now(); + let mut retry_after = None; + let mut reports = SuspendWaitReports::default(); + + assert!(defer_suspend_retry( + &mut retry_after, + &mut reports.editor_reported, + now + )); + // The editor retry succeeds while the pager request remains pending. + retry_after = None; + reports.editor_reported = false; + + assert!(defer_suspend_retry( + &mut retry_after, + &mut reports.pager_reported, + now + )); + retry_after = None; + assert!(!defer_suspend_retry( + &mut retry_after, + &mut reports.pager_reported, + now + )); + } + + #[test] + fn suspend_wait_sink_is_mode_appropriate() { + assert_eq!( + suspend_wait_sink(crate::app::ScreenMode::Minimal), + SuspendWaitSink::SystemBlock + ); + assert_eq!( + suspend_wait_sink(crate::app::ScreenMode::Inline), + SuspendWaitSink::Toast + ); + assert_eq!( + suspend_wait_sink(crate::app::ScreenMode::Fullscreen), + SuspendWaitSink::Toast + ); + } + + #[test] + fn suspend_wait_report_uses_system_block_in_minimal_mode() { + use crate::scrollback::block::RenderBlock; + + let mut app = crate::app::app_view::tests::test_app(); + let id = crate::app::agent::AgentId(0); + let agent = crate::test_util::make_agent_view(Some("session"), "/tmp"); + app.agents.insert(id, agent); + app.active_view = ActiveView::Agent(id); + app.screen_mode = crate::app::ScreenMode::Minimal; + + report_suspend_wait(&mut app, EDITOR_SUSPEND_WAIT); + + let agent = app.agents.get(&id).expect("active agent"); + let entry = agent.scrollback.last().expect("system block"); + assert!(matches!( + &entry.block, + RenderBlock::System(block) if block.text == EDITOR_SUSPEND_WAIT + )); + assert!(agent.toast.is_none()); + } + + #[test] + fn suspend_wait_report_uses_toast_outside_minimal_mode() { + let mut app = crate::app::app_view::tests::test_app(); + let id = crate::app::agent::AgentId(0); + let agent = crate::test_util::make_agent_view(Some("session"), "/tmp"); + app.agents.insert(id, agent); + app.active_view = ActiveView::Agent(id); + app.screen_mode = crate::app::ScreenMode::Inline; + + report_suspend_wait(&mut app, EDITOR_SUSPEND_WAIT); + + let agent = app.agents.get(&id).expect("active agent"); + assert_eq!( + agent.toast.as_ref().map(|(message, _)| message.as_str()), + Some(EDITOR_SUSPEND_WAIT) + ); + assert!(agent.scrollback.last().is_none()); + } + + #[test] + fn writer_failure_event_returns_original_error() { + let error = writer_event_sequence(crate::render::draw::WriterEvent::Failed( + std::io::Error::other("injected writer failure"), + )) + .expect_err("writer failure must terminate the event loop"); + + assert_eq!(error.to_string(), "injected writer failure"); + } + + #[test] + fn presenter_coalesces_until_ack() { + let mut presenter = Presenter::new(); + let mut draws = 0; + + presenter.request(false); + assert!(presenter.try_present(0, |_| draws += 1, || 1)); + assert_eq!(presenter.in_flight_target, Some(1)); + for _ in 0..5 { + presenter.request(false); + assert!(!presenter.try_present(1, |_| draws += 1, || 2)); + } + assert_eq!(draws, 1); + assert!(presenter.dirty); + + presenter.acknowledge(1); + assert!(presenter.try_present(1, |_| draws += 1, || 2)); + assert_eq!(draws, 2); + assert_eq!(presenter.in_flight_target, Some(2)); + } + + #[test] + fn presenter_no_output_does_not_wedge() { + let mut presenter = Presenter::new(); + presenter.request(false); + + assert!(presenter.try_present(4, |_| {}, || 4)); + assert_eq!(presenter.in_flight_target, None); + assert!(!presenter.dirty); + + presenter.request(false); + assert!(presenter.try_present(4, |_| {}, || 5)); + assert_eq!(presenter.in_flight_target, Some(5)); + } + + #[test] + fn presenter_keeps_forced_repaint_sticky() { + let mut presenter = Presenter { + in_flight_target: Some(8), + ..Presenter::new() + }; + presenter.request(false); + presenter.request(true); + let mut forced = false; + + presenter.acknowledge(8); + assert!(presenter.try_present(8, |force| forced = force, || 9)); + assert!(forced); + assert!(!presenter.force_full_repaint); + } + + #[test] + fn presenter_immediate_ack_before_request_is_not_lost() { + let mut presenter = Presenter { + in_flight_target: Some(3), + ..Presenter::new() + }; + presenter.acknowledge(3); + presenter.request(false); + + assert!(presenter.try_present(3, |_| {}, || 4)); + assert_eq!(presenter.in_flight_target, Some(4)); + } + + #[test] + fn presenter_later_ack_clears_target() { + let mut presenter = Presenter { + in_flight_target: Some(3), + ..Presenter::new() + }; + + presenter.acknowledge(4); + + assert_eq!(presenter.in_flight_target, None); + } + + #[test] + fn presenter_waits_for_last_payload_in_turn() { + let mut presenter = Presenter::new(); + presenter.request(false); + assert!(presenter.try_present(10, |_| {}, || 13)); + presenter.request(false); + + presenter.acknowledge(11); + assert!(!presenter.try_present(13, |_| panic!("target not acknowledged"), || 14)); + presenter.acknowledge(13); + assert!(presenter.try_present(13, |_| {}, || 14)); + assert_eq!(presenter.in_flight_target, Some(14)); + } + + #[test] + fn timed_paste_uses_first_contributing_event() { + let start = std::time::Instant::now(); + let events = vec![ + timed( + Event::Key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)), + start, + ), + timed( + Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + start + Duration::from_millis(4), + ), + timed( + Event::Key(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE)), + start + Duration::from_millis(8), + ), + ]; + + let coalesced = coalesce_rapid_keys(events); + assert_eq!(coalesced.len(), 1); + assert_eq!(coalesced[0].arrived_at, start); + assert_eq!(coalesced[0].event, Event::Paste("a\nb".to_owned())); + + let fragments = vec![ + timed(Event::Paste("a".to_owned()), start), + timed( + Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + start + Duration::from_millis(4), + ), + timed( + Event::Key(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE)), + start + Duration::from_millis(8), + ), + ]; + let merged = merge_paste_fragments(fragments); + assert_eq!(merged[0].arrived_at, start); + assert_eq!(merged[0].event, Event::Paste("a\nb".to_owned())); + } + + #[test] + fn delayed_scroll_batch_preserves_arrival_spacing_and_reversal() { + use crossterm::event::MouseEventKind::{ScrollDown, ScrollUp}; + + let mut app = crate::app::app_view::tests::test_app(); + let start = std::time::Instant::now() + Duration::from_secs(1); + app.scroll_state = Default::default(); + for event in [ + scroll_event(ScrollUp, start), + scroll_event(ScrollUp, start + Duration::from_millis(4)), + scroll_event(ScrollUp, start + Duration::from_millis(12)), + ] { + let routed = normalize_input_event(event); + let _ = app.handle_input_at_with_paste_provenance( + &routed.event, + routed.arrived_at, + routed.paste_provenance, + ); + } + let spaced = app + .scroll_state + .debug_snapshot(&app.scroll_config, start + Duration::from_millis(12)); + assert_eq!( + spaced.stream.expect("up stream active").avg_interval_ms, + Some(8.0) + ); + + let routed = + normalize_input_event(scroll_event(ScrollDown, start + Duration::from_millis(40))); + let _ = app.handle_input_at_with_paste_provenance( + &routed.event, + routed.arrived_at, + routed.paste_provenance, + ); + + let snapshot = app + .scroll_state + .debug_snapshot(&app.scroll_config, start + Duration::from_millis(40)); + let stream = snapshot.stream.expect("reversal starts a new stream"); + assert_eq!(snapshot.last_stream.expect("up stream finalized").events, 3); + assert_eq!(stream.events, 1); + assert_eq!(stream.gap_remaining_ms, 80); } #[cfg(target_os = "linux")] @@ -3592,12 +4258,15 @@ mod tests { ..Default::default() }); - let normalized = normalize_input_event(mouse_event( + let input = mouse_event( MouseEventKind::Down(MouseButton::Middle), KeyModifiers::NONE, - )); + ); + let arrived_at = input.arrived_at; + let normalized = normalize_input_event(input); assert_eq!(normalized.event, Event::Paste("PRIMARY\nexact".to_owned())); + assert_eq!(normalized.arrived_at, arrived_at); assert_eq!(normalized.paste_provenance, PasteProvenance::X11Primary); assert_eq!(crate::clipboard::primary_selection_read_call_count(), 1); crate::clipboard::clear_clipboard_probe_hook(); @@ -3615,18 +4284,18 @@ mod tests { let release = mouse_event(MouseEventKind::Up(MouseButton::Middle), KeyModifiers::NONE); let normalized = normalize_input_event(release.clone()); - assert_eq!(normalized.event, release); + assert_eq!(normalized.event, release.event); assert_eq!(normalized.paste_provenance, PasteProvenance::Terminal); let modified = mouse_event( MouseEventKind::Down(MouseButton::Middle), KeyModifiers::SHIFT, ); let normalized = normalize_input_event(modified.clone()); - assert_eq!(normalized.event, modified); + assert_eq!(normalized.event, modified.event); assert_eq!(normalized.paste_provenance, PasteProvenance::Terminal); let left = mouse_event(MouseEventKind::Down(MouseButton::Left), KeyModifiers::NONE); let normalized = normalize_input_event(left.clone()); - assert_eq!(normalized.event, left); + assert_eq!(normalized.event, left.event); assert_eq!(normalized.paste_provenance, PasteProvenance::Terminal); assert_eq!(crate::clipboard::primary_selection_read_call_count(), 0); crate::clipboard::clear_clipboard_probe_hook(); @@ -3647,7 +4316,7 @@ mod tests { ); let normalized = normalize_input_event(middle.clone()); - assert_eq!(normalized.event, middle); + assert_eq!(normalized.event, middle.event); assert_eq!(normalized.paste_provenance, PasteProvenance::Terminal); assert_eq!(crate::clipboard::primary_selection_read_call_count(), 1); crate::clipboard::clear_clipboard_probe_hook(); @@ -3664,7 +4333,7 @@ mod tests { ]; let result = coalesce_rapid_keys(events); assert_eq!(result.len(), 1); - assert_eq!(result[0], Event::Paste("ab\ncd".to_string())); + assert_eq!(result[0].event, Event::Paste("ab\ncd".to_string())); } #[test] @@ -3682,7 +4351,7 @@ mod tests { ]; let result = coalesce_rapid_keys(events); assert_eq!(result.len(), 1); - assert_eq!(result[0], Event::Paste("ab\nc".to_string())); + assert_eq!(result[0].event, Event::Paste("ab\nc".to_string())); } #[test] @@ -3697,7 +4366,7 @@ mod tests { ]; let result = coalesce_rapid_keys(events); assert_eq!(result.len(), 1); - assert_eq!(result[0], Event::Paste("Hi\nBye".to_string())); + assert_eq!(result[0].event, Event::Paste("Hi\nBye".to_string())); } #[test] @@ -3705,8 +4374,8 @@ mod tests { let events = vec![press(KeyCode::Char('a')), press(KeyCode::Enter)]; let result = coalesce_rapid_keys(events); assert_eq!(result.len(), 2); - assert!(matches!(&result[0], Event::Key(ke) if ke.code == KeyCode::Char('a'))); - assert!(matches!(&result[1], Event::Key(ke) if ke.code == KeyCode::Enter)); + assert!(matches!(&result[0].event, Event::Key(ke) if ke.code == KeyCode::Char('a'))); + assert!(matches!(&result[1].event, Event::Key(ke) if ke.code == KeyCode::Enter)); } #[test] @@ -3722,7 +4391,7 @@ mod tests { let result = coalesce_rapid_keys(events); assert_eq!(result.len(), 5); for ev in &result { - assert!(matches!(ev, Event::Key(_))); + assert!(matches!(&ev.event, Event::Key(_))); } } @@ -3742,17 +4411,17 @@ mod tests { #[test] fn coalesce_preserves_non_key_events() { let events = vec![ - Event::Resize(80, 24), + TimedInputEvent::now(Event::Resize(80, 24)), press(KeyCode::Char('a')), press(KeyCode::Enter), press(KeyCode::Char('b')), - Event::Resize(100, 30), + TimedInputEvent::now(Event::Resize(100, 30)), ]; let result = coalesce_rapid_keys(events); assert_eq!(result.len(), 3); - assert!(matches!(&result[0], Event::Resize(80, 24))); - assert_eq!(result[1], Event::Paste("a\nb".to_string())); - assert!(matches!(&result[2], Event::Resize(100, 30))); + assert!(matches!(&result[0].event, Event::Resize(80, 24))); + assert_eq!(result[1].event, Event::Paste("a\nb".to_string())); + assert!(matches!(&result[2].event, Event::Resize(100, 30))); } #[test] @@ -3780,7 +4449,7 @@ mod tests { ]; let result = coalesce_rapid_keys(events); assert_eq!(result.len(), 1); - assert_eq!(result[0], Event::Paste("if\n\tx".to_string())); + assert_eq!(result[0].event, Event::Paste("if\n\tx".to_string())); } #[test] @@ -3792,7 +4461,7 @@ mod tests { ]; let result = coalesce_rapid_keys(events); assert_eq!(result.len(), 1); - assert_eq!(result[0], Event::Paste("a\nb".to_string())); + assert_eq!(result[0].event, Event::Paste("a\nb".to_string())); } #[test] @@ -3806,21 +4475,21 @@ mod tests { ]; let result = coalesce_rapid_keys(events); assert_eq!(result.len(), 4); - assert!(matches!(&result[3], Event::Key(ke) if ke.code == KeyCode::Enter)); + assert!(matches!(&result[3].event, Event::Key(ke) if ke.code == KeyCode::Enter)); } #[test] fn fragmented_paste_merged_with_keys() { // Event::Paste mixed with key events — merge into one paste. let events = vec![ - Event::Paste("real paste".into()), + TimedInputEvent::now(Event::Paste("real paste".into())), press(KeyCode::Char('a')), press(KeyCode::Enter), press(KeyCode::Char('b')), ]; let result = coalesce_rapid_keys(events); assert_eq!(result.len(), 1); - assert_eq!(result[0], Event::Paste("real pastea\nb".to_string())); + assert_eq!(result[0].event, Event::Paste("real pastea\nb".to_string())); } #[test] @@ -3828,7 +4497,7 @@ mod tests { let events = vec![press(KeyCode::Enter)]; let result = coalesce_rapid_keys(events); assert_eq!(result.len(), 1); - assert!(matches!(&result[0], Event::Key(_))); + assert!(matches!(&result[0].event, Event::Key(_))); } #[test] @@ -3857,7 +4526,7 @@ mod tests { ]; let result = coalesce_rapid_keys(events); assert_eq!(result.len(), 1); - assert_eq!(result[0], Event::Paste("foo\nbar\nbaz".to_string())); + assert_eq!(result[0].event, Event::Paste("foo\nbar\nbaz".to_string())); } #[test] @@ -3875,7 +4544,7 @@ mod tests { ]; let result = coalesce_rapid_keys(events); assert_eq!(result.len(), 1); - assert_eq!(result[0], Event::Paste("a\nb\nc\nd\n".to_string())); + assert_eq!(result[0].event, Event::Paste("a\nb\nc\nd\n".to_string())); } // ── should_extend_for_paste tests ─────────────────────────────── @@ -3895,7 +4564,7 @@ mod tests { #[test] fn extend_not_triggered_with_bracketed_paste() { let events = vec![ - Event::Paste("hello".into()), + TimedInputEvent::now(Event::Paste("hello".into())), press(KeyCode::Char('a')), press(KeyCode::Enter), press(KeyCode::Char('b')), @@ -3905,7 +4574,7 @@ mod tests { #[test] fn extend_not_triggered_with_only_non_pasteable() { - let events = vec![Event::Resize(80, 24)]; + let events = vec![TimedInputEvent::now(Event::Resize(80, 24))]; assert!(!should_extend_for_paste(&events)); } @@ -3915,59 +4584,59 @@ mod tests { fn merge_paste_and_key_fragments() { // Fragmented bracketed paste: Event::Paste + loose key events. let events = vec![ - Event::Paste("hello\nwor".into()), + TimedInputEvent::now(Event::Paste("hello\nwor".into())), press(KeyCode::Char('l')), press(KeyCode::Char('d')), ]; let result = coalesce_rapid_keys(events); assert_eq!(result.len(), 1); - assert_eq!(result[0], Event::Paste("hello\nworld".to_string())); + assert_eq!(result[0].event, Event::Paste("hello\nworld".to_string())); } #[test] fn merge_multiple_paste_fragments() { let events = vec![ - Event::Paste("aa\n".into()), - Event::Paste("bb\n".into()), + TimedInputEvent::now(Event::Paste("aa\n".into())), + TimedInputEvent::now(Event::Paste("bb\n".into())), press(KeyCode::Char('c')), ]; let result = coalesce_rapid_keys(events); assert_eq!(result.len(), 1); - assert_eq!(result[0], Event::Paste("aa\nbb\nc".to_string())); + assert_eq!(result[0].event, Event::Paste("aa\nbb\nc".to_string())); } #[test] fn merge_preserves_non_key_events() { let events = vec![ - Event::Paste("hello".into()), - Event::Resize(80, 24), + TimedInputEvent::now(Event::Paste("hello".into())), + TimedInputEvent::now(Event::Resize(80, 24)), press(KeyCode::Char('x')), ]; let result = coalesce_rapid_keys(events); assert_eq!(result.len(), 3); - assert_eq!(result[0], Event::Paste("hello".to_string())); - assert!(matches!(result[1], Event::Resize(80, 24))); - assert_eq!(result[2], Event::Paste("x".to_string())); + assert_eq!(result[0].event, Event::Paste("hello".to_string())); + assert!(matches!(result[1].event, Event::Resize(80, 24))); + assert_eq!(result[2].event, Event::Paste("x".to_string())); } #[test] fn merge_skips_release_events() { let events = vec![ - Event::Paste("ab".into()), + TimedInputEvent::now(Event::Paste("ab".into())), press(KeyCode::Char('c')), release(KeyCode::Char('c')), ]; let result = coalesce_rapid_keys(events); assert_eq!(result.len(), 1); - assert_eq!(result[0], Event::Paste("abc".to_string())); + assert_eq!(result[0].event, Event::Paste("abc".to_string())); } #[test] fn pure_paste_no_merge_needed() { - let events = vec![Event::Paste("hello\nworld".into())]; + let events = vec![TimedInputEvent::now(Event::Paste("hello\nworld".into()))]; let result = coalesce_rapid_keys(events); assert_eq!(result.len(), 1); - assert_eq!(result[0], Event::Paste("hello\nworld".to_string())); + assert_eq!(result[0].event, Event::Paste("hello\nworld".to_string())); } // ── is_pasteable_key_event filtering tests ───────────────────────── @@ -3999,8 +4668,8 @@ mod tests { #[test] fn pasteable_rejects_release_events() { - assert!(!is_pasteable_key_event(&release(KeyCode::Char('a')))); - assert!(!is_pasteable_key_event(&release(KeyCode::Enter))); + assert!(!is_pasteable_key_event(&release(KeyCode::Char('a')).event)); + assert!(!is_pasteable_key_event(&release(KeyCode::Enter).event)); } #[test] @@ -4021,23 +4690,25 @@ mod tests { #[test] fn pasteable_accepts_valid_key_presses() { - assert!(is_pasteable_key_event(&press(KeyCode::Char('a')))); - assert!(is_pasteable_key_event(&press_shift(KeyCode::Char('A')))); - assert!(is_pasteable_key_event(&press(KeyCode::Enter))); - assert!(is_pasteable_key_event(&press(KeyCode::Tab))); + assert!(is_pasteable_key_event(&press(KeyCode::Char('a')).event)); + assert!(is_pasteable_key_event( + &press_shift(KeyCode::Char('A')).event + )); + assert!(is_pasteable_key_event(&press(KeyCode::Enter).event)); + assert!(is_pasteable_key_event(&press(KeyCode::Tab).event)); } #[test] fn extend_not_triggered_with_only_mouse_and_focus() { use crossterm::event::{MouseEvent, MouseEventKind}; let events = vec![ - Event::Mouse(MouseEvent { + TimedInputEvent::now(Event::Mouse(MouseEvent { kind: MouseEventKind::Moved, column: 10, row: 5, modifiers: KeyModifiers::NONE, - }), - Event::FocusGained, + })), + TimedInputEvent::now(Event::FocusGained), ]; assert!(!should_extend_for_paste(&events)); } @@ -4046,14 +4717,14 @@ mod tests { fn extend_triggered_only_when_key_present_in_mixed_batch() { use crossterm::event::{MouseEvent, MouseEventKind}; let events = vec![ - Event::Mouse(MouseEvent { + TimedInputEvent::now(Event::Mouse(MouseEvent { kind: MouseEventKind::Moved, column: 0, row: 0, modifiers: KeyModifiers::NONE, - }), + })), press(KeyCode::Char('a')), - Event::FocusLost, + TimedInputEvent::now(Event::FocusLost), ]; assert!(should_extend_for_paste(&events)); } @@ -4066,25 +4737,25 @@ mod tests { use crossterm::event::{MouseEvent, MouseEventKind}; let events = vec![ press(KeyCode::Char('a')), - Event::Mouse(MouseEvent { + TimedInputEvent::now(Event::Mouse(MouseEvent { kind: MouseEventKind::Moved, column: 10, row: 5, modifiers: KeyModifiers::NONE, - }), - Event::Mouse(MouseEvent { + })), + TimedInputEvent::now(Event::Mouse(MouseEvent { kind: MouseEventKind::Moved, column: 11, row: 5, modifiers: KeyModifiers::NONE, - }), + })), ]; let result = coalesce_rapid_keys(events); // Below coalesce threshold, all events pass through unchanged. assert_eq!(result.len(), 3); - assert!(matches!(&result[0], Event::Key(ke) if ke.code == KeyCode::Char('a'))); - assert!(matches!(&result[1], Event::Mouse(_))); - assert!(matches!(&result[2], Event::Mouse(_))); + assert!(matches!(&result[0].event, Event::Key(ke) if ke.code == KeyCode::Char('a'))); + assert!(matches!(&result[1].event, Event::Mouse(_))); + assert!(matches!(&result[2].event, Event::Mouse(_))); } #[test] @@ -4096,12 +4767,12 @@ mod tests { press(KeyCode::Char('a')), press(KeyCode::Char('b')), press(KeyCode::Enter), - Event::Mouse(MouseEvent { + TimedInputEvent::now(Event::Mouse(MouseEvent { kind: MouseEventKind::Moved, column: 5, row: 3, modifiers: KeyModifiers::NONE, - }), + })), press(KeyCode::Char('c')), ]; let result = coalesce_rapid_keys(events); @@ -4117,7 +4788,7 @@ mod tests { // (other platforms reliably get bracketed paste for drag-drop). #[cfg(target_os = "windows")] - fn press_run(text: &str) -> Vec { + fn press_run(text: &str) -> Vec { text.chars().map(|c| press(KeyCode::Char(c))).collect() } @@ -4137,7 +4808,7 @@ mod tests { ] { let result = coalesce_rapid_keys(press_run(input)); assert_eq!(result.len(), 1, "input {input:?} should coalesce"); - assert_eq!(result[0], Event::Paste(input.to_string())); + assert_eq!(result[0].event, Event::Paste(input.to_string())); } } @@ -4150,13 +4821,13 @@ mod tests { assert!( coalesce_rapid_keys(press_run(short)) .iter() - .all(|e| matches!(e, Event::Key(_))) + .all(|e| matches!(e.event, Event::Key(_))) ); let prose = "helloworld"; // 10 chars, no path anchor assert!( coalesce_rapid_keys(press_run(prose)) .iter() - .all(|e| matches!(e, Event::Key(_))) + .all(|e| matches!(e.event, Event::Key(_))) ); } @@ -4171,7 +4842,7 @@ mod tests { events.extend(press_run(r"\foo.png")); let result = coalesce_rapid_keys(events); assert_eq!(result.len(), 1); - assert_eq!(result[0], Event::Paste(r"C:\foo.png".to_string())); + assert_eq!(result[0].event, Event::Paste(r"C:\foo.png".to_string())); } // ── make_run_result exit info ──────────────────────────────────────── diff --git a/crates/codegen/xai-grok-pager/src/app/inline_edit.rs b/crates/codegen/xai-grok-pager/src/app/inline_edit.rs index 79ee06d..4a40f8b 100644 --- a/crates/codegen/xai-grok-pager/src/app/inline_edit.rs +++ b/crates/codegen/xai-grok-pager/src/app/inline_edit.rs @@ -23,6 +23,15 @@ use super::actions::Action; use super::agent_view::AgentView; use super::app_view::InputOutcome; +/// Master switch for the in-place prompt edit feature. +/// +/// Disabled while we resolve an unsolved scroll jump on enter (see +/// `x/agottumukkala/inline-edit-scroll-jank.md`). Gates the user entry points +/// (Enter in `agent_view/panes.rs`, double-click in `agent_view/selection.rs`). +/// Everything else stays wired and unit-tested, so flipping this to `true` +/// re-enables the feature in one place. +pub(crate) const INLINE_EDIT_ENABLED: bool = false; + /// State of an in-place edit of a previous user prompt. pub struct InlineEditState { /// Stable id of the edited entry (indices shift; re-resolve per use). diff --git a/crates/codegen/xai-grok-pager/src/app/mod.rs b/crates/codegen/xai-grok-pager/src/app/mod.rs index 21bf391..c933802 100644 --- a/crates/codegen/xai-grok-pager/src/app/mod.rs +++ b/crates/codegen/xai-grok-pager/src/app/mod.rs @@ -27,6 +27,12 @@ mod dispatch; /// Display-refresh probe + motion cadence + terminal telemetry at startup. mod display_refresh_startup; mod effects; +pub mod roster; +pub mod session_startup; +pub mod status_blocks; +pub mod subagent; +pub mod subscription; +pub(crate) use effects::sanitize_user_error; mod event_loop; mod foreign_sessions; mod inline_edit; @@ -35,13 +41,8 @@ mod leader_cluster; mod modals; mod mouse; mod queue_edit; -pub mod roster; pub(crate) mod screen_mode_relaunch; -pub mod session_startup; -mod signal_handler; -pub mod status_blocks; -pub mod subagent; -pub mod subscription; +pub mod signal_handler; mod turn_completion; mod xt_filter; pub(crate) use crate::terminal::kitty_flags_pushed; @@ -674,7 +675,8 @@ pub async fn run( ); engage_startup_theme(screen_mode); let minimal_live_rows = config_watcher.current().minimal_live_rows; - let (frame_tx, writer_sync, writer_thread) = crate::render::draw::spawn_writer_thread(); + let (frame_tx, writer_sync, writer_event_rx, writer_thread) = + crate::render::draw::spawn_writer_thread(); let cursor_blink = event_loop::load_initial_ui_config().cursor_blink; let (mut terminal, screen_mode) = init_terminal( screen_mode, @@ -718,12 +720,29 @@ pub async fn run( term_state, materialized, bg_update_rx, + writer_event_rx, ) .await; crate::unified_log::flush_blocking().await; - let _ = restore_terminal(terminal, writer_thread, screen_mode); + let restore_result = restore_terminal(terminal, writer_thread, screen_mode); cancel.cancel(); xai_tty_utils::global_process_scope().kill_all(); + if let Err(cleanup_error) = restore_result { + match &result { + Ok(_) => { + tracing::warn!( + error = % cleanup_error, + "terminal cleanup failed after successful event loop" + ) + } + Err(run_error) => { + tracing::warn!( + error = % cleanup_error, run_error = % run_error, + "terminal cleanup also failed" + ) + } + } + } match result { Ok(run_result) => { if run_result.quit_for_update { @@ -750,7 +769,7 @@ pub async fn run( } Ok(false) } - Err(e) => Err(e), + Err(run_error) => Err(run_error), } } /// Plain-quit "Resume this session with…" lines (after terminal restore). @@ -1048,7 +1067,7 @@ fn init_terminal( mode: ScreenMode, minimal_live_rows: u16, clear_main_screen: bool, - frame_tx: std::sync::mpsc::Sender>, + frame_tx: crate::render::draw::WriterSender, writer_sync: crate::render::draw::WriterSync, cursor_blink: Option, ) -> io::Result<(PagerTerminal, ScreenMode)> { @@ -1146,8 +1165,10 @@ fn init_terminal( } crate::terminal::set_kitty_flags_pushed(use_keyboard_enhancement); if mode.is_fullscreen() { - let backend = - CrosstermBackend::new(crate::render::draw::TermWriter::new(frame_tx, writer_sync)); + let backend = CrosstermBackend::new( + crate::render::draw::TermWriter::new(frame_tx, writer_sync) + .map_err(io::Error::other)?, + ); Ok(( xai_ratatui_inline::Terminal::new(backend)?, ScreenMode::Fullscreen, @@ -1159,10 +1180,10 @@ fn init_terminal( } else { rows }; - let probe_backend = CrosstermBackend::new(crate::render::draw::TermWriter::new( - frame_tx.clone(), - writer_sync.clone(), - )); + let probe_backend = CrosstermBackend::new( + crate::render::draw::TermWriter::new(frame_tx.clone(), writer_sync.clone()) + .map_err(io::Error::other)?, + ); if let Ok(term) = xai_ratatui_inline::Terminal::with_options( probe_backend, ratatui::TerminalOptions { @@ -1186,10 +1207,10 @@ fn init_terminal( execute!(stderr, event::EnableMouseCapture) })?; MOUSE_CAPTURE_ENABLED.store(true, Ordering::Release); - let retry_backend = CrosstermBackend::new(crate::render::draw::TermWriter::new( - frame_tx.clone(), - writer_sync.clone(), - )); + let retry_backend = CrosstermBackend::new( + crate::render::draw::TermWriter::new(frame_tx.clone(), writer_sync.clone()) + .map_err(io::Error::other)?, + ); if let Ok(term) = xai_ratatui_inline::Terminal::with_options( retry_backend, ratatui::TerminalOptions { @@ -1208,8 +1229,10 @@ fn init_terminal( cursor::MoveTo(0, 0), ) })?; - let backend = - CrosstermBackend::new(crate::render::draw::TermWriter::new(frame_tx, writer_sync)); + let backend = CrosstermBackend::new( + crate::render::draw::TermWriter::new(frame_tx, writer_sync) + .map_err(io::Error::other)?, + ); let term = xai_ratatui_inline::Terminal::with_options( backend, ratatui::TerminalOptions { @@ -1234,9 +1257,9 @@ fn init_terminal( fn drain_writer_thread_before_teardown( terminal: PagerTerminal, writer_thread: crate::render::draw::WriterThread, -) { +) -> io::Result<()> { drop(terminal); - writer_thread.join(); + writer_thread.join() } /// Inline teardown escape sequences in the canonical order, shared by /// `restore_terminal` and `set_panic_hook` so the on-wire byte order is @@ -1297,16 +1320,18 @@ fn emit_terminal_teardown_sequences(mode: ScreenMode, inline_cursor_row: Option< #[cfg(windows)] win_native_selection::restore_stdin_mode(); } -/// Consumes `terminal` and `writer_thread`: queues a final clear in -/// fullscreen mode, drains the writer thread, then emits the inline -/// teardown sequences. The drain ordering guarantees no late frame can -/// land after `LeaveAlternateScreen`. -fn restore_terminal( +/// Consumes `terminal` and `writer_thread`: queues a final fullscreen clear, +/// drains every accepted frame, then emits teardown sequences. Teardown still +/// runs if draining fails, so terminal state is restored before returning that +/// error. Draining first prevents a late frame after `LeaveAlternateScreen`. +fn restore_terminal_with( mut terminal: PagerTerminal, writer_thread: crate::render::draw::WriterThread, mode: ScreenMode, + drain: impl FnOnce(PagerTerminal, crate::render::draw::WriterThread) -> io::Result<()>, + teardown: impl FnOnce(ScreenMode, Option), ) -> io::Result<()> { - if mode.is_fullscreen() { + if mode.is_fullscreen() && !writer_thread.writer_sync().failed() { let _ = terminal.clear(); { use std::io::Write; @@ -1314,14 +1339,27 @@ fn restore_terminal( } } let inline_cursor_row = (!mode.is_fullscreen()).then(|| terminal.viewport_area().bottom()); - drain_writer_thread_before_teardown(terminal, writer_thread); - emit_terminal_teardown_sequences(mode, inline_cursor_row); + let drain_result = drain(terminal, writer_thread); + teardown(mode, inline_cursor_row); drain_pending_events_with_timeout(std::time::Duration::from_millis(10)); let _ = terminal::disable_raw_mode(); signal_handler::mark_restored(); xai_crash_handler::disable_terminal_escape_restore(); xai_tty_utils::restore_native_stderr(); - Ok(()) + drain_result +} +fn restore_terminal( + terminal: PagerTerminal, + writer_thread: crate::render::draw::WriterThread, + mode: ScreenMode, +) -> io::Result<()> { + restore_terminal_with( + terminal, + writer_thread, + mode, + drain_writer_thread_before_teardown, + emit_terminal_teardown_sequences, + ) } pub(crate) fn set_terminal_title(title: &str) { let full = terminal_title_string(title); @@ -1358,6 +1396,39 @@ fn set_panic_hook(mode: ScreenMode) { #[cfg(test)] mod tests { use super::*; + #[test] + fn restore_runs_teardown_even_when_writer_failed() { + use ratatui::{TerminalOptions, Viewport}; + let (tx, _rx) = std::sync::mpsc::channel::(); + let sync = crate::render::draw::WriterSync::new(); + let backend = CrosstermBackend::new( + crate::render::draw::TermWriter::new(tx, sync).expect("single test writer"), + ); + let terminal = xai_ratatui_inline::Terminal::with_options( + backend, + TerminalOptions { + viewport: Viewport::Fixed(ratatui::layout::Rect::new(0, 0, 80, 24)), + }, + ) + .expect("test terminal"); + let (writer_tx, _writer_sync, _events, writer_thread) = + crate::render::draw::spawn_writer_thread(); + drop(writer_tx); + let teardown_called = std::cell::Cell::new(false); + let result = restore_terminal_with( + terminal, + writer_thread, + ScreenMode::Inline, + |terminal, writer_thread| { + drop(terminal); + drop(writer_thread); + Err(io::Error::other("injected drain failure")) + }, + |_, _| teardown_called.set(true), + ); + assert!(result.is_err()); + assert!(teardown_called.get()); + } /// `[ui].cursor_blink` tri-state → startup cursor policy; the `None` /// default must be Inherit (emit nothing). #[test] diff --git a/crates/codegen/xai-grok-pager/src/app/modals.rs b/crates/codegen/xai-grok-pager/src/app/modals.rs index d87dcd6..6c20b10 100644 --- a/crates/codegen/xai-grok-pager/src/app/modals.rs +++ b/crates/codegen/xai-grok-pager/src/app/modals.rs @@ -116,16 +116,16 @@ impl AgentView { // Extract window state for handle_modal_key. let (window, query_empty, esc_clears) = match modal { ActiveModal::CommandPalette { window, state, .. } => { - (window, state.query.is_empty(), true) + (window, state.query().is_empty(), true) } ActiveModal::ArgPicker { window, state, .. } => { - (window, state.query.is_empty(), false) + (window, state.query().is_empty(), false) } ActiveModal::SessionPicker { window, state, .. } => { - (window, state.query.is_empty(), false) + (window, state.query().is_empty(), false) } ActiveModal::DocPicker { window, state, .. } => { - (window, state.query.is_empty(), true) + (window, state.query().is_empty(), true) } _ => unreachable!(), }; @@ -145,14 +145,12 @@ impl AgentView { if esc_clears && !query_empty { match modal { ActiveModal::CommandPalette { state, .. } => { - state.query.clear(); - state.query_cursor = 0; + state.set_query(""); state.selected = 0; state.scroll_offset = None; } ActiveModal::DocPicker { state, .. } => { - state.query.clear(); - state.query_cursor = 0; + state.set_query(""); state.selected = 0; state.scroll_offset = None; } @@ -294,10 +292,9 @@ impl AgentView { } = modal { use crate::views::shortcuts_help::{self, ShortcutsHelpOutcome}; - let searching = state.search_active || !state.query.is_empty(); + let searching = state.search_active || !state.query().is_empty(); if mode.is_browse() && searching && key.code == KeyCode::Esc { - state.query.clear(); - state.query_cursor = 0; + state.set_query(""); state.search_active = false; state.selected = 0; return InputOutcome::Changed; @@ -393,7 +390,7 @@ impl AgentView { // Sub-mode short-circuit: FilterFocused, PickingEnum, PickingGroup, // and EditingValue own their own Esc/keystroke semantics. if matches!( - state.mode, + state.mode(), crate::views::settings_modal::SettingsModalMode::FilterFocused | crate::views::settings_modal::SettingsModalMode::PickingEnum { .. } | crate::views::settings_modal::SettingsModalMode::PickingGroup { .. } @@ -478,6 +475,51 @@ impl AgentView { } } + pub(super) fn handle_modal_paste(&mut self, text: &str) -> InputOutcome { + use crate::views::modal::ActiveModal; + + let event = crossterm::event::Event::Paste(text.to_owned()); + if matches!(self.active_modal, Some(ActiveModal::DocPicker { .. })) { + return self.handle_doc_input(&event); + } + if matches!( + self.active_modal, + Some( + ActiveModal::CommandPalette { .. } + | ActiveModal::ArgPicker { .. } + | ActiveModal::SessionPicker { .. } + ) + ) { + return self.handle_palette_or_arg_input(&event); + } + + if let Some(ActiveModal::ShortcutsHelp { state, mode, .. }) = self.active_modal.as_mut() { + return match crate::views::shortcuts_help::handle_paste(text, state, mode) { + crate::views::shortcuts_help::ShortcutsHelpOutcome::Changed => { + InputOutcome::Changed + } + _ => InputOutcome::Unchanged, + }; + } + if let Some(ActiveModal::MemoryBrowser { state }) = self.active_modal.as_mut() { + return crate::views::memory_modal::handle_memory_paste(state, text); + } + let settings_outcome = match self.active_modal.as_mut() { + Some(ActiveModal::Settings { state }) => Some( + crate::views::settings_modal::handle_settings_paste(state, text), + ), + _ => None, + }; + if let Some(outcome) = settings_outcome { + return apply_settings_outcome(self, outcome); + } + if self.active_modal.is_some() { + InputOutcome::Changed + } else { + InputOutcome::Unchanged + } + } + /// Arg picker input (separate from command palette to avoid borrow conflicts /// when stepping back from the model effort phase via slash registry + session). fn handle_arg_picker_input(&mut self, ev: &crossterm::event::Event) -> InputOutcome { @@ -532,7 +574,8 @@ impl AgentView { None => return InputOutcome::Changed, }, PickerOutcome::Closed => ArgPickerStep::Closed, - PickerOutcome::Changed => ArgPickerStep::FilterChanged, + PickerOutcome::QueryChanged => ArgPickerStep::FilterChanged, + PickerOutcome::Changed => return InputOutcome::Changed, PickerOutcome::Unchanged => return InputOutcome::Unchanged, _ => return InputOutcome::Changed, } @@ -547,7 +590,7 @@ impl AgentView { .. }) = self.active_modal.as_mut() { - let q = state.query.to_lowercase(); + let q = state.query().to_lowercase(); *items = original_items .iter() .filter(|item| { @@ -635,9 +678,10 @@ impl AgentView { ActiveModal::CommandPalette { entries: _, state, .. } => { - // Build filtered entries for count and non-selectable indices. - let filtered = - crate::views::modal::filter_palette_entries(&state.query, self.sharing_enabled); + let filtered = crate::views::modal::filter_palette_entries( + state.query(), + self.sharing_enabled, + ); let non_sel: Vec = filtered .iter() .map(|e| matches!(e.command, PaletteCommand::SectionHeader(_))) @@ -842,20 +886,21 @@ impl AgentView { self.active_modal = None; InputOutcome::Changed } - PickerOutcome::Changed => { + PickerOutcome::QueryChanged => { // Re-filter entries based on updated query. let sharing_enabled = self.sharing_enabled; if let Some(ActiveModal::CommandPalette { entries, state, .. }) = self.active_modal.as_mut() { *entries = crate::views::modal::filter_palette_entries( - &state.query, + state.query(), sharing_enabled, ); state.selected = state.selected.min(entries.len().saturating_sub(1)); } InputOutcome::Changed } + PickerOutcome::Changed => InputOutcome::Changed, PickerOutcome::Unchanged => InputOutcome::Unchanged, _ => InputOutcome::Changed, } @@ -874,11 +919,11 @@ impl AgentView { .. } => { use crate::views::session_picker::{ - CONTENT_EXPAND_OFFSET, PickerItem, build_entry_map, effective_filter_query, + CONTENT_EXPAND_OFFSET, PickerItem, SessionPickerWorktreeSelection, + build_entry_map, effective_filter_query, session_picker_worktree_selection, + sync_session_picker_query_expansion, }; - let query_before = state.query.clone(); - // Build grouped mapping using shared helper (now with content). // Pin the current session's repo group using the live agent cwd. let current_repo = crate::views::session_picker::repo_name_from_cwd( @@ -887,7 +932,7 @@ impl AgentView { let entry_map = build_entry_map( entries.as_deref(), content_results.as_deref(), - effective_filter_query(&state.query, entries_query.as_deref()), + effective_filter_query(state.query(), entries_query.as_deref()), true, *content_loading, *source_filter, @@ -974,6 +1019,29 @@ impl AgentView { } } + if let crossterm::event::Event::Key(key) = ev + && let Some(selection) = session_picker_worktree_selection( + key, + state, + &entry_map, + &non_sel, + entries.as_deref(), + content_results.as_deref(), + ) + { + return InputOutcome::Action(match selection { + SessionPickerWorktreeSelection::Fuzzy(original_index) => { + Action::PickSessionInWorktree(original_index) + } + SessionPickerWorktreeSelection::Content { session_id, cwd } => { + Action::PickContentSessionInWorktree { session_id, cwd } + } + SessionPickerWorktreeSelection::Unavailable => { + return InputOutcome::Changed; + } + }); + } + match handle_picker_input(ev, state, entry_count, &config) { PickerOutcome::Selected(i) => { match entry_map.get(i).and_then(|e| e.as_ref()) { @@ -998,7 +1066,7 @@ impl AgentView { } } PickerOutcome::SubmitQuery => { - let query = state.query.trim().to_string(); + let query = state.query().trim().to_string(); if !query.is_empty() { self.active_modal = None; InputOutcome::Action(Action::LoadSession(query, None, false)) @@ -1089,50 +1157,27 @@ impl AgentView { InputOutcome::Changed } } - PickerOutcome::Changed => { - if state.query != query_before { - return InputOutcome::Action(Action::TriggerDeepSearch); - } - InputOutcome::Changed + PickerOutcome::QueryChanged => { + sync_session_picker_query_expansion( + entries.as_deref(), + content_results.as_deref(), + entries_query.as_deref(), + state, + true, + *content_loading, + *source_filter, + Some(current_repo.as_str()), + ); + InputOutcome::Action(Action::TriggerDeepSearch) } + PickerOutcome::Changed => InputOutcome::Changed, PickerOutcome::Unchanged => { if let crossterm::event::Event::Key(key) = ev && key.kind == KeyEventKind::Press + && crate::key!('/', CONTROL).matches(key) + && !state.query().trim().is_empty() { - if crate::key!('w', CONTROL).matches(key) && entry_count > 0 { - match entry_map.get(state.selected).and_then(|e| e.as_ref()) { - Some(PickerItem::Fuzzy { original_index }) => { - if let Some(entries) = entries.as_ref() - && let Some(entry) = entries.get(*original_index) - && !crate::app::foreign_sessions::is_foreign_picker_source( - &entry.source, - ) - { - return InputOutcome::Action( - Action::PickSessionInWorktree(*original_index), - ); - } - } - Some(PickerItem::Content { hit_index }) => { - if let Some(hits) = content_results.as_ref() - && let Some(hit) = hits.get(*hit_index) - { - return InputOutcome::Action( - Action::PickContentSessionInWorktree { - session_id: hit.session_id.clone(), - cwd: hit.cwd.clone(), - }, - ); - } - } - None => {} - } - } - if crate::key!('/', CONTROL).matches(key) - && !state.query.trim().is_empty() - { - return InputOutcome::Action(Action::ForceDeepSearch); - } + return InputOutcome::Action(Action::ForceDeepSearch); } InputOutcome::Unchanged } @@ -1189,10 +1234,10 @@ impl AgentView { }) = &mut self.active_modal { // Filter entries based on search query - let filtered: Vec<_> = if state.query.is_empty() { + let filtered: Vec<_> = if state.query().is_empty() { entries.iter().enumerate().collect() } else { - let q = state.query.to_lowercase(); + let q = state.query().to_lowercase(); entries .iter() .enumerate() @@ -1627,7 +1672,7 @@ impl AgentView { } = active_modal { // Command palette: ModalWindow chrome + picker content. - let filtered = modal::filter_palette_entries(&state.query, self.sharing_enabled); + let filtered = modal::filter_palette_entries(state.query(), self.sharing_enabled); let non_sel: Vec = filtered .iter() .map(|e| matches!(e.command, modal::PaletteCommand::SectionHeader(_))) @@ -1862,16 +1907,15 @@ impl AgentView { if let Some(mca) = mw::render_modal_window(buf, area, window, &modal_config, &theme) { let content_area = mca.content; - picker::render_search_bar( + picker::render_picker_search_bar( buf, content_area.x, content_area.y, content_area.width, &theme, - &state.query, + state, state.search_active, true, - state.query_cursor, Some(theme.bg_base), ); // Render filter indicator on the search bar row (hidden in @@ -1914,7 +1958,7 @@ impl AgentView { // the input handler's `build_entry_map` (which receives // the effective query) on row indices. let filter_query = crate::views::session_picker::effective_filter_query( - &state.query, + state.query(), entries_query.as_deref(), ); let entries_data = entries.as_deref().unwrap_or(&[]); @@ -2201,7 +2245,7 @@ impl AgentView { } let rows = shortcuts_help::CheatsheetRows::build( entries, - &state.query, + state.query(), *filter_active, collapsed_sections, ); @@ -2218,7 +2262,7 @@ impl AgentView { }; if let Some(mca) = mw::render_modal_window(buf, area, window, &modal_config, &theme) { - let searching = state.search_active || !state.query.is_empty(); + let searching = state.search_active || !state.query().is_empty(); picker::render_picker_in_modal_inner( buf, mca.content, @@ -2444,6 +2488,24 @@ mod session_picker_delete_tests { ); } + #[test] + fn ctrl_w_resumes_session_while_search_is_focused() { + let mut agent = make_agent(); + open_picker(&mut agent, vec![entry("s0")]); + if let Some(ActiveModal::SessionPicker { state, .. }) = agent.active_modal.as_mut() { + state.selected = 1; + state.search_active = true; + state.set_query("s"); + } + + let worktree = Event::Key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL)); + let outcome = agent.handle_palette_or_arg_input(&worktree); + assert!(matches!( + outcome, + InputOutcome::Action(Action::PickSessionInWorktree(0)) + )); + } + #[test] fn foreign_row_refuses_delete_detail_and_worktree_actions() { let mut agent = make_agent(); @@ -2497,7 +2559,7 @@ mod session_picker_delete_tests { .. }) = agent.active_modal.as_mut() { - state.query = "hit".into(); + state.set_query("hit"); *entries_query = Some("hit".into()); // A re-search of the stamped query may be in flight: with the // effective query empty, the input map appends NO "Searching…" @@ -2524,7 +2586,7 @@ mod session_picker_delete_tests { e.source = "conversation".into(); open_picker(&mut agent, vec![e]); if let Some(ActiveModal::SessionPicker { state, .. }) = agent.active_modal.as_mut() { - state.query = "hit".into(); + state.set_query("hit"); state.selected = 1; } let out = agent.handle_palette_or_arg_input(&key_code(KeyCode::Enter)); @@ -2607,12 +2669,13 @@ mod session_picker_delete_tests { #[cfg(test)] mod command_palette_vim_input_tests { + use crate::actions::ActionRegistry; use crate::app::agent_view::AgentView; use crate::app::agent_view::test_fixtures::make_agent; use crate::app::app_view::InputOutcome; use crate::views::modal::ActiveModal; use crate::views::picker::PickerState; - use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; // Open the command palette exactly as the Ctrl+P handler does: type-to-find // INPUT mode (`input_active`) over the full palette entries. @@ -2655,25 +2718,25 @@ mod command_palette_vim_input_tests { assert!(palette_state(&agent).search_active, "opens in input mode"); agent.handle_modal_key(&key('a')); let st = palette_state(&agent); - assert_eq!(st.query, "a", "input mode: a letter filters"); + assert_eq!(st.query(), "a", "input mode: a letter filters"); assert!(st.search_active); // First Esc clears the query via the modal chrome but stays in input. agent.handle_modal_key(&esc()); let st = palette_state(&agent); - assert!(st.query.is_empty(), "Esc clears the query"); + assert!(st.query().is_empty(), "Esc clears the query"); assert!(st.search_active, "still input after the first Esc"); // Second Esc (empty query) drops to NAV via the picker's vim Esc. agent.handle_modal_key(&esc()); let st = palette_state(&agent); assert!(!st.search_active, "second Esc drops to nav"); - assert!(st.query.is_empty()); + assert!(st.query().is_empty()); // NAV: a bare printable key must NOT type. let out = agent.handle_modal_key(&key('b')); let st = palette_state(&agent); - assert!(st.query.is_empty(), "nav: a bare letter does not filter"); + assert!(st.query().is_empty(), "nav: a bare letter does not filter"); assert!(!st.search_active); assert!( matches!(out, InputOutcome::Unchanged), @@ -2683,9 +2746,9 @@ mod command_palette_vim_input_tests { // `i` re-enters INPUT without typing; a letter then filters again. agent.handle_modal_key(&key('i')); assert!(palette_state(&agent).search_active, "i re-enters search"); - assert!(palette_state(&agent).query.is_empty(), "i does not type"); + assert!(palette_state(&agent).query().is_empty(), "i does not type"); agent.handle_modal_key(&key('c')); - assert_eq!(palette_state(&agent).query, "c", "typing filters again"); + assert_eq!(palette_state(&agent).query(), "c", "typing filters again"); // Reset the global vim pin so it can't leak to later tests (libtest reuses threads). crate::appearance::cache::set_vim_mode(false); } @@ -2705,7 +2768,7 @@ mod command_palette_vim_input_tests { agent.handle_modal_key(&key('/')); assert!(palette_state(&agent).search_active, "/ re-enters search"); - assert!(palette_state(&agent).query.is_empty(), "/ does not type"); + assert!(palette_state(&agent).query().is_empty(), "/ does not type"); // Reset the global vim pin so it can't leak to later tests (libtest reuses threads). crate::appearance::cache::set_vim_mode(false); } @@ -2720,23 +2783,54 @@ mod command_palette_vim_input_tests { agent.handle_modal_key(&key('a')); let st = palette_state(&agent); - assert_eq!(st.query, "a", "a letter filters"); + assert_eq!(st.query(), "a", "a letter filters"); assert!(st.search_active); // Esc clears the query (chrome) but never drops to a nav mode. agent.handle_modal_key(&esc()); assert!( - palette_state(&agent).query.is_empty(), + palette_state(&agent).query().is_empty(), "Esc clears the query" ); // A bare letter still types — no vim nav-mode suppression. agent.handle_modal_key(&key('b')); let st = palette_state(&agent); - assert_eq!(st.query, "b", "still type-to-filter (no nav mode)"); + assert_eq!(st.query(), "b", "still type-to-filter (no nav mode)"); assert!(st.search_active); } + #[test] + fn command_palette_bracketed_paste_targets_only_active_query() { + crate::appearance::cache::set_vim_mode(false); + let mut agent = make_agent(); + agent.prompt.set_text("hidden prompt"); + open_command_palette(&mut agent); + if let Some(ActiveModal::CommandPalette { state, .. }) = agent.active_modal.as_mut() { + state.set_query("ab"); + } + let registry = ActionRegistry::defaults(); + let _ = agent.handle_input( + &Event::Key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)), + ®istry, + ); + let outcome = agent.handle_input(&Event::Paste("中\r\n".to_owned()), ®istry); + assert!(matches!(outcome, InputOutcome::Changed)); + assert_eq!(palette_state(&agent).query(), "a中b"); + assert_eq!(agent.prompt.text(), "hidden prompt"); + + if let Some(ActiveModal::CommandPalette { state, .. }) = agent.active_modal.as_mut() { + state.set_query(""); + state.search_active = false; + } + crate::appearance::cache::set_vim_mode(true); + let outcome = agent.handle_input(&Event::Paste("ignored".to_owned()), ®istry); + assert!(matches!(outcome, InputOutcome::Unchanged)); + assert!(palette_state(&agent).query().is_empty()); + assert_eq!(agent.prompt.text(), "hidden prompt"); + crate::appearance::cache::set_vim_mode(false); + } + // Drives the REAL command-palette render seam (draw_active_modal → // picker::render_picker_in_modal → render_search_bar) // — the path the bug was on — and asserts the cursor tracks focus. @@ -2798,3 +2892,63 @@ mod command_palette_vim_input_tests { ); } } + +#[cfg(test)] +mod settings_memory_paste_routing_tests { + use std::sync::Arc; + + use crate::actions::ActionRegistry; + use crate::app::agent_view::test_fixtures::make_agent; + use crate::app::app_view::InputOutcome; + use crate::settings::{PagerLocalSnapshot, SettingsRegistry}; + use crate::views::memory_modal::{MemoryModalMode, MemoryModalState}; + use crate::views::modal::ActiveModal; + use crate::views::settings_modal::SettingsModalState; + use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; + use xai_grok_shell::agent::config::UiConfig; + + fn left() -> Event { + Event::Key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)) + } + + #[test] + fn settings_and_memory_paste_only_into_focused_filters() { + let registry = ActionRegistry::defaults(); + let mut agent = make_agent(); + agent.prompt.set_text("hidden prompt"); + + let mut settings = SettingsModalState::new( + Arc::new(SettingsRegistry::defaults()), + UiConfig::default(), + PagerLocalSnapshot::default(), + ); + settings.focus_filter(); + settings.set_query("ab"); + agent.active_modal = Some(ActiveModal::Settings { + state: Box::new(settings), + }); + let _ = agent.handle_input(&left(), ®istry); + let outcome = agent.handle_input(&Event::Paste("中\r\n".to_owned()), ®istry); + assert!(matches!(outcome, InputOutcome::Changed)); + let Some(ActiveModal::Settings { state }) = agent.active_modal.as_ref() else { + panic!("settings modal remains open"); + }; + assert_eq!(state.query(), "a中b"); + assert_eq!(agent.prompt.text(), "hidden prompt"); + + let mut memory = MemoryModalState::new(Vec::new()); + memory.mode = MemoryModalMode::FilterFocused; + agent.active_modal = Some(ActiveModal::MemoryBrowser { + state: Box::new(memory), + }); + let _ = agent.handle_input(&Event::Paste("ab".to_owned()), ®istry); + let _ = agent.handle_input(&left(), ®istry); + let outcome = agent.handle_input(&Event::Paste("中\r\n".to_owned()), ®istry); + assert!(matches!(outcome, InputOutcome::Changed)); + let Some(ActiveModal::MemoryBrowser { state }) = agent.active_modal.as_ref() else { + panic!("memory modal remains open"); + }; + assert_eq!(state.query(), "a中b"); + assert_eq!(agent.prompt.text(), "hidden prompt"); + } +} diff --git a/crates/codegen/xai-grok-pager/src/app/queue_edit.rs b/crates/codegen/xai-grok-pager/src/app/queue_edit.rs index c7d859c..b0564ba 100644 --- a/crates/codegen/xai-grok-pager/src/app/queue_edit.rs +++ b/crates/codegen/xai-grok-pager/src/app/queue_edit.rs @@ -537,7 +537,6 @@ mod tests { }; use crate::app::agent_view::{AgentPane, AgentView, PromptMode}; use crate::app::app_view::InputOutcome; - use crate::app::dispatch::maybe_drain_queue; use crate::scrollback::block::RenderBlock; use crate::views::modal::{ActiveModal, ModalConfirmation}; @@ -746,7 +745,11 @@ mod tests { let outcome = agent.handle_prompt_key_for_test(&enter_key()); assert!(matches!(outcome, InputOutcome::Action(Action::DrainQueue))); - let effects = maybe_drain_queue(&mut agent); + let mut app = crate::app::app_view::tests::test_app(); + let id = agent.session.id; + app.agents.insert(id, agent); + let effects = crate::app::dispatch::maybe_drain_queue_and_note_peek(&mut app, id); + let agent = app.agents.get(&id).unwrap(); match &agent.scrollback.get(0).unwrap().block { RenderBlock::UserPrompt(b) => { assert_eq!(b.text, "great /pr-workflow go"); @@ -984,11 +987,15 @@ mod tests { // Running server turn completed: its shared-queue row is gone, so the // local edited row is the next turn (server-owns-next-turn gate clears). agent.shared_queue.clear(); - let effects = maybe_drain_queue(&mut agent); + let mut app = crate::app::app_view::tests::test_app(); + let id = agent.session.id; + app.agents.insert(id, agent); + let effects = crate::app::dispatch::maybe_drain_queue_and_note_peek(&mut app, id); assert!(matches!( effects.as_slice(), [Effect::SendPromptBlocks { .. }] )); + let agent = app.agents.get(&id).unwrap(); let in_flight = agent.session.in_flight_prompt.as_ref().unwrap(); assert_eq!(in_flight.images.len(), 2); assert_eq!(in_flight.chip_elements.len(), 2); diff --git a/crates/codegen/xai-grok-pager/src/app/signal_handler.rs b/crates/codegen/xai-grok-pager/src/app/signal_handler.rs index ce53e22..67ed9ce 100644 --- a/crates/codegen/xai-grok-pager/src/app/signal_handler.rs +++ b/crates/codegen/xai-grok-pager/src/app/signal_handler.rs @@ -104,22 +104,17 @@ fn spawn_async_signal_task() { let mut ctrl_shutdown = windows::ctrl_shutdown().ok(); tokio::select! { _ = tokio::signal::ctrl_c() => { - request_graceful_or_exit(130); - let _ = tokio::signal::ctrl_c().await; - shutdown_with_terminal_restore(130); + handle_windows_ctrl_c_double().await; + } + _ = recv_optional_ctrl_close(&mut ctrl_close) => { + shutdown_with_terminal_restore(1); + } + _ = recv_optional_ctrl_logoff(&mut ctrl_logoff) => { + shutdown_with_terminal_restore(0); + } + _ = recv_optional_ctrl_shutdown(&mut ctrl_shutdown) => { + shutdown_with_terminal_restore(0); } - _ = async { - if let Some(s) = ctrl_close.as_mut() { let _ = s.recv().await; } - else { std::future::pending::<()>().await; } - } => shutdown_with_terminal_restore(1), - _ = async { - if let Some(s) = ctrl_logoff.as_mut() { let _ = s.recv().await; } - else { std::future::pending::<()>().await; } - } => shutdown_with_terminal_restore(0), - _ = async { - if let Some(s) = ctrl_shutdown.as_mut() { let _ = s.recv().await; } - else { std::future::pending::<()>().await; } - } => shutdown_with_terminal_restore(0), } } #[cfg(not(any(unix, windows)))] @@ -134,24 +129,66 @@ fn spawn_async_signal_task() { } /// Wait for the next SIGINT/SIGTERM/SIGHUP and map it to its exit code. +/// +/// Shared with the agent binary (`xai-grok-pager-bin`) so the 130/143/129 map +/// cannot drift between TUI and agent signal handlers. #[cfg(unix)] -async fn next_signal_code( +pub async fn next_signal_code( sigterm: &mut Option, sighup: &mut Option, ) -> i32 { tokio::select! { _ = tokio::signal::ctrl_c() => 130, - _ = async { - if let Some(s) = sigterm.as_mut() { let _ = s.recv().await; } - else { std::future::pending::<()>().await; } - } => 143, - _ = async { - if let Some(s) = sighup.as_mut() { let _ = s.recv().await; } - else { std::future::pending::<()>().await; } - } => 129, + _ = recv_optional_unix_signal(sigterm) => 143, + _ = recv_optional_unix_signal(sighup) => 129, } } +/// Await one recv on an optional unix signal stream, or pend forever if absent. +#[cfg(unix)] +pub async fn recv_optional_unix_signal(sig: &mut Option) { + if let Some(s) = sig.as_mut() { + let _ = s.recv().await; + } else { + std::future::pending::<()>().await; + } +} + +#[cfg(windows)] +async fn handle_windows_ctrl_c_double() { + request_graceful_or_exit(130); + let _ = tokio::signal::ctrl_c().await; + shutdown_with_terminal_restore(130); +} + +// Tokio exposes distinct CtrlClose / CtrlLogoff / CtrlShutdown types with no +// shared trait — generate the three identical recv helpers from one body. +#[cfg(windows)] +macro_rules! define_recv_optional_windows_signal { + ($name:ident, $ty:ty) => { + async fn $name(sig: &mut Option<$ty>) { + if let Some(s) = sig.as_mut() { + let _ = s.recv().await; + } else { + std::future::pending::<()>().await; + } + } + }; +} + +#[cfg(windows)] +define_recv_optional_windows_signal!(recv_optional_ctrl_close, tokio::signal::windows::CtrlClose); +#[cfg(windows)] +define_recv_optional_windows_signal!( + recv_optional_ctrl_logoff, + tokio::signal::windows::CtrlLogoff +); +#[cfg(windows)] +define_recv_optional_windows_signal!( + recv_optional_ctrl_shutdown, + tokio::signal::windows::CtrlShutdown +); + /// Request the event loop's graceful quit when it is registered and the TUI /// still owns the terminal; otherwise hard-exit (agent mode, or a signal after /// teardown already started). diff --git a/crates/codegen/xai-grok-pager/src/app/turn_completion.rs b/crates/codegen/xai-grok-pager/src/app/turn_completion.rs index e85c97a..ae4085f 100644 --- a/crates/codegen/xai-grok-pager/src/app/turn_completion.rs +++ b/crates/codegen/xai-grok-pager/src/app/turn_completion.rs @@ -18,29 +18,21 @@ use super::app_view::AppView; /// any pending stop/stop_failure hook runs into it so they render inline /// (right-justified) on the marker line instead of as a standalone block. /// -/// All four marker rails route through here: the driver's `PromptResponse`, -/// the lost-RPC reconcile, the viewer finalize, and wake turns' -/// `push_wake_end_marker` (acp_handler). `event == None` (bash turns, -/// rate-limit / re-auth UX that replaces the marker) flushes the held hooks -/// as the legacy standalone lifecycle block so failures stay visible. +/// All three marker rails route through here: the driver's `PromptResponse`, +/// the lost-RPC reconcile, and the viewer finalize. (Wake turns close +/// markerless — see `finish_wake_turn` in acp_handler.) `event == None` +/// (bash turns, rate-limit / re-auth UX that replaces the marker) flushes the +/// held hooks as the legacy standalone lifecycle block so failures stay +/// visible. /// -/// A stamped stash folds only on an exact ending-id match. On a mismatch the -/// real-turn rails flush it standalone (the ending turn is THE turn — an -/// older stash has no marker coming); the wake rail instead passes -/// `preserve_mismatched_stash` so a REAL turn's leftover stash stays pending -/// for its own marker rail rather than flushing on an unrelated wake. An -/// unstamped stash keeps the legacy stashed-during-this-turn heuristic. -/// -/// The marker carries a snapshot of the background work still running -/// ("Worked for X. N commands still running") when any exists — a -/// workless marker renders the legacy text unchanged. Announcing work opens -/// the between-turns status window: completions landing before the next -/// turn re-emit a fresh work-only status line after their chip. +/// A stamped stash folds only on an exact ending-id match. On a mismatch it +/// flushes standalone (the ending turn is THE turn — an older stash has no +/// marker coming). An unstamped stash keeps the legacy +/// stashed-during-this-turn heuristic. pub(super) fn push_turn_terminal_marker( agent: &mut AgentView, event: Option, ending_prompt_id: Option<&str>, - preserve_mismatched_stash: bool, ) { let pending = agent.pending_stop_hooks.take(); let groups = match pending { @@ -52,14 +44,8 @@ pub(super) fn push_turn_terminal_marker( (None, _) => false, }; if stale { - if preserve_mismatched_stash { - // Wake rail: the stash belongs to a real turn whose own - // marker rail will fold it — leave it pending. - agent.pending_stop_hooks = Some(pending); - } else { - for (name, runs) in pending.groups { - agent.scrollback.push_lifecycle_hooks(name, runs); - } + for (name, runs) in pending.groups { + agent.scrollback.push_lifecycle_hooks(name, runs); } Vec::new() } else { @@ -297,7 +283,7 @@ pub(super) fn finalize_turn_from_terminal( elapsed: Some(elapsed), }), }; - push_turn_terminal_marker(agent, event, ending_prompt_id.as_deref(), false); + push_turn_terminal_marker(agent, event, ending_prompt_id.as_deref()); agent.mark_turn_finished(); diff --git a/crates/codegen/xai-grok-pager/src/app/turn_completion/tests.rs b/crates/codegen/xai-grok-pager/src/app/turn_completion/tests.rs index 9b4497b..16616ab 100644 --- a/crates/codegen/xai-grok-pager/src/app/turn_completion/tests.rs +++ b/crates/codegen/xai-grok-pager/src/app/turn_completion/tests.rs @@ -103,7 +103,6 @@ fn marker_push_consumes_matching_stop_hook_stash() { elapsed: Some(std::time::Duration::from_secs(2)), }), Some("p1"), - false, ); assert_eq!( @@ -131,7 +130,6 @@ fn marker_push_flushes_stale_stash_standalone() { elapsed: Some(std::time::Duration::from_secs(2)), }), Some("p2"), - false, ); assert_eq!( @@ -160,7 +158,6 @@ fn marker_without_ending_pid_flushes_stamped_stash_standalone() { elapsed: Some(std::time::Duration::from_secs(2)), }), None, - false, ); assert_eq!( @@ -182,7 +179,7 @@ fn no_marker_flushes_stash_as_standalone_block() { groups: one_stop_group(), }); - push_turn_terminal_marker(&mut agent, None, Some("p1"), false); + push_turn_terminal_marker(&mut agent, None, Some("p1")); assert_eq!(count_lifecycle_blocks(&agent.scrollback), 1); assert!(agent.pending_stop_hooks.is_none()); @@ -379,9 +376,7 @@ fn driver_rearm_same_pid_preserves_received_at() { assert_eq!(first, second); } -// ── EndLine snapshots: work suffix + between-turns status window ── - -use crate::scrollback::blocks::EndWork; +// ── End markers: always the plain event text (work lives in the status row) ── fn insert_bg_task(agent: &mut AgentView, task_id: &str, is_monitor: bool) { agent.session.bg_tasks.insert( @@ -422,7 +417,10 @@ fn last_marker_block(agent: &AgentView) -> &SessionEventBlock { } #[test] -fn real_end_marker_snapshots_running_work() { +fn real_end_marker_stays_plain_with_running_work() { + // Background work never rides the end marker as a "still running" suffix + // — the persistent "watching · …" status row carries it instead. The + // running command shows up in the watchers count only. let mut agent = running_driver("p1"); insert_bg_task(&mut agent, "bg-1", false); @@ -432,33 +430,22 @@ fn real_end_marker_snapshots_running_work() { elapsed: Some(std::time::Duration::from_secs(2)), }), Some("p1"), - false, ); let block = last_marker_block(&agent); assert!(!block.parked); assert_eq!(block.prompt_id.as_deref(), Some("p1")); + assert_eq!(block.event.message(), "Worked for 2.0s"); assert_eq!( - block.end_work, - Some(EndWork { - running_commands: 1, - ..EndWork::default() - }) - ); - assert_eq!( - block.marker_text(), - "Worked for 2.0s. 1 command still running." - ); - assert!( - agent.end_work_announced, - "announcing work opens the between-turns status window" + agent.watchers().commands, + 1, + "the running command feeds the status-row watchers cue instead" ); } #[test] -fn workless_marker_stays_legacy_and_closes_window() { +fn workless_marker_renders_legacy_text() { let mut agent = running_driver("p1"); - agent.end_work_announced = true; push_turn_terminal_marker( &mut agent, @@ -466,40 +453,10 @@ fn workless_marker_stays_legacy_and_closes_window() { elapsed: Some(std::time::Duration::from_secs(2)), }), Some("p1"), - false, ); let block = last_marker_block(&agent); - assert!(block.end_work.is_none(), "legacy marker: no work suffix"); - assert_eq!(block.marker_text(), "Worked for 2.0s."); - assert!( - !agent.end_work_announced, - "a workless marker proves nothing is running — window closed" - ); -} - -#[test] -fn marker_snapshot_never_mutates() { - // The suffix is a push-time snapshot: later completions re-emit a - // fresh status line instead of editing the marker. - let mut agent = running_driver("p1"); - insert_bg_task(&mut agent, "bg-1", false); - push_turn_terminal_marker( - &mut agent, - Some(SessionEvent::TurnCompleted { - elapsed: Some(std::time::Duration::from_secs(2)), - }), - Some("p1"), - false, - ); - - agent.session.bg_tasks.get_mut("bg-1").unwrap().status = crate::app::agent::BgTaskStatus::Done; - - assert_eq!( - last_marker_block(&agent).marker_text(), - "Worked for 2.0s. 1 command still running.", - "the marker keeps its push-time counts" - ); + assert_eq!(block.event.message(), "Worked for 2.0s"); } // ── Send-now cancel marker suppression (viewer finalize rail) ──────── diff --git a/crates/codegen/xai-grok-pager/src/app/xt_filter.rs b/crates/codegen/xai-grok-pager/src/app/xt_filter.rs index ec1bb96..fd35ecd 100644 --- a/crates/codegen/xai-grok-pager/src/app/xt_filter.rs +++ b/crates/codegen/xai-grok-pager/src/app/xt_filter.rs @@ -5,7 +5,7 @@ use std::time::{Duration, Instant}; use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers}; -use super::event_loop::is_bare_esc_press; +use super::event_loop::{TimedInputEvent, is_bare_esc_press}; /// How long the filter stays armed waiting for the reply (opentui uses a /// non-blocking 5s window); zero-cost after disarm. @@ -24,11 +24,8 @@ const XT_MAX_PAYLOAD: usize = 64; /// Recognizes and swallows the XTVERSION DCS reply arriving through the /// input event channel. crossterm surfaces `ESC P` as Alt+Shift+P, the payload as -/// plain Char presses, ST as Alt+\ and BEL as Ctrl+G. Unmatched events -/// pass through; partial prefixes are held in `tentative` and resolved by -/// the caller. Non-key events (Resize/Focus/Mouse/Paste) pass through -/// without disturbing a hold — they can interleave with a split reply but -/// can never be part of it. +/// plain Char presses, ST as Alt+\ and BEL as Ctrl+G. Events behind a partial +/// prefix are staged so surviving input retains FIFO order and timestamps. pub(super) struct XtversionFilter { armed: bool, /// Set on the first `filter()` call, not at construction — a loaded @@ -36,11 +33,16 @@ pub(super) struct XtversionFilter { /// batch, and that time must not burn the arm window. deadline: Option, state: XtState, - tentative: Vec, + staged: Vec, payload: String, completed: Option, } +enum StagedEvent { + Tentative(TimedInputEvent), + PassThrough(TimedInputEvent), +} + #[derive(Clone, Copy, PartialEq)] enum XtState { Idle, @@ -63,7 +65,7 @@ impl XtversionFilter { armed, deadline: None, state: XtState::Idle, - tentative: Vec::new(), + staged: Vec::new(), payload: String::new(), completed: None, } @@ -74,7 +76,11 @@ impl XtversionFilter { } pub(super) fn holding(&self) -> bool { - self.armed && !self.tentative.is_empty() + self.armed + && self + .staged + .iter() + .any(|event| matches!(event, StagedEvent::Tentative(_))) } pub(super) fn take_completed(&mut self) -> Option { @@ -82,32 +88,44 @@ impl XtversionFilter { } /// Flush held events back (prefix turned out not to be a reply). - fn flush(&mut self) -> Vec { + fn flush(&mut self) -> Vec { self.state = XtState::Idle; self.payload.clear(); - std::mem::take(&mut self.tentative) + std::mem::take(&mut self.staged) + .into_iter() + .map(|event| match event { + StagedEvent::Tentative(event) | StagedEvent::PassThrough(event) => event, + }) + .collect() + } + + fn release_pass_through(&mut self) -> Vec { + std::mem::take(&mut self.staged) + .into_iter() + .filter_map(|event| match event { + StagedEvent::Tentative(_) => None, + StagedEvent::PassThrough(event) => Some(event), + }) + .collect() } - /// True once the full `ESC P > |` intro was seen — past that point the - /// held events are terminal output, never typing. fn intro_confirmed(&self) -> bool { matches!(self.state, XtState::Payload | XtState::PayloadEscHeld) } - /// Resolve a hold that won't complete: drop a confirmed reply fragment - /// rather than type it into the prompt (drop unsolicited replies); flush - /// back a short pre-intro hold (real keypresses). - pub(super) fn resolve_dead_hold(&mut self) -> Vec { + /// Flush pre-intro input; drop confirmed DCS bytes but preserve pass-through. + pub(super) fn resolve_dead_hold(&mut self) -> Vec { if !self.intro_confirmed() { return self.flush(); } tracing::debug!("dropping stalled XTVERSION reply fragment"); - self.flush(); - Vec::new() + self.state = XtState::Idle; + self.payload.clear(); + self.release_pass_through() } /// Remove a complete DCS reply from the batch; pass everything else. - fn filter(&mut self, events: Vec) -> Vec { + fn filter(&mut self, events: Vec) -> Vec { // Don't expire mid-hold once the intro is confirmed: the in-flight // reply must resolve (Complete or dead-hold drop), or its tail // would pass through as typed text. @@ -132,22 +150,28 @@ impl XtversionFilter { result.push(ev); continue; } - match self.advance(&ev) { - XtAdvance::Hold => self.tentative.push(ev), - XtAdvance::PassThrough => result.push(ev), + match self.advance(&ev.event) { + XtAdvance::Hold => self.staged.push(StagedEvent::Tentative(ev)), + XtAdvance::PassThrough => { + if self.holding() { + self.staged.push(StagedEvent::PassThrough(ev)); + } else { + result.push(ev); + } + } XtAdvance::Complete => { self.completed = Some(std::mem::take(&mut self.payload)); - self.tentative.clear(); self.state = XtState::Idle; self.armed = false; + result.extend(self.release_pass_through()); } // Dead hold: drop a confirmed reply fragment, flush back a // pre-intro one; re-evaluate the rejecting event from Idle // so a following reply is still caught. XtAdvance::Mismatch => { result.append(&mut self.resolve_dead_hold()); - if matches!(self.advance(&ev), XtAdvance::Hold) { - self.tentative.push(ev); + if matches!(self.advance(&ev.event), XtAdvance::Hold) { + self.staged.push(StagedEvent::Tentative(ev)); } else { result.push(ev); } @@ -159,8 +183,6 @@ impl XtversionFilter { fn advance(&mut self, ev: &Event) -> XtAdvance { use XtState::*; - // Non-key events can't be reply bytes nor "the first typed char"; - // surfacing them ahead of a hold is harmless. if !matches!(ev, Event::Key(_)) { return XtAdvance::PassThrough; } @@ -213,9 +235,9 @@ enum XtAdvance { /// delayed by up to one fragment timeout before flushing back. pub(super) async fn filter_with_fragment_wait( xt_filter: &mut XtversionFilter, - mut raw_events: Vec, - input_rx: &mut tokio::sync::mpsc::UnboundedReceiver, -) -> Vec { + mut raw_events: Vec, + input_rx: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> Vec { raw_events = xt_filter.filter(raw_events); let hold_deadline = Instant::now() + XT_MAX_HOLD; while xt_filter.holding() { @@ -293,29 +315,39 @@ fn xt_plain_char(ev: &Event) -> Option { #[cfg(test)] mod tests { + use std::sync::OnceLock; + use super::*; use crossterm::event::{KeyEvent, KeyEventState}; - fn press_mods(code: KeyCode, modifiers: KeyModifiers) -> Event { - Event::Key(KeyEvent { - code, - modifiers, - kind: KeyEventKind::Press, - state: KeyEventState::NONE, - }) + fn test_instant() -> Instant { + static NOW: OnceLock = OnceLock::new(); + *NOW.get_or_init(Instant::now) } - fn press(code: KeyCode) -> Event { + fn press_mods(code: KeyCode, modifiers: KeyModifiers) -> TimedInputEvent { + TimedInputEvent { + event: Event::Key(KeyEvent { + code, + modifiers, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }), + arrived_at: test_instant(), + } + } + + fn press(code: KeyCode) -> TimedInputEvent { press_mods(code, KeyModifiers::NONE) } - fn press_shift(code: KeyCode) -> Event { + fn press_shift(code: KeyCode) -> TimedInputEvent { press_mods(code, KeyModifiers::SHIFT) } /// The reply `ESC P > | ESC \` as crossterm surfaces it in /// one read: Alt+Shift+P, plain chars, Alt+\. - fn dcs_reply_events(payload: &str) -> Vec { + fn dcs_reply_events(payload: &str) -> Vec { let mut evs = vec![press_mods( KeyCode::Char('P'), KeyModifiers::ALT | KeyModifiers::SHIFT, @@ -342,6 +374,76 @@ mod tests { assert!(!f.armed()); } + #[test] + fn xt_filter_dead_pre_intro_hold_preserves_fifo_and_timestamps() { + let start = Instant::now(); + let resize_at = start + Duration::from_millis(4); + let mut filter = XtversionFilter::with_armed(true); + let events = vec![ + TimedInputEvent { + event: press(KeyCode::Esc).event, + arrived_at: start, + }, + TimedInputEvent { + event: Event::Resize(80, 24), + arrived_at: resize_at, + }, + ]; + + assert!(filter.filter(events).is_empty()); + let output = filter.resolve_dead_hold(); + + assert_eq!(output.len(), 2); + assert_eq!(output[0].event, press(KeyCode::Esc).event); + assert_eq!(output[0].arrived_at, start); + assert_eq!(output[1].event, Event::Resize(80, 24)); + assert_eq!(output[1].arrived_at, resize_at); + } + + #[test] + fn xt_filter_confirmed_dead_hold_releases_interleaved_pass_through() { + let start = Instant::now(); + let resize_at = start + Duration::from_millis(5); + let mut events = dcs_reply_events("x"); + events.pop(); + events.insert( + 4, + TimedInputEvent { + event: Event::Resize(80, 24), + arrived_at: resize_at, + }, + ); + let mut filter = XtversionFilter::with_armed(true); + + assert!(filter.filter(events).is_empty()); + let output = filter.resolve_dead_hold(); + + assert_eq!(output.len(), 1); + assert_eq!(output[0].event, Event::Resize(80, 24)); + assert_eq!(output[0].arrived_at, resize_at); + } + + #[test] + fn xt_filter_confirmed_reply_preserves_interleaved_pass_through_order() { + let start = Instant::now(); + let mut reply = dcs_reply_events("x"); + let tail = reply.split_off(2); + let mut events = reply; + events.push(TimedInputEvent { + event: Event::Resize(80, 24), + arrived_at: start, + }); + events.extend(tail); + let mut filter = XtversionFilter::with_armed(true); + + let output = filter.filter(events); + + assert_eq!(output.len(), 1); + assert_eq!(output[0].event, Event::Resize(80, 24)); + assert_eq!(output[0].arrived_at, start); + assert_eq!(filter.take_completed().as_deref(), Some("x")); + } + #[test] fn xt_filter_passes_surrounding_keys() { let mut f = XtversionFilter::with_armed(true); @@ -476,18 +578,29 @@ mod tests { #[test] fn xt_filter_resize_mid_hold_does_not_break_reply() { - // Startup Resize/Focus events interleaved with a split reply must - // pass through without dropping the hold. let mut f = XtversionFilter::with_armed(true); let evs = dcs_reply_events("kitty 0.35.2"); let (a, b) = evs.split_at(6); + let resize_at = test_instant() + Duration::from_millis(3); + let focus_at = test_instant() + Duration::from_millis(4); let mut first = a.to_vec(); - first.push(Event::Resize(80, 24)); - first.push(Event::FocusGained); - let out = f.filter(first); - assert_eq!(out, vec![Event::Resize(80, 24), Event::FocusGained]); + first.push(TimedInputEvent { + event: Event::Resize(80, 24), + arrived_at: resize_at, + }); + first.push(TimedInputEvent { + event: Event::FocusGained, + arrived_at: focus_at, + }); + assert!(f.filter(first).is_empty()); assert!(f.holding()); - assert!(f.filter(b.to_vec()).is_empty()); + + let out = f.filter(b.to_vec()); + assert_eq!(out.len(), 2); + assert_eq!(out[0].event, Event::Resize(80, 24)); + assert_eq!(out[0].arrived_at, resize_at); + assert_eq!(out[1].event, Event::FocusGained); + assert_eq!(out[1].arrived_at, focus_at); assert_eq!(f.take_completed().as_deref(), Some("kitty 0.35.2")); } } diff --git a/crates/codegen/xai-grok-pager/src/bin/scrollback_search_playground.rs b/crates/codegen/xai-grok-pager/src/bin/scrollback_search_playground.rs index 302cac3..53a0716 100644 --- a/crates/codegen/xai-grok-pager/src/bin/scrollback_search_playground.rs +++ b/crates/codegen/xai-grok-pager/src/bin/scrollback_search_playground.rs @@ -26,15 +26,12 @@ use xai_grok_pager::scrollback::{ RenderBlock, ScratchBuffer, ScrollbackPane, ScrollbackSearchState, ScrollbackState, }; use xai_grok_pager::theme::Theme; -use xai_grok_pager::views::picker::render_search_bar; +use xai_grok_pager::views::picker::render_search_bar_with_viewport; struct App { scrollback: ScrollbackState, scratch: ScratchBuffer, search: ScrollbackSearchState, - /// The query input buffer the user is editing (the source of truth for the - /// composing string, mirroring how the production input router will work). - query: String, events: VecDeque, } @@ -58,7 +55,6 @@ impl App { scrollback, scratch: ScratchBuffer::new(), search: ScrollbackSearchState::open(), - query: String::new(), events: VecDeque::new(), } } @@ -70,13 +66,10 @@ impl App { } } - /// Re-run the search for the current query buffer. - fn refresh_query(&mut self) { - self.search.update_query(&self.query, &self.scrollback); - self.reveal_current(); + fn log_query(&mut self) { self.push(format!( "query={:?} matches={}", - self.query, + self.search.query(), self.search.match_count() )); } @@ -138,22 +131,16 @@ fn handle_key(app: &mut App, key: KeyEvent) -> bool { match key.code { // Esc clears the query; quit only when it's already empty. KeyCode::Esc => { - if app.query.is_empty() { + if app.search.query().is_empty() { return true; } - app.query.clear(); app.search = ScrollbackSearchState::open(); - app.refresh_query(); app.push("clear".to_string()); } KeyCode::Enter => { app.search.accept(); app.push("accept: browsing".to_string()); } - KeyCode::Backspace => { - app.query.pop(); - app.refresh_query(); - } // While browsing (accepted), `n` / `N` navigate. While composing they // are typed into the query, matching real vim `/` behavior. KeyCode::Char('n') if !app.search.is_composing() => { @@ -166,9 +153,11 @@ fn handle_key(app: &mut App, key: KeyEvent) -> bool { app.reveal_current(); app.push(format!("prev -> {:?}", app.search.current_index())); } - KeyCode::Char(c) => { - app.query.push(c); - app.refresh_query(); + _ if app.search.is_composing() => { + let before = app.search.query().to_owned(); + if app.search.handle_query_key(&key, &app.scrollback) && app.search.query() != before { + app.log_query(); + } } _ => {} } @@ -230,26 +219,35 @@ fn draw(f: &mut ratatui::Frame, app: &mut App) { } // -- Search bar -- - render_search_bar( + let query = app.search.query(); + let counter = match app.search.current_index() { + Some(i) => Some(format!("{}/{}", i + 1, app.search.match_count())), + None if app.search.has_error() => Some("bad pattern".to_string()), + None if !query.is_empty() => Some("no matches".to_string()), + None => None, + }; + let counter_width = counter + .as_deref() + .map_or(0, |text| UnicodeWidthStr::width(text) as u16); + let search_layout = + xai_grok_pager::views::picker::search_bar_layout(block_area.width, counter_width); + render_search_bar_with_viewport( f.buffer_mut(), block_area.x, bar_y, - block_area.width, + search_layout, &theme, - &app.query, + query, app.search.is_composing(), - app.query.is_empty() && app.search.is_composing(), - app.query.len(), + query.is_empty() && app.search.is_composing(), None, + app.search.query_viewport(search_layout.input_width()), ); // Right-aligned match counter: `m/n`, or `no matches` for a live query. - let counter = match app.search.current_index() { - Some(i) => Some(format!("{}/{}", i + 1, app.search.match_count())), - None if !app.query.is_empty() => Some("no matches".to_string()), - None => None, - }; - if let Some(counter) = counter { + if let Some(counter) = counter + && search_layout.trailing_width() > 0 + { let w = counter.width() as u16; if block_area.width > w { f.buffer_mut().set_string( diff --git a/crates/codegen/xai-grok-pager/src/diagnostics.rs b/crates/codegen/xai-grok-pager/src/diagnostics.rs index 1fa80a5..e57ec1d 100644 --- a/crates/codegen/xai-grok-pager/src/diagnostics.rs +++ b/crates/codegen/xai-grok-pager/src/diagnostics.rs @@ -651,33 +651,6 @@ pub fn diagnose_wayland_data_control_live() -> Option { ) } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum Osc52Capability { - Supported, - Unsupported, - Unknown, -} - -impl Osc52Capability { - fn from_brand(brand: TerminalName) -> Self { - if brand.supports_osc52_clipboard() { - Self::Supported - } else if brand == TerminalName::Unknown { - Self::Unknown - } else { - Self::Unsupported - } - } - - fn label(self) -> &'static str { - match self { - Self::Supported => "supported", - Self::Unsupported => "unsupported", - Self::Unknown => "unknown", - } - } -} - #[derive(Clone, Copy, Debug)] pub struct ClipboardDiagnosticsInput<'a> { pub route_native: bool, @@ -703,27 +676,27 @@ pub struct ClipboardDiagnostics { /// Format preflight clipboard routes without claiming that a copy already happened. pub fn format_clipboard_diagnostics(input: ClipboardDiagnosticsInput<'_>) -> ClipboardDiagnostics { use crate::clipboard::{ - ClipboardDelivery, NativeClipboardPreflight, expected_delivery, native_clipboard_preflight, + ClipboardDelivery, ClipboardEnvironment, NativeClipboardPreflight, expected_delivery, + native_clipboard_preflight, }; - let capability = Osc52Capability::from_brand(input.brand); - let native_preflight = native_clipboard_preflight( - input.route_native, - input.host_os, - input.display_server, - input.is_ssh, - input.container_no_display, - input.wayland_data_control, - input.wl_copy_available, - ); + let environment = ClipboardEnvironment { + brand: input.brand, + host_os: input.host_os, + display_server: input.display_server, + remote: input.is_ssh, + container: input.container_no_display, + osc52_sink: input.osc52_sink, + wayland_data_control: input.wayland_data_control, + wl_copy_available: input.wl_copy_available, + }; + let capability = environment.osc52_capability(); + let native_preflight = native_clipboard_preflight(input.route_native, environment); let delivery = expected_delivery( native_preflight, input.route_tmux, input.route_osc52, - input.brand, - input.is_ssh, - input.container_no_display, - input.osc52_sink, + environment, ); let native = match native_preflight { NativeClipboardPreflight::LocalAvailable => format!("local ({})", input.native_tool), @@ -735,12 +708,10 @@ pub fn format_clipboard_diagnostics(input: ClipboardDiagnosticsInput<'_>) -> Cli NativeClipboardPreflight::Disabled => "off".to_owned(), }; let tmux = if input.route_tmux { "on" } else { "off" }; - let osc52 = if !input.route_osc52 { - "off" - } else if input.osc52_sink || capability == Osc52Capability::Supported { - "supported" - } else { + let osc52 = if input.route_osc52 { capability.label() + } else { + "off" }; let wrap = if input.osc52_sink { "on" } else { "off" }; let status = match delivery { @@ -785,7 +756,7 @@ pub fn format_clipboard_diagnostics(input: ClipboardDiagnosticsInput<'_>) -> Cli } ClipboardDiagnostics { text: out, - has_issue: delivery != ClipboardDelivery::Confirmed, + has_issue: !delivery.is_confirmed(), } } @@ -1076,6 +1047,22 @@ mod tests { .contains("fix grok wrap or /minimal") ); assert!(unsupported.has_issue); + + let unsupported_container = format_clipboard_diagnostics(ClipboardDiagnosticsInput { + is_ssh: false, + container_no_display: true, + ..clipboard_input(TerminalName::Vte) + }); + assert!( + unsupported_container + .text + .contains("osc 52 unsupported") + ); + assert!( + unsupported_container + .text + .contains("status unavailable") + ); } #[test] @@ -1095,7 +1082,7 @@ mod tests { wl_copy_available: wl_copy, ..clipboard_input(TerminalName::Vte) }); - assert_eq!(diagnostics.has_issue, expected.is_failed()); + assert_eq!(diagnostics.has_issue, !expected.is_confirmed()); assert!(diagnostics.text.contains(if data_control { "data-control on" } else { @@ -1118,6 +1105,7 @@ mod tests { osc52_sink: true, ..clipboard_input(TerminalName::Unknown) }); + assert!(wrapped.text.contains("osc 52 supported")); assert!(wrapped.text.contains("wrap on")); assert!(wrapped.text.contains("status confirmed")); @@ -1133,6 +1121,21 @@ mod tests { .text .contains("fix grok wrap or /minimal") ); + + let remote_container = format_clipboard_diagnostics(ClipboardDiagnosticsInput { + container_no_display: true, + ..clipboard_input(TerminalName::Unknown) + }); + assert!( + remote_container + .text + .contains("native container (arboard)") + ); + assert!( + remote_container + .text + .contains("fix grok wrap or /minimal") + ); } #[test] diff --git a/crates/codegen/xai-grok-pager/src/headless.rs b/crates/codegen/xai-grok-pager/src/headless.rs index 103d028..5b8a655 100644 --- a/crates/codegen/xai-grok-pager/src/headless.rs +++ b/crates/codegen/xai-grok-pager/src/headless.rs @@ -872,7 +872,6 @@ pub async fn run_single_turn( agent_config.resolve_runtime_fields(&xai_grok_shell::agent::config::RuntimeResolutionContext { raw_config: &raw_config, remote_settings: None, - cwd: Some(&cwd), is_headless: true, cli_subagents: None, cli_web_search_model: None, @@ -1324,7 +1323,10 @@ pub async fn run_single_turn( Some(Err(err)) => { let msg = if i32::from(err.code) == RATE_LIMITED_ERROR_CODE { let detail = err.data.as_ref().and_then(error_detail_from_data); - format_rate_limited_user_message(detail.as_deref(), is_api_key_auth) + crate::app::sanitize_user_error(&format_rate_limited_user_message( + detail.as_deref(), + is_api_key_auth, + )) } else { err.to_string() }; diff --git a/crates/codegen/xai-grok-pager/src/input/line_editor.rs b/crates/codegen/xai-grok-pager/src/input/line_editor.rs new file mode 100644 index 0000000..8aa585d --- /dev/null +++ b/crates/codegen/xai-grok-pager/src/input/line_editor.rs @@ -0,0 +1,304 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use xai_ratatui_textarea::{ + EditBuffer, EditCommand, EditOutcome, SingleLineViewport, classify_key_event, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum LineEditOutcome { + Unhandled, + /// The key was recognized and consumed, but text and cursor stayed unchanged. + HandledNoChange, + CursorChanged, + TextChanged, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct LineEditor { + buffer: EditBuffer, +} + +impl LineEditor { + pub(crate) fn text(&self) -> &str { + self.buffer.text() + } + + pub(crate) fn cursor_byte(&self) -> usize { + self.buffer.cursor_byte() + } + + pub(crate) fn set_text(&mut self, text: impl Into) { + self.buffer = EditBuffer::from_text(sanitize_single_line(text)); + } + + pub(crate) fn reset(&mut self) { + self.buffer = EditBuffer::new(); + } + + #[cfg(test)] + pub(crate) fn set_cursor_byte(&mut self, cursor_byte: usize) -> LineEditOutcome { + Self::from_edit_outcome(self.buffer.set_cursor_byte(cursor_byte)) + } + + pub(crate) fn delete_last_grapheme(&mut self) -> LineEditOutcome { + let _ = self.buffer.set_cursor_byte(self.buffer.text().len()); + Self::from_edit_outcome(self.buffer.apply(EditCommand::DeleteGraphemeBackward)) + } + + pub(crate) fn insert_paste(&mut self, text: &str) -> LineEditOutcome { + self.insert_paste_with_policy(text, |_| true, usize::MAX) + } + + pub(crate) fn insert_paste_with_policy( + &mut self, + text: &str, + mut allow_insert: impl FnMut(char) -> bool, + max_chars: usize, + ) -> LineEditOutcome { + let cleaned = sanitize_single_line(text); + let accepted = cleaned + .chars() + .filter(|character| allow_insert(*character)) + .take(max_chars) + .collect::(); + if accepted.is_empty() { + return LineEditOutcome::HandledNoChange; + } + Self::from_edit_outcome(self.buffer.insert_str(&accepted)) + } + + pub(crate) fn insert_paste_with_byte_limit( + &mut self, + text: &str, + max_total_bytes: usize, + ) -> LineEditOutcome { + let cleaned = sanitize_single_line(text); + let remaining = max_total_bytes.saturating_sub(self.buffer.text().len()); + let mut accepted_bytes = 0usize; + let accepted = cleaned + .chars() + .take_while(|character| { + let next = accepted_bytes + character.len_utf8(); + if next > remaining { + return false; + } + accepted_bytes = next; + true + }) + .collect::(); + if accepted.is_empty() { + return LineEditOutcome::HandledNoChange; + } + Self::from_edit_outcome(self.buffer.insert_str(&accepted)) + } + + pub(crate) fn handle_key(&mut self, key: &KeyEvent) -> LineEditOutcome { + self.handle_key_with_insert_policy(key, |_| true) + } + + pub(crate) fn handle_key_with_insert_policy( + &mut self, + key: &KeyEvent, + allow_insert: impl FnOnce(char) -> bool, + ) -> LineEditOutcome { + if key.kind == KeyEventKind::Release { + return LineEditOutcome::Unhandled; + } + let command = match key { + KeyEvent { + code: KeyCode::Home, + .. + } + | KeyEvent { + code: KeyCode::Left, + modifiers: KeyModifiers::SUPER, + .. + } => Some(EditCommand::MoveLogicalLineStart), + KeyEvent { + code: KeyCode::End, .. + } + | KeyEvent { + code: KeyCode::Right, + modifiers: KeyModifiers::SUPER, + .. + } => Some(EditCommand::MoveLogicalLineEnd), + _ => classify_key_event(key), + }; + let Some(command) = command else { + return LineEditOutcome::Unhandled; + }; + if let EditCommand::Insert(character) = command + && !allow_insert(character) + { + return LineEditOutcome::HandledNoChange; + } + Self::from_edit_outcome(self.buffer.apply(command)) + } + + pub(crate) fn viewport(&self, width: usize) -> SingleLineViewport { + self.buffer.single_line_viewport(width) + } + + fn from_edit_outcome(outcome: EditOutcome) -> LineEditOutcome { + match outcome { + EditOutcome::Unchanged => LineEditOutcome::HandledNoChange, + EditOutcome::CursorOnly => LineEditOutcome::CursorChanged, + EditOutcome::TextOnly(_) | EditOutcome::TextAndCursor(_) => { + LineEditOutcome::TextChanged + } + } + } +} + +pub(crate) fn sanitize_single_line(text: impl Into) -> String { + let mut text = text.into(); + text.retain(|character| !matches!(character, '\r' | '\n')); + text +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + fn key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent { + KeyEvent::new(code, modifiers) + } + + #[test] + fn canonical_word_keys_and_legacy_alt_bindings() { + for event in [ + key(KeyCode::Left, KeyModifiers::ALT), + key(KeyCode::Char('b'), KeyModifiers::ALT), + key(KeyCode::Left, KeyModifiers::CONTROL), + ] { + let mut editor = LineEditor::default(); + editor.set_text("hello-world"); + assert_eq!(editor.handle_key(&event), LineEditOutcome::CursorChanged); + assert_eq!(editor.cursor_byte(), "hello-".len()); + } + + let mut editor = LineEditor::default(); + editor.set_text("hello-world"); + assert_eq!( + editor.handle_key(&key(KeyCode::Backspace, KeyModifiers::ALT)), + LineEditOutcome::TextChanged + ); + assert_eq!(editor.text(), "hello-"); + } + + #[test] + fn home_end_super_and_outcomes() { + let mut editor = LineEditor::default(); + editor.set_text("abc"); + assert_eq!( + editor.handle_key(&key(KeyCode::Home, KeyModifiers::NONE)), + LineEditOutcome::CursorChanged + ); + assert_eq!( + editor.handle_key(&key(KeyCode::Left, KeyModifiers::SUPER)), + LineEditOutcome::HandledNoChange + ); + assert_eq!( + editor.handle_key(&key(KeyCode::Right, KeyModifiers::SUPER)), + LineEditOutcome::CursorChanged + ); + assert_eq!( + editor.handle_key(&key(KeyCode::End, KeyModifiers::NONE)), + LineEditOutcome::HandledNoChange + ); + assert_eq!( + editor.handle_key(&key(KeyCode::Esc, KeyModifiers::NONE)), + LineEditOutcome::Unhandled + ); + } + + #[test] + fn ctrl_u_kills_only_to_the_cursor() { + let mut editor = LineEditor::default(); + editor.set_text("hello world"); + assert_eq!( + editor.handle_key(&key(KeyCode::Left, KeyModifiers::NONE)), + LineEditOutcome::CursorChanged + ); + assert_eq!( + editor.handle_key(&key(KeyCode::Char('u'), KeyModifiers::CONTROL)), + LineEditOutcome::TextChanged + ); + assert_eq!(editor.text(), "d"); + assert_eq!(editor.cursor_byte(), 0); + } + + #[test] + fn paste_byte_limit_keeps_whole_characters_at_cursor() { + let mut editor = LineEditor::default(); + editor.set_text("ab"); + let _ = editor.set_cursor_byte(1); + assert_eq!( + editor.insert_paste_with_byte_limit("中x", 5), + LineEditOutcome::TextChanged + ); + assert_eq!(editor.text(), "a中b"); + assert_eq!(editor.cursor_byte(), "a中".len()); + } + + #[test] + fn delete_last_grapheme_ignores_cursor_and_deletes_one_cluster() { + let mut editor = LineEditor::default(); + editor.set_text("a👩🏽\u{200d}💻"); + let _ = editor.set_cursor_byte(0); + assert_eq!(editor.delete_last_grapheme(), LineEditOutcome::TextChanged); + assert_eq!(editor.text(), "a"); + assert_eq!(editor.cursor_byte(), 1); + } + + #[test] + fn set_text_sanitizes_and_places_cursor_at_end() { + let mut editor = LineEditor::default(); + editor.set_text("one\r\ntwo\nthree\rfour"); + assert_eq!(editor.text(), "onetwothreefour"); + assert_eq!(editor.cursor_byte(), editor.text().len()); + } + + #[test] + fn insert_policy_only_gates_insert_commands() { + let mut editor = LineEditor::default(); + editor.set_text("ab"); + assert_eq!( + editor.handle_key_with_insert_policy( + &key(KeyCode::Char('x'), KeyModifiers::NONE), + |_| false, + ), + LineEditOutcome::HandledNoChange + ); + assert_eq!(editor.text(), "ab"); + assert_eq!( + editor + .handle_key_with_insert_policy(&key(KeyCode::Left, KeyModifiers::NONE), |_| false,), + LineEditOutcome::CursorChanged + ); + + let mut unrestricted = LineEditor::default(); + assert_eq!( + unrestricted.handle_key(&key(KeyCode::Char('\u{202e}'), KeyModifiers::NONE)), + LineEditOutcome::TextChanged + ); + assert_eq!(unrestricted.text(), "\u{202e}"); + } + + #[test] + fn viewport_keeps_graphemes_and_cursor_visible() { + let grapheme = "👩🏽\u{200d}💻"; + let mut editor = LineEditor::default(); + editor.set_text(format!("a{grapheme}b")); + assert_eq!( + editor.handle_key(&key(KeyCode::Left, KeyModifiers::NONE)), + LineEditOutcome::CursorChanged + ); + let viewport = editor.viewport(3); + assert_eq!( + &editor.text()[viewport.visible_byte_range.clone()], + format!("{grapheme}b") + ); + assert_eq!(viewport.cursor_display_column, 2); + } +} diff --git a/crates/codegen/xai-grok-pager/src/input/mod.rs b/crates/codegen/xai-grok-pager/src/input/mod.rs index 158b200..72a40d5 100644 --- a/crates/codegen/xai-grok-pager/src/input/mod.rs +++ b/crates/codegen/xai-grok-pager/src/input/mod.rs @@ -2,6 +2,7 @@ pub mod key; pub mod keyboard_normalizer; +pub(crate) mod line_editor; #[cfg(target_os = "macos")] pub mod macos_modifiers; pub mod mouse; diff --git a/crates/codegen/xai-grok-pager/src/minimal/api.rs b/crates/codegen/xai-grok-pager/src/minimal/api.rs index 916df8e..e99a02c 100644 --- a/crates/codegen/xai-grok-pager/src/minimal/api.rs +++ b/crates/codegen/xai-grok-pager/src/minimal/api.rs @@ -51,6 +51,60 @@ use crate::views::rewind::RewindState; use crate::views::session_picker::{SessionEntryData, SourceFilter}; use crate::views::suggestion_controller::SuggestionController; +/// The shared renderer's minimum `/btw` panel dimensions. +pub const MINIMAL_BTW_MIN_WIDTH: u16 = 12; +pub const MINIMAL_BTW_MIN_HEIGHT: u16 = 3; + +/// Whether minimal can paint and expose input geometry for this panel size. +pub fn minimal_btw_size_is_paintable(width: u16, height: u16) -> bool { + width >= MINIMAL_BTW_MIN_WIDTH && height >= MINIMAL_BTW_MIN_HEIGHT +} + +/// Whether cached minimal input geometry represents a painted panel. +pub fn minimal_btw_geometry_is_paintable(area: Rect) -> bool { + minimal_btw_size_is_paintable(area.width, area.height) +} + +/// Clamp desired `/btw` rows to the available minimal live-region rows. +pub fn minimal_btw_visible_height(desired: u16, width: u16, available: u16) -> u16 { + if desired == 0 || !minimal_btw_size_is_paintable(width, available) { + 0 + } else { + desired.min(available) + } +} + +/// Typed result of the minimal-only `/btw` pre-router. +#[derive(Debug)] +pub enum MinimalBtwInput { + /// Minimal consumed the event; the shared/fullscreen router must not run. + Handled(Box), + /// Another minimal surface owns the event; delegate to its shared handler. + Occluded, + /// The plain live surface is active but `/btw` declined the event. + Delegate, +} + +/// Per-agent ownership of a minimal `/btw` panel and its in-flight response. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum MinimalBtwLifecycle { + Active { + request_id: Option, + revision: uuid::Uuid, + }, + Suspended { + revision: uuid::Uuid, + }, +} + +/// Complete minimal lifecycle moved aside while a painted modal handles input. +pub(crate) struct SuspendedMinimalBtwLifecycle { + state: crate::views::btw_overlay::BtwOverlayState, + request_id: Option, + revision: uuid::Uuid, + focused: bool, +} + // ── Consolidated minimal-mode state (AppView::minimal_state) ───────────────── // // Minimal's private per-session state, consolidated into a single field on the @@ -333,6 +387,158 @@ pub fn plan_approval_view(v: &AgentView) -> Option<&PlanApprovalViewState> { v.plan_approval_view.as_ref() } +/// Whether the minimal `/btw` panel is the painted input owner. +/// +/// This mirrors the shared router's surface cascade: everything that handles +/// input before `/btw`, plus the later prompt-replacing surfaces that minimal +/// paints in place of the panel, takes precedence here. Keeping the complete +/// owner predicate at the minimal facade gives paint and minimal input one +/// canonical answer without changing the fullscreen router. +pub fn minimal_btw_surface_available(v: &AgentView) -> bool { + v.active_subagent.is_none() + && v.image_viewer.is_none() + && v.video_viewer.is_none() + && v.gboom.is_none() + && !(v.show_goal_detail && v.goal_state.is_some()) + && v.line_viewer.is_none() + && v.extensions_modal.is_none() + && v.persona_detail.is_none() + && v.agents_modal.is_none() + && v.block_viewer.is_none() + && v.active_modal.is_none() + && v.no_input_overlay_pending() + && v.rewind_state.is_none() +} + +/// Start a correlated minimal `/btw` loading panel on this agent. +pub fn start_minimal_btw(v: &mut AgentView, question: String) -> uuid::Uuid { + let request_id = uuid::Uuid::new_v4(); + v.minimal_btw_lifecycle = Some(MinimalBtwLifecycle::Active { + request_id: Some(request_id), + revision: uuid::Uuid::new_v4(), + }); + v.btw_state = Some(crate::views::btw_overlay::BtwOverlayState::Loading { question }); + v.btw_focused = false; + request_id +} + +/// Apply a minimal `/btw` response only when it still owns the loading panel. +pub fn finish_minimal_btw( + v: &mut AgentView, + request_id: uuid::Uuid, + result: Result, +) -> bool { + let Some(MinimalBtwLifecycle::Active { + request_id: Some(active_id), + .. + }) = v.minimal_btw_lifecycle + else { + return false; + }; + if active_id != request_id { + return false; + } + let Some(crate::views::btw_overlay::BtwOverlayState::Loading { question }) = v.btw_state.take() + else { + return false; + }; + v.minimal_btw_lifecycle = Some(MinimalBtwLifecycle::Active { + request_id: None, + revision: uuid::Uuid::new_v4(), + }); + match result { + Ok(response) => { + v.btw_state = Some(crate::views::btw_overlay::BtwOverlayState::done( + question, response, + )); + v.btw_focused = true; + } + Err(error) => { + v.btw_state = + Some(crate::views::btw_overlay::BtwOverlayState::Error { question, error }); + v.btw_focused = false; + } + } + true +} + +/// Invalidate and clear the complete minimal `/btw` lifecycle. +pub fn clear_minimal_btw(v: &mut AgentView) { + if v.minimal_btw_lifecycle.is_none() { + return; + } + v.minimal_btw_lifecycle = None; + v.btw_state = None; + v.btw_focused = false; + v.last_btw_area = Rect::default(); + v.last_btw_selection_model = Default::default(); + v.hit_btw_close.clear(); + clear_btw_drag_state(v); +} + +/// Clear text-drag state only when it belongs to the minimal `/btw` surface. +/// +/// Kept in this facade rather than widening the viewer module's private helper: +/// minimal already owns this lifecycle reset and is the only cross-module caller. +fn clear_btw_drag_state(v: &mut AgentView) { + let is_btw = v + .pending_text_drag + .is_some_and(|p| p.anchor.entry_idx == crate::views::btw_overlay::BTW_OVERLAY_ENTRY_IDX) + || v.drag_selection.as_ref().is_some_and(|d| { + d.anchor.entry_idx == crate::views::btw_overlay::BTW_OVERLAY_ENTRY_IDX + }); + if is_btw { + v.pending_text_drag = None; + v.drag_selection = None; + v.drag_autoscroll = None; + v.last_drag_mouse = None; + } +} + +/// Atomically suspend the complete lifecycle while another surface handles input. +pub(crate) fn suspend_minimal_btw(v: &mut AgentView) -> Option { + let MinimalBtwLifecycle::Active { + request_id, + revision, + } = v.minimal_btw_lifecycle? + else { + return None; + }; + let state = v.btw_state.take()?; + v.minimal_btw_lifecycle = Some(MinimalBtwLifecycle::Suspended { revision }); + let focused = std::mem::replace(&mut v.btw_focused, false); + Some(SuspendedMinimalBtwLifecycle { + state, + request_id, + revision, + focused, + }) +} + +/// Restore only if delegated handling left the same suspension marker intact. +pub(crate) fn restore_minimal_btw(v: &mut AgentView, suspended: SuspendedMinimalBtwLifecycle) { + if v.minimal_btw_lifecycle + != Some(MinimalBtwLifecycle::Suspended { + revision: suspended.revision, + }) + { + return; + } + v.btw_state = Some(suspended.state); + v.btw_focused = suspended.focused; + v.minimal_btw_lifecycle = Some(MinimalBtwLifecycle::Active { + request_id: suspended.request_id, + revision: suspended.revision, + }); +} + +/// `AgentView::btw_focused` — whether Up/Down/PgUp/PgDn scroll the `/btw` +/// panel (set when a Done answer arrives; cleared when the user returns to +/// the prompt). Minimal paints the focus ring / ↑↓ hint from this flag. +pub fn btw_focused(v: &AgentView) -> bool { + v.btw_focused +} + /// `AgentView::cancel_turn_view`. pub fn cancel_turn_view(v: &AgentView) -> Option<&CancelTurnViewState> { v.cancel_turn_view.as_ref() @@ -356,14 +562,21 @@ pub fn resolve_turn_activity(v: &AgentView) -> Option { v.resolve_turn_activity() } -/// [`AgentView::renders_parked`] — minimal renders the idle hint (not the -/// turn-status row) while the parked-wait marker's turn is parked, mirroring -/// the full TUI. The marker itself is pushed by the shared ACP notification -/// path, so minimal's scrollback carries it too. +/// [`AgentView::renders_parked`] — while the parked-wait marker's turn is +/// parked, minimal renders the "watching · …" cue (watchers running) or the +/// idle hint (none), mirroring the full TUI. The marker itself is pushed by +/// the shared ACP notification path, so minimal's scrollback carries it too. pub fn renders_parked(v: &AgentView) -> bool { v.renders_parked() } +/// [`AgentView::watchers`] — idle-surviving background work (running +/// commands / monitors / loops / subagents) for the shared turn-status +/// widget's "watching · …" cue. +pub fn watchers(v: &AgentView) -> crate::views::turn_status::Watchers { + v.watchers() +} + /// [`AgentView::held_queue_count`]. pub fn held_queue_count(v: &AgentView) -> usize { v.held_queue_count() @@ -483,6 +696,28 @@ pub fn mcp_status_label(status: &McpServerDisplayStatus) -> &'static str { // ── Session picker builders ────────────────────────────────────────────────── +/// Render a search bar from a [`PickerState`] using its grapheme-safe viewport. +pub fn render_picker_search_bar( + buf: &mut Buffer, + area: Rect, + theme: &Theme, + state: &PickerState, + show_hint: bool, + bg: Option, +) { + crate::views::picker::render_picker_search_bar( + buf, + area.x, + area.y, + area.width, + theme, + state, + state.search_active, + show_hint, + bg, + ); +} + /// [`crate::views::session_picker::repo_name_from_cwd`]. pub fn repo_name_from_cwd(cwd: &str) -> String { crate::views::session_picker::repo_name_from_cwd(cwd) diff --git a/crates/codegen/xai-grok-pager/src/scrollback/block.rs b/crates/codegen/xai-grok-pager/src/scrollback/block.rs index f6ae20f..5a7e9ab 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/block.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/block.rs @@ -1134,7 +1134,7 @@ impl RenderBlock { RenderBlock::AgentMessage(b) => join_searchable([Some(b.copy_text(false))]), RenderBlock::Thinking(b) => join_searchable([Some(b.copy_text(false))]), RenderBlock::System(b) => join_searchable([Some(b.text.clone())]), - RenderBlock::SessionEvent(b) => join_searchable([Some(b.marker_text())]), + RenderBlock::SessionEvent(b) => join_searchable([Some(b.event.message())]), RenderBlock::BgTask(b) => { join_searchable([Some(b.command.clone()), b.description.clone()]) } diff --git a/crates/codegen/xai-grok-pager/src/scrollback/blocks/mod.rs b/crates/codegen/xai-grok-pager/src/scrollback/blocks/mod.rs index 3682b30..c5647bc 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/blocks/mod.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/blocks/mod.rs @@ -22,7 +22,7 @@ pub use bg_task::{BgTaskBlock, BgTaskKind}; pub use btw::BtwBlock; pub use context_info::ContextInfoBlock; pub use credit_limit::{CreditLimitBlock, CreditLimitCardAction}; -pub use session_event::{EndWork, SessionEvent, SessionEventBlock}; +pub use session_event::{SessionEvent, SessionEventBlock}; pub use subagent::{SubagentBlock, SubagentBlockKind}; pub use system::SystemMessageBlock; pub use thinking::ThinkingBlock; diff --git a/crates/codegen/xai-grok-pager/src/scrollback/blocks/session_event.rs b/crates/codegen/xai-grok-pager/src/scrollback/blocks/session_event.rs index 58b6832..b084633 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/blocks/session_event.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/blocks/session_event.rs @@ -142,10 +142,11 @@ impl SessionEvent { /// Format the event as a human-readable string. pub fn message(&self) -> String { match self { + // Deliberately period-less — don't re-punctuate. SessionEvent::TurnCompleted { elapsed: Some(elapsed), } => { - format!("Worked for {}.", format_duration(*elapsed)) + format!("Worked for {}", format_duration(*elapsed)) } SessionEvent::TurnCompleted { elapsed: None } => "Turn completed.".to_string(), SessionEvent::TurnCancelled { elapsed } => { @@ -294,64 +295,6 @@ fn format_tokens(tokens: u64) -> String { } } -/// Background work counted on a turn-end marker's work suffix -/// ("1 command, 1 monitor and 2 subagents still running."). A static -/// snapshot taken at push time — nothing mutates it; a later completion -/// re-emits a fresh work-only status line when no wake response follows, -/// and otherwise gets the wake turn's own end marker (see the pager's -/// `push_wake_end_marker`). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub struct EndWork { - /// Background bash commands still running (`BgTaskStatus::Running`, - /// non-monitor). - pub running_commands: usize, - /// Background monitors still running — counted apart from commands so a - /// long-lived watch reads as "1 monitor", not a stuck command. - pub running_monitors: usize, - /// Subagents still running. - pub running_subagents: usize, -} - -impl EndWork { - /// `None` at all-zero counts — the one spelling of "a workless marker - /// carries no `end_work`", so it renders the legacy text and closes the - /// between-turns status window. - pub fn nonzero(self) -> Option { - (self != Self::default()).then_some(self) - } - - /// The "N … still running." line, `None` when every count is zero so a - /// marker collapses to the plain legacy completion text (and no - /// between-turns status line is emitted). Doubles as the work-only - /// status line re-emitted after a background completion. Ends with a - /// period, never an ellipsis: the marker states a settled fact, not a - /// progress spinner. - pub fn still_running_text(&self) -> Option { - let count = |n: usize, noun: &str| -> Option { - match n { - 0 => None, - 1 => Some(format!("1 {noun}")), - n => Some(format!("{n} {noun}s")), - } - }; - let parts: Vec = [ - count(self.running_commands, "command"), - count(self.running_monitors, "monitor"), - count(self.running_subagents, "subagent"), - ] - .into_iter() - .flatten() - .collect(); - let (last, head) = parts.split_last()?; - let joined = if head.is_empty() { - last.clone() - } else { - format!("{} and {last}", head.join(", ")) - }; - Some(format!("{joined} still running.")) - } -} - /// Block that renders a [`SessionEvent`] in scrollback. /// /// Visually identical to [`super::SystemMessageBlock`] (muted text, compact, @@ -369,11 +312,6 @@ pub struct SessionEventBlock { /// The prompt turn a terminal marker belongs to, when known. Gates /// which stop-hook batches may merge into it. pub prompt_id: Option, - /// Work counts appended to a turn-end marker as a "still running" - /// suffix, snapshotted at push time. `None` for every non-marker event - /// and for markers pushed with no work in flight — those render the - /// legacy text unchanged. - pub end_work: Option, /// The marker was pushed at park time (user-interruptible blocking /// wait): the turn is still running shell-side, so it must never accept /// stop hooks. Rendering is unchanged — a parked wait reads as stopped — @@ -388,7 +326,6 @@ impl SessionEventBlock { event, stop_hooks: Vec::new(), prompt_id: None, - end_work: None, parked: false, } } @@ -404,7 +341,6 @@ impl SessionEventBlock { event, stop_hooks, prompt_id, - end_work: None, parked: false, } } @@ -416,17 +352,6 @@ impl SessionEventBlock { self.event.is_turn_terminal() && !self.parked } - /// The marker line text: the turn-scoped prefix ("Worked for X.") - /// plus the work suffix ("1 command still running.") snapshotted at push - /// time. No `end_work` (or all-zero counts) is byte-identical to the - /// legacy event text. - pub fn marker_text(&self) -> String { - match self.end_work.as_ref().and_then(EndWork::still_running_text) { - Some(work) => format!("{} {work}", self.event.message()), - None => self.event.message(), - } - } - /// Whether any attached stop hook actually ran (non-skipped). Gates the /// fold/selection affordances and the inline summary, mirroring /// [`ToolCallHookData::has_content`](super::tool::ToolCallHookData::has_content). @@ -453,7 +378,7 @@ impl SessionEventBlock { /// /// The summary spans are decoration — [`Selectable::Spans`] keeps /// drag-copy on the marker text only, so a copied "Worked for - /// 4.4s." never drags the padding and hook counts along. + /// 4.4s" never drags the padding and hook counts along. fn append_stop_hooks(&self, lines: &mut Vec, ctx: &BlockContext) { use super::tool::hook::{render_hooks_for_mode, render_stop_hooks_summary}; @@ -621,7 +546,7 @@ impl BlockContent for SessionEventBlock { theme.muted() }; - let text = self.marker_text(); + let text = self.event.message(); let wrapped = if text.contains('\n') { let input_lines = text .split('\n') @@ -743,7 +668,7 @@ mod tests { let event = SessionEvent::TurnCompleted { elapsed: Some(Duration::from_secs(125)), }; - assert_eq!(event.message(), "Worked for 2m5s."); + assert_eq!(event.message(), "Worked for 2m5s"); } #[test] @@ -1244,7 +1169,7 @@ mod tests { assert_eq!(out.lines.len(), 1, "collapsed marker stays a single line"); let text = plain(&out.lines[0]); assert!( - text.starts_with("Worked for 5.0s."), + text.starts_with("Worked for 5.0s"), "marker text keeps the left edge: {text}" ); assert!( @@ -1264,14 +1189,14 @@ mod tests { ); assert_eq!( out.lines[0].selection_text.as_deref(), - Some("Worked for 5.0s.") + Some("Worked for 5.0s") ); } #[test] fn stop_hooks_summary_wraps_to_own_line_when_narrow() { let block = completed_with_stop_hooks(); - // "Worked for 5.0s." is 16 cols; the summary is 16 — no room + // "Worked for 5.0s" is 15 cols; the summary is 16 — no room // at width 30, so the summary right-justifies on its own line. let out = block.output(&BlockContext { mode: DisplayMode::Collapsed, @@ -1382,7 +1307,7 @@ mod tests { mode: DisplayMode::Collapsed, ..ctx() }); - assert_eq!(plain(&out.lines[0]), "Worked for 5.0s."); + assert_eq!(plain(&out.lines[0]), "Worked for 5.0s"); } #[test] @@ -1418,78 +1343,22 @@ mod tests { ); } - /// A marker block with snapshotted work counts (parked by default — the - /// shape `maybe_push_parked_marker` pushes). - fn marker_with_work(commands: usize, monitors: usize, subagents: usize) -> SessionEventBlock { + /// A parked marker block — the shape `maybe_push_parked_marker` pushes. + fn parked_marker() -> SessionEventBlock { SessionEventBlock { event: SessionEvent::TurnCompleted { elapsed: Some(Duration::from_secs(24)), }, stop_hooks: Vec::new(), prompt_id: None, - end_work: Some(EndWork { - running_commands: commands, - running_monitors: monitors, - running_subagents: subagents, - }), parked: true, } } - #[test] - fn end_work_suffix_names_actual_pending_work() { - let msg = |c, m, s| marker_with_work(c, m, s).marker_text(); - assert_eq!(msg(1, 0, 0), "Worked for 24s. 1 command still running."); - assert_eq!(msg(0, 0, 1), "Worked for 24s. 1 subagent still running."); - assert_eq!(msg(3, 0, 0), "Worked for 24s. 3 commands still running."); - assert_eq!( - msg(2, 0, 1), - "Worked for 24s. 2 commands and 1 subagent still running." - ); - // Monitors are counted apart from commands so a long-lived watch - // reads as "1 monitor", not a stuck command. - assert_eq!(msg(0, 1, 0), "Worked for 24s. 1 monitor still running."); - assert_eq!( - msg(1, 1, 2), - "Worked for 24s. 1 command, 1 monitor and 2 subagents still running." - ); - } - - #[test] - fn end_work_suffix_disappears_at_zero_counts() { - // All-zero counts (a sleep-park with nothing running) and a legacy - // `end_work: None` marker must both render the byte-identical plain - // completion text. - assert_eq!(marker_with_work(0, 0, 0).marker_text(), "Worked for 24s."); - assert_eq!( - SessionEventBlock::new(SessionEvent::TurnCompleted { - elapsed: Some(Duration::from_secs(24)), - }) - .marker_text(), - "Worked for 24s." - ); - } - - #[test] - fn still_running_text_is_the_work_only_status_form() { - // The between-turns status line reuses the marker suffix verbatim - // (no turn prefix), and stays absent at zero counts. - let work = EndWork { - running_commands: 2, - running_monitors: 0, - running_subagents: 1, - }; - assert_eq!( - work.still_running_text().as_deref(), - Some("2 commands and 1 subagent still running.") - ); - assert_eq!(EndWork::default().still_running_text(), None); - } - #[test] fn parked_markers_never_accept_stop_hooks() { // A parked marker renders mid-turn, before any Stop hook fires. - let block = marker_with_work(1, 0, 0); + let block = parked_marker(); assert!(!block.accepts_stop_hooks(), "parked marker refuses hooks"); // The real terminal marker accepts. @@ -1506,14 +1375,12 @@ mod tests { } #[test] - fn parked_marker_output_reads_as_completed_with_work_suffix() { - // The rendered block line matches `marker_text` (the suffix rides the - // same muted line, wrapped like any other session event). - let block = marker_with_work(1, 0, 2); + fn parked_marker_output_reads_as_plain_completed_marker() { + // The parked marker renders the plain event text — still-running + // background work is the status row's "watching · …" cue, never a + // transcript suffix. + let block = parked_marker(); let out = block.output(&ctx()); - assert_eq!( - plain(&out.lines[0]), - "Worked for 24s. 1 command and 2 subagents still running." - ); + assert_eq!(plain(&out.lines[0]), "Worked for 24s"); } } diff --git a/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/edit.rs b/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/edit.rs index 9b51915..58a2d7a 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/edit.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/edit.rs @@ -206,17 +206,32 @@ fn render_diff_hunks_core( let layout = gutter_layout(hunk, config); let indent_width = if config.indent { INDENT.len() } else { 0 }; let content_width = (width as usize).saturating_sub(layout.total); - // Fresh per-hunk highlighter, same as the hunk-only phase always did. - let mut highlighter = syntect.highlight_lines_by_file_path(path); + // A diff interleaves two file versions; give each side its own highlighter + // so a multi-line construct can't leak across sides. Equal lines render on + // the new side and advance both. + let mut old_highlighter = syntect.highlight_lines_by_file_path(path); + let mut new_highlighter = syntect.highlight_lines_by_file_path(path); for line in hunk { let trimmed = line.text.trim_end_matches(['\r', '\n']); - let raw_text = expand_tabs(trimmed); + let text = expand_tabs(trimmed); // Cold spans render unconditionally so Delete lines and any map // miss (text drift) paint exactly like the hunk-only phase. - let mut content_spans = - render_content_spans(&raw_text, line.tag, theme, &mut highlighter, syntect); + let mut content_spans = match line.tag { + ChangeTag::Delete => { + render_content_spans(&text, line.tag, theme, &mut old_highlighter, syntect) + } + ChangeTag::Insert => { + render_content_spans(&text, line.tag, theme, &mut new_highlighter, syntect) + } + ChangeTag::Equal => { + let spans = + render_content_spans(&text, line.tag, theme, &mut new_highlighter, syntect); + advance_highlighter(&mut old_highlighter, &text, syntect); + spans + } + }; if let Some(map) = by_new_line - && let Some(spans) = map_spans_for_line(line, &raw_text, map, theme) + && let Some(spans) = map_spans_for_line(line, &text, map, theme) { content_spans = spans; } @@ -647,6 +662,16 @@ fn painted(text: &str, style: Style) -> Span<'static> { Span::styled(text.to_string(), style) } +fn advance_highlighter( + highlighter: &mut Option>, + content: &str, + syntect: &Syntect, +) { + if let Some(hl) = highlighter.as_mut() { + let _ = hl.highlight_line(&format!("{content}\n"), &syntect.syntax_set); + } +} + /// Render content spans with syntax highlighting. fn render_content_spans( content: &str, @@ -2535,6 +2560,57 @@ class ProcessQueueItem(BaseModel): ); } + /// Regression: a `"""` opened on a removed line must not change how the + /// added line highlights. The two diff sides are highlighted independently. + #[test] + fn delete_side_multiline_string_does_not_leak_into_insert() { + let _guard = pin_groknight_syntect(); + let path = Path::new("probe.py"); + let config = DiffRenderConfig::default(); + let theme = Theme::groknight(); + + // Content spans of the added `def` line, given the removed line above it. + let added_def = |removed: &str| -> Vec<(ratatui::style::Color, String)> { + let hunk = vec![ + DiffLine { + text: format!("{removed}\n"), + lo: 1, + ln: 0, + tag: ChangeTag::Delete, + }, + DiffLine { + text: "def parse(x: str) -> int:\n".into(), + lo: 0, + ln: 1, + tag: ChangeTag::Insert, + }, + ]; + let rows = render_diff_hunk_highlighted(&hunk, path, &theme, 120, &config); + let insert = rows.last().expect("insert row"); + insert.line.spans[insert.gutter_span_count..] + .iter() + .map(|span| { + ( + span.style.fg.unwrap_or(ratatui::style::Color::Reset), + span.content.to_string(), + ) + }) + .collect() + }; + + let after_open_docstring = added_def(" \"\"\"Old docstring opener"); + let after_plain_code = added_def(" x = 1"); + assert_eq!( + after_open_docstring, after_plain_code, + "added line must be highlighted independently of the removed side", + ); + // Under the bug the added line is one string span; the fix keeps it code. + assert!( + after_open_docstring.len() > 1, + "added def line should be syntax highlighted" + ); + } + /// Build a DiffHunk spanning the closing `"""` through end of the fixture. fn fixture_python_close_hunk() -> (String, DiffHunk, usize) { let (file, close_ln) = fixture_python_parts(); diff --git a/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/execute.rs b/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/execute.rs index faab7e0..aa056ae 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/execute.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/blocks/tool/execute.rs @@ -305,7 +305,7 @@ impl ExecuteToolCallBlock { }; let mut spans = vec![Span::styled("Run ", label_style)]; if self.bash_mode { - // Same style as session event messages (e.g. "Worked for 2.3s.") + // Same style as session event messages (e.g. "Worked for 2.3s") spans.push(Span::styled("(user) ", theme.muted())); } // Single ratatui Line — never pass raw newlines (callers that need diff --git a/crates/codegen/xai-grok-pager/src/scrollback/search.rs b/crates/codegen/xai-grok-pager/src/scrollback/search.rs index 701ead7..c5fe0dc 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/search.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/search.rs @@ -8,8 +8,8 @@ //! //! The cache is shared as an `Arc<[IndexedEntry]>` so it can be handed to a //! background [`SearchDaemon`] without re-cloning the strings. The daemon runs -//! the regex scan off the input thread: [`ScrollbackSearchState::update_query`] -//! only enqueues the latest corpus and query (O(1) on the UI thread), and +//! the regex scan off the input thread: query mutations only enqueue the latest +//! corpus and query (O(1) on the UI thread), and //! [`ScrollbackSearchState::poll`] picks up results once the scan completes. //! This keeps per-keystroke typing responsive on long sessions where a //! synchronous scan would stall the input thread. @@ -23,8 +23,11 @@ use std::sync::{ }; use std::thread::{self, JoinHandle}; +use crossterm::event::KeyEvent; + use super::entry::EntryId; use super::state::ScrollbackState; +use crate::input::line_editor::{LineEditOutcome, LineEditor}; use crate::search::{QueryKind, TextMatcher}; /// A located query match within the scrollback. @@ -147,15 +150,14 @@ fn scan_matches(entries: &[IndexedEntry], matcher: &TextMatcher) -> Vec, - generation: usize, + request_generation: u64, /// The query these matches were computed for. `poll` compares it against - /// the live matcher and drops results for a query the user has already + /// the live editor and drops results for a query the user has already /// typed past (the scan was in flight while they kept editing), so the /// match count / cursor never desync from the visible query. query: String, @@ -173,6 +175,8 @@ enum SearchMsg { corpus: Option>, /// Query to scan for. query: String, + /// UI-owned request identity. + request_generation: u64, }, /// Shut the daemon thread down. Stop, @@ -183,6 +187,7 @@ enum SearchMsg { struct DrainedUpdate { corpus: Option>, query: Option, + request_generation: Option, stop: bool, } @@ -196,11 +201,16 @@ fn drain_to_latest(first: SearchMsg, rx: &Receiver) -> DrainedUpdate let mut msg = first; loop { match msg { - SearchMsg::Update { corpus, query } => { + SearchMsg::Update { + corpus, + query, + request_generation, + } => { if corpus.is_some() { out.corpus = corpus; } out.query = Some(query); + out.request_generation = Some(request_generation); } SearchMsg::Stop => { out.stop = true; @@ -239,8 +249,6 @@ impl SearchDaemon { let handle = thread::spawn(move || { let mut corpus: Arc<[IndexedEntry]> = Arc::from([]); let mut query = String::new(); - let mut generation: usize = 0; - while let Ok(msg) = rx.recv() { let update = drain_to_latest(msg, &rx); if update.stop { @@ -252,6 +260,9 @@ impl SearchDaemon { if let Some(new_query) = update.query { query = new_query; } + let Some(request_generation) = update.request_generation else { + continue; + }; // Every non-`Stop` burst carries a query, so rescan once here. // Compile + scan off the lock; the mutex only guards the quick @@ -262,10 +273,9 @@ impl SearchDaemon { } else { scan_matches(&corpus, &matcher).into() }; - generation += 1; *out.lock().unwrap() = SearchSnapshot { matches, - generation, + request_generation, query: query.clone(), }; } @@ -286,9 +296,9 @@ impl Drop for SearchDaemon { } } -/// An interactive search session over the scrollback: owns the query matcher, -/// the cached index, the background scan daemon, the latest match list, and a -/// cursor into it. +/// An interactive search session over the scrollback: owns the query editor, +/// derived matcher, cached index, background scan daemon, latest match list, +/// and a cursor into it. /// /// Matching runs off-thread. [`update_query`](Self::update_query) only enqueues /// the corpus and query for the daemon (it never scans); results arrive later @@ -303,11 +313,12 @@ impl Drop for SearchDaemon { /// is deliberately no `cancel` method. #[derive(Debug)] pub struct ScrollbackSearchState { + /// Canonical editable query and cursor. + editor: LineEditor, /// Cached searchable text; re-synced lazily on content change, never per /// frame. Lives on the UI thread and is handed to the daemon as an `Arc`. index: ScrollbackSearchIndex, - /// Compiled query, kept UI-side for the highlight pass, error state, and - /// echoing the query back to the search bar. + /// Compiled query derived from `editor` for highlighting and error state. matcher: TextMatcher, /// Latest matches from the daemon, in scrollback order. matches: Arc<[ScrollbackMatch]>, @@ -318,8 +329,11 @@ pub struct ScrollbackSearchState { composing: bool, /// Background scan thread; dropped (and stopped) when the session closes. daemon: SearchDaemon, - /// Snapshot generation last consumed by `poll`, to detect new results. - last_seen_generation: usize, + /// Snapshot request generation last observed by `poll`. + last_seen_generation: u64, + /// Monotonic UI-owned request generation, incremented only when enqueuing + /// query/corpus work. + request_generation: u64, } impl ScrollbackSearchState { @@ -327,6 +341,7 @@ impl ScrollbackSearchState { /// first [`update_query`](Self::update_query) result is [`poll`](Self::poll)ed in. pub fn open() -> Self { Self { + editor: LineEditor::default(), index: ScrollbackSearchIndex::new(), matcher: TextMatcher::new("", QueryKind::Regex), matches: Arc::from([]), @@ -334,28 +349,47 @@ impl ScrollbackSearchState { composing: true, daemon: SearchDaemon::new(), last_seen_generation: 0, + request_generation: 0, } } - /// Recompile `query` UI-side (for the highlight pass) and enqueue the latest - /// corpus and query for the background scan. Cheap and non-blocking: the - /// scan runs off-thread and results land via [`poll`](Self::poll). + /// Replace the canonical query, recompile its derived matcher, and enqueue + /// the latest corpus/query snapshot for the background scan. /// /// The corpus is only re-synced and re-sent when scrollback content changed /// since the last send, so steady-state keystrokes just push a query string. pub fn update_query(&mut self, query: &str, state: &ScrollbackState) { + self.editor.set_text(query); + self.update_derived_query(state); + } + + fn update_derived_query(&mut self, state: &ScrollbackState) { + let query = self.editor.text().to_owned(); // Compile UI-side: the render layer highlights from this matcher, and it // backs `query` / `has_error`, all of which must update immediately. - self.matcher = TextMatcher::new(query, QueryKind::Regex); + self.matcher = TextMatcher::new(query.as_str(), QueryKind::Regex); + // Keep the last settled result navigable while the next scan is in + // flight. Empty and malformed queries are known synchronously to have + // no matches, so those can clear immediately. + if query.is_empty() || self.matcher.is_error() { + self.matches = Arc::from([]); + self.current = None; + } // Re-send the corpus only when `sync` actually rebuilt it (content // changed); otherwise `None` tells the daemon to keep the corpus it // already holds. The index itself guards the rebuild on // `content_generation`, so no separate generation tracking is needed. let corpus = self.index.sync(state).then(|| self.index.entries_arc()); + let Some(request_generation) = self.request_generation.checked_add(1) else { + tracing::debug!("scrollback search request generation exhausted; dropping update"); + return; + }; + self.request_generation = request_generation; if let Err(err) = self.daemon.tx.send(SearchMsg::Update { corpus, - query: query.to_string(), + query, + request_generation, }) { // A failed send means the daemon thread is gone (panicked or already // stopped) — search has silently stopped working, so leave a trace. @@ -363,6 +397,35 @@ impl ScrollbackSearchState { } } + pub(crate) fn apply_query_key( + &mut self, + key: &KeyEvent, + state: &ScrollbackState, + ) -> LineEditOutcome { + let outcome = self.editor.handle_key(key); + if outcome == LineEditOutcome::TextChanged { + self.update_derived_query(state); + } + outcome + } + + pub(crate) fn apply_query_paste( + &mut self, + text: &str, + state: &ScrollbackState, + ) -> LineEditOutcome { + let outcome = self.editor.insert_paste(text); + if outcome == LineEditOutcome::TextChanged { + self.update_derived_query(state); + } + outcome + } + + /// Apply one canonical query-edit key. Returns whether the key was consumed. + pub fn handle_query_key(&mut self, key: &KeyEvent, state: &ScrollbackState) -> bool { + self.apply_query_key(key, state) != LineEditOutcome::Unhandled + } + /// Pick up the latest scan results from the daemon. Returns `true` when the /// results changed (so the caller can redraw / reveal the new match). /// @@ -373,15 +436,15 @@ impl ScrollbackSearchState { // Arc-pointer clone). Never clone the whole snapshot: that would heap- // allocate its `query: String` on every no-change tick at ~30 Hz. let guard = self.daemon.shared.lock().unwrap(); - if guard.generation == self.last_seen_generation { + if guard.request_generation == self.last_seen_generation { return false; } - self.last_seen_generation = guard.generation; + self.last_seen_generation = guard.request_generation; // Drop a scan that finished for a superseded query: while it was in - // flight the user kept typing, so the matcher (search bar + highlight) - // has already moved on. Applying it would desync the match count and + // flight the user kept typing, so the editor and derived matcher have + // already moved on. Applying it would desync the match count and // cursor from the visible query until the current query's scan lands. - if guard.query != self.matcher.query() { + if guard.request_generation != self.request_generation || guard.query != self.query() { return false; } let matches = guard.matches.clone(); @@ -437,14 +500,18 @@ impl ScrollbackSearchState { /// The raw query string being searched. pub fn query(&self) -> &str { - self.matcher.query() + self.editor.text() + } + + pub fn query_viewport(&self, width: usize) -> xai_ratatui_textarea::SingleLineViewport { + self.editor.viewport(width) } /// The compiled query regex for the highlight pass, or `None` when the /// query is empty or fails to compile (nothing to highlight). Decoupled /// from the index — the render layer re-runs it per visible row. pub fn highlight_regex(&self) -> Option { - (!self.matcher.query().is_empty() && !self.matcher.is_error()) + (!self.query().is_empty() && !self.matcher.is_error()) .then(|| self.matcher.compiled_regex().clone()) } @@ -465,6 +532,7 @@ mod tests { use super::*; use crate::scrollback::block::RenderBlock; use crate::search::QueryKind; + use crossterm::event::{KeyCode, KeyModifiers}; fn substring(query: &str) -> TextMatcher { TextMatcher::new(query, QueryKind::Substring) @@ -725,6 +793,84 @@ mod tests { assert_eq!(search.current_index(), None); } + #[test] + fn cursor_only_query_edits_do_not_enqueue_daemon_work() { + let state = state_with(&["foo bar"]); + let mut search = ScrollbackSearchState::open(); + update_and_wait(&mut search, "foo", &state); + let generation = search.request_generation; + + let outcome = + search.apply_query_key(&KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), &state); + assert_eq!(outcome, LineEditOutcome::CursorChanged); + assert_eq!(search.query(), "foo"); + assert_eq!(search.request_generation, generation); + assert_eq!(search.match_count(), 1); + + let outcome = search.apply_query_key( + &KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), + &state, + ); + assert_eq!(outcome, LineEditOutcome::TextChanged); + assert_eq!(search.query(), "foxo"); + assert_eq!(search.request_generation, generation + 1); + assert_eq!(search.match_count(), 1); + assert_eq!(search.current_index(), Some(0)); + search.next(); + assert_eq!(search.current_index(), Some(0)); + + let mut settled = false; + for _ in 0..1000 { + if search.poll() { + settled = true; + break; + } + std::thread::sleep(std::time::Duration::from_millis(1)); + } + assert!(settled, "daemon did not publish the text mutation"); + assert_eq!(search.match_count(), 0); + assert_eq!(search.current_index(), None); + assert_eq!( + search.daemon.shared.lock().unwrap().request_generation, + generation + 1 + ); + } + + #[test] + fn paste_sanitizes_at_cursor_and_enqueues_only_text_mutation() { + let state = state_with(&["alpha beta"]); + let mut search = ScrollbackSearchState::open(); + search.update_query("ab", &state); + let _ = search.editor.set_cursor_byte(1); + let generation = search.request_generation; + + let outcome = search.apply_query_paste("中\r\n", &state); + assert_eq!(outcome, LineEditOutcome::TextChanged); + assert_eq!(search.query(), "a中b"); + assert_eq!(search.request_generation, generation + 1); + + let outcome = search.apply_query_paste("\r\n", &state); + assert_eq!(outcome, LineEditOutcome::HandledNoChange); + assert_eq!(search.query(), "a中b"); + assert_eq!(search.request_generation, generation + 1); + } + + #[test] + fn query_viewport_preserves_graphemes_and_actual_cursor() { + let state = state_with(&["anything"]); + let mut search = ScrollbackSearchState::open(); + let grapheme = "👩🏽\u{200d}💻"; + search.update_query(&format!("123456中e\u{301}{grapheme}z"), &state); + let cursor_byte = search.query().len() - 1; + let _ = search.editor.set_cursor_byte(cursor_byte); + let viewport = search.query_viewport(10); + let visible = &search.query()[viewport.visible_byte_range]; + assert!(visible.contains('中')); + assert!(visible.contains("e\u{301}")); + assert!(visible.contains(grapheme)); + assert!(viewport.cursor_display_column < 10); + } + #[test] fn update_query_picks_up_content_added_mid_session() { // Content appended while a session is open changes `content_generation`, @@ -733,9 +879,11 @@ mod tests { let mut search = ScrollbackSearchState::open(); update_and_wait(&mut search, "foo", &state); assert_eq!(search.match_count(), 1); + let generation = search.request_generation; state.push_block(RenderBlock::user_prompt("foo two")); update_and_wait(&mut search, "foo", &state); + assert_eq!(search.request_generation, generation + 1); assert_eq!(search.match_count(), 2); } @@ -848,16 +996,19 @@ mod tests { tx.send(SearchMsg::Update { corpus: Some(c1), query: "a".into(), + request_generation: 1, }) .unwrap(); tx.send(SearchMsg::Update { corpus: None, query: "ab".into(), + request_generation: 2, }) .unwrap(); tx.send(SearchMsg::Update { corpus: Some(c2.clone()), query: "abc".into(), + request_generation: 3, }) .unwrap(); @@ -866,6 +1017,7 @@ mod tests { assert!(!out.stop); assert_eq!(out.query.as_deref(), Some("abc"), "newest query wins"); + assert_eq!(out.request_generation, Some(3)); assert!( std::sync::Arc::ptr_eq(&out.corpus.unwrap(), &c2), "newest Some(corpus) wins" @@ -879,11 +1031,13 @@ mod tests { tx.send(SearchMsg::Update { corpus: Some(c1.clone()), query: "a".into(), + request_generation: 1, }) .unwrap(); tx.send(SearchMsg::Update { corpus: None, query: "ab".into(), + request_generation: 2, }) .unwrap(); @@ -895,6 +1049,7 @@ mod tests { "a later None corpus must not clobber the earlier corpus" ); assert_eq!(out.query.as_deref(), Some("ab")); + assert_eq!(out.request_generation, Some(2)); } #[test] @@ -903,6 +1058,7 @@ mod tests { tx.send(SearchMsg::Update { corpus: None, query: "a".into(), + request_generation: 1, }) .unwrap(); tx.send(SearchMsg::Stop).unwrap(); @@ -914,13 +1070,14 @@ mod tests { } #[test] - fn poll_drops_results_for_a_superseded_query() { + fn poll_rejects_same_query_and_aba_stale_snapshots() { // The daemon parks on recv() until a message is sent; since this test // never calls update_query, the shared snapshot is uncontested and we - // can simulate a scan landing for an old query without a race. + // can publish snapshots in a deterministic order. let mut search = ScrollbackSearchState::open(); - // The user has typed "current"; the search bar + highlight reflect it. - search.matcher = TextMatcher::new("current", QueryKind::Regex); + search.editor.set_text("A"); + search.matcher = TextMatcher::new("A", QueryKind::Regex); + search.request_generation = 3; let mut state = ScrollbackState::new(); let id = state.push_block(RenderBlock::user_prompt("x")); @@ -930,26 +1087,30 @@ mod tests { byte_range: 0..1, }; - // A scan for the now-superseded query finishes: poll must ignore it. + // Generation 1 used the same visible query but an older corpus. *search.daemon.shared.lock().unwrap() = SearchSnapshot { matches: std::sync::Arc::from([a_match.clone()]), - generation: 1, - query: "curren".into(), + request_generation: 1, + query: "A".into(), }; - assert!( - !search.poll(), - "a snapshot for a superseded query is dropped" - ); + assert!(!search.poll(), "same-query stale corpus result is dropped"); + assert_eq!(search.match_count(), 0); + + // Generation 2 is the intermediate B in an A→B→A sequence. + *search.daemon.shared.lock().unwrap() = SearchSnapshot { + matches: std::sync::Arc::from([a_match.clone()]), + request_generation: 2, + query: "B".into(), + }; + assert!(!search.poll(), "intermediate B result is dropped"); assert_eq!(search.match_count(), 0); - assert_eq!(search.current_index(), None); - // The current query's scan lands: poll applies it. *search.daemon.shared.lock().unwrap() = SearchSnapshot { matches: std::sync::Arc::from([a_match]), - generation: 2, - query: "current".into(), + request_generation: 3, + query: "A".into(), }; - assert!(search.poll(), "a snapshot for the current query is applied"); + assert!(search.poll(), "current A generation is applied"); assert_eq!(search.match_count(), 1); assert_eq!(search.current_index(), Some(0)); } diff --git a/crates/codegen/xai-grok-pager/src/scrollback/state/mod.rs b/crates/codegen/xai-grok-pager/src/scrollback/state/mod.rs index 3d97011..08dbccf 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/state/mod.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/state/mod.rs @@ -808,14 +808,6 @@ impl ScrollbackState { None } - /// Whether a turn-terminal marker stamped with `prompt_id` is in scrollback. - pub fn has_turn_terminal_marker_with_pid(&self, prompt_id: &str) -> bool { - self.entries.iter().any(|(_, entry)| { - matches!(&entry.block, RenderBlock::SessionEvent(b) - if b.event.is_turn_terminal() && b.prompt_id.as_deref() == Some(prompt_id)) - }) - } - /// Merge a stop/stop_failure hook batch into a turn-terminal marker /// entry and collapse it so the right-justified summary — not the /// fold-out detail — is the resting state. Returns `false` unless the @@ -856,44 +848,6 @@ impl ScrollbackState { true } - /// Refresh an uncommitted parked marker after a subagent completion. - pub(crate) fn refresh_parked_subagent_marker( - &mut self, - id: EntryId, - prompt_id: &str, - elapsed: std::time::Duration, - end_work: super::blocks::EndWork, - ) -> bool { - if self.is_committed(id) { - return false; - } - let Some(end_work) = end_work.nonzero() else { - return false; - }; - let Some(entry) = self.entries.get_mut(&id) else { - return false; - }; - let RenderBlock::SessionEvent(block) = &mut entry.block else { - return false; - }; - if !block.parked - || block.prompt_id.as_deref() != Some(prompt_id) - || !matches!( - &block.event, - super::blocks::SessionEvent::TurnCompleted { .. } - ) - { - return false; - } - block.event = super::blocks::SessionEvent::TurnCompleted { - elapsed: Some(elapsed), - }; - block.end_work = Some(end_work); - entry.invalidate_cache(); - self.mark_height_dirty(id); - true - } - /// Push a text chunk to an agent message entry. /// /// This is the preferred way to append streaming content because it: @@ -1919,12 +1873,11 @@ pub(super) mod test_util { self.state.set_selected(Some(idx)); } + /// Send with page-flip on (default product behavior). pub(super) fn send_prompt(&mut self, text: &str) -> EntryId { let id = self.state.push_block(RenderBlock::user_prompt(text)); let prompt_idx = self.state.len().saturating_sub(1); - self.state.set_selected(Some(prompt_idx)); - self.state.scroll_to_entry_top(prompt_idx); - self.state.enable_follow_with_preserve(); + self.state.follow_new_turn(Some(prompt_idx), true); self.frame(); id } @@ -2293,34 +2246,6 @@ mod tests { ); } - #[test] - fn subagent_marker_refresh_does_not_mutate_terminal_marker() { - use crate::scrollback::blocks::{EndWork, SessionEvent, SessionEventBlock}; - - let mut state = ScrollbackState::new(); - let mut block = SessionEventBlock::new(SessionEvent::TurnCompleted { - elapsed: Some(std::time::Duration::from_secs(1)), - }); - block.prompt_id = Some("p1".into()); - let marker = state.push_block(RenderBlock::SessionEvent(block)); - - assert!(!state.refresh_parked_subagent_marker( - marker, - "p1", - std::time::Duration::from_secs(2), - EndWork { - running_subagents: 1, - ..EndWork::default() - }, - )); - - let RenderBlock::SessionEvent(block) = &state.get_by_id(marker).unwrap().block else { - panic!("expected terminal marker"); - }; - assert_eq!(block.marker_text(), "Worked for 1.0s."); - assert!(block.end_work.is_none()); - } - #[test] fn stop_hooks_respect_marker_prompt_id() { use crate::scrollback::blocks::tool::{HookRunEntry, HookRunStatus}; diff --git a/crates/codegen/xai-grok-pager/src/scrollback/state/nav.rs b/crates/codegen/xai-grok-pager/src/scrollback/state/nav.rs index 9e181f9..6644f09 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/state/nav.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/state/nav.rs @@ -570,6 +570,29 @@ impl ScrollbackState { self.follow_preserve_scroll = true; } + /// Viewport policy for a turn this client just started. + /// + /// - `page_flip` + prompt: pin at viewport top and arm follow-with-preserve. + /// - `page_flip` + no prompt (bash/synthetic): arm follow-with-preserve only. + /// - `!page_flip` + prompt: leave scroll and follow unchanged. + /// - `!page_flip` + no prompt: still arm follow-with-preserve (there is no + /// prompt to snap; pre-setting bash/adoption always engaged follow). + /// + /// Always selects `prompt_idx` when present. + pub fn follow_new_turn(&mut self, prompt_idx: Option, page_flip: bool) { + if page_flip { + if let Some(idx) = prompt_idx { + self.scroll_to_entry_top(idx); + } + self.enable_follow_with_preserve(); + } else if prompt_idx.is_none() { + self.enable_follow_with_preserve(); + } + if let Some(idx) = prompt_idx { + self.set_selected(Some(idx)); + } + } + /// Check if follow mode is enabled. pub fn is_follow_mode(&self) -> bool { self.follow_mode @@ -1169,6 +1192,63 @@ mod tests { use super::*; use pretty_assertions::assert_eq; + #[test] + fn follow_new_turn_scroll_policies() { + fn tall_state_with_prompt() -> (ScrollbackState, usize) { + let mut state = ScrollbackState::new(); + for i in 0..30 { + state.push_block(agent_block(&format!("filler line {i}"))); + } + state.push_block(user_block("next question")); + let prompt_idx = state.len() - 1; + state.prepare_layout(80, 8); + (state, prompt_idx) + } + + let (mut state, prompt_idx) = tall_state_with_prompt(); + state.goto_bottom(); + let bottom = state.scroll_offset(); + state.follow_new_turn(Some(prompt_idx), true); + assert!(state.is_follow_mode()); + assert!(state.is_follow_preserve_scroll()); + assert_eq!(state.selected(), Some(prompt_idx)); + assert_ne!(state.scroll_offset(), bottom); + + let (mut state, prompt_idx) = tall_state_with_prompt(); + state.goto_bottom(); + let bottom = state.scroll_offset(); + state.follow_new_turn(Some(prompt_idx), false); + assert!(state.is_follow_mode()); + assert!(!state.is_follow_preserve_scroll()); + assert_eq!(state.scroll_offset(), bottom); + assert_eq!(state.selected(), Some(prompt_idx)); + + let (mut state, prompt_idx) = tall_state_with_prompt(); + state.goto_bottom(); + state.scroll_up(10); + let reading = state.scroll_offset(); + state.follow_new_turn(Some(prompt_idx), false); + assert!(!state.is_follow_mode()); + assert_eq!(state.scroll_offset(), reading); + assert_eq!(state.selected(), Some(prompt_idx)); + + // No prompt (bash/synthetic): always arm follow, with or without page_flip. + let (mut state, _) = tall_state_with_prompt(); + state.goto_bottom(); + state.follow_new_turn(None, true); + assert!(state.is_follow_mode()); + assert!(state.is_follow_preserve_scroll()); + + let (mut state, _) = tall_state_with_prompt(); + state.goto_bottom(); + state.scroll_up(10); + let reading = state.scroll_offset(); + state.follow_new_turn(None, false); + assert!(state.is_follow_mode()); + assert!(state.is_follow_preserve_scroll()); + assert_eq!(state.scroll_offset(), reading); + } + #[test] fn test_response_anchor_trailing_run_skips_interleaved_messages() { let mut state = ScrollbackState::new(); diff --git a/crates/codegen/xai-grok-pager/src/settings/defs.rs b/crates/codegen/xai-grok-pager/src/settings/defs.rs index daca12f..89fd21d 100644 --- a/crates/codegen/xai-grok-pager/src/settings/defs.rs +++ b/crates/codegen/xai-grok-pager/src/settings/defs.rs @@ -134,12 +134,12 @@ const CODING_DATA_SHARING_CHOICES: &[EnumChoice] = &[ EnumChoice { canonical: "opt-in", display: "Opt in", - description: "Allow SpaceXAI to retain and use coding session data for training and product improvement.", + description: "Allow SpaceXAI to retain coding session data for model training and product improvement.", }, EnumChoice { canonical: "opt-out", display: "Opt out", - description: "Do not retain coding session data. Code requests will not be used for training.", + description: "Do not retain coding session data for training. Does not disable product analytics.", }, ]; @@ -596,6 +596,23 @@ pub fn default_settings() -> Vec { // Minimal mode has no interactive scrollback pane for the rail. hidden_in_minimal: true, }, + SettingMeta { + key: "page_flip_on_send", + category: SettingCategory::Appearance, + owner: SettingOwner::Shared, + label: "Snap prompt to top on send", + description: "When you send a prompt, scroll it to the top of the screen so the \ + response starts on a fresh page (default). Turn off to leave the scroll \ + position unchanged when you send.", + keywords: &[ + "page", "flip", "send", "prompt", "scroll", "top", "jump", "auto", "snap", + ], + kind: SettingKind::Bool { + default: ui_default.page_flip_on_send_enabled(), + }, + restart_required: false, + hidden_in_minimal: true, + }, SettingMeta { // Persisted key stays `simple_mode`; the user-facing label // distinguishes the PROMPT vim-mode (this setting) from the @@ -1107,27 +1124,31 @@ pub fn default_settings() -> Vec { }, // SHELL-owned. Persisted in auth metadata (not config.toml). // Reads from `PagerLocalSnapshot.coding_data_sharing_opt_out`. - // Default "opt-in" matches `AuthEntry::coding_data_retention_opt_out = false`. + // Default "opt-out" matches `AuthEntry::coding_data_retention_opt_out = true` + // (safer consumer default; server enrichment may still opt the user in). // ZDR / non-admin guards are enforced at dispatch time. + // Do not put "telemetry" in keywords — that word is the config-file + // analytics toggle (Monitoring / Configuration docs). SettingMeta { key: "coding_data_sharing", category: SettingCategory::Privacy, owner: SettingOwner::Shell, label: "Coding data sharing", - description: "Controls whether SpaceXAI may retain and train on coding session data.", + description: "Controls whether SpaceXAI may retain and train on coding session \ + data. Does not affect product analytics; see Configuration and \ + Monitoring docs.", keywords: &[ "privacy", "data", "sharing", "coding", "retention", - "telemetry", "training", "opt-in", "opt-out", ], kind: SettingKind::Enum { - default: "opt-in", + default: "opt-out", choices: CODING_DATA_SHARING_CHOICES, supports_preview: false, }, diff --git a/crates/codegen/xai-grok-pager/src/settings/registry.rs b/crates/codegen/xai-grok-pager/src/settings/registry.rs index 8a993c9..3fa5dfb 100644 --- a/crates/codegen/xai-grok-pager/src/settings/registry.rs +++ b/crates/codegen/xai-grok-pager/src/settings/registry.rs @@ -249,7 +249,8 @@ pub struct PagerLocalSnapshot { pub available_models: Vec<(String, acp::ModelId)>, /// Whether the user has opted OUT of coding data sharing. /// Lives in auth metadata (no `UiConfig` field). Inverted mapping: - /// `opt_out == false` → canonical "opt-in". + /// `opt_out == false` → canonical "opt-in". Snapshot default is + /// `true` (opted out) to match the safer consumer default. pub coding_data_sharing_opt_out: bool, /// Whether plan mode is active. Uses effective state /// (`pending.unwrap_or(active)`) so rapid toggles don't double-send. @@ -289,7 +290,7 @@ impl Default for PagerLocalSnapshot { auto_mode: false, current_model_name: None, available_models: Vec::new(), - coding_data_sharing_opt_out: false, + coding_data_sharing_opt_out: true, plan_mode_active: false, show_tips: None, auto_update: None, @@ -484,6 +485,10 @@ pub fn current_value_for( "compact_mode" => Some(SettingValue::Bool(ui.compact_mode)), "show_timestamps" => Some(SettingValue::Bool(ui.show_timestamps.unwrap_or(true))), "show_timeline" => Some(SettingValue::Bool(ui.show_timeline_enabled())), + // Cache is the send-path source of truth (same pattern as group_tool_verbs). + "page_flip_on_send" => Some(SettingValue::Bool( + crate::appearance::cache::load_page_flip_on_send(), + )), "simple_mode" => Some(SettingValue::Bool(ui.simple_mode.unwrap_or(true))), // Per-tip contextual hints — `None` (inherit) reads as the default ON. "contextual_hints.undo" => { @@ -780,6 +785,13 @@ mod tests { "show_timeline default drifts from UiConfig::default()" ); } + ("page_flip_on_send", SettingKind::Bool { default }) => { + assert_eq!( + *default, + ui.page_flip_on_send_enabled(), + "page_flip_on_send default drifts from UiConfig::default()" + ); + } ("simple_mode", SettingKind::Bool { default }) => { assert_eq!( *default, @@ -865,14 +877,15 @@ mod tests { ); } // coding_data_sharing: no UiConfig field; default pinned - // against auth metadata (opt_out=false → "opt-in"). + // against auth metadata (opt_out=true → "opt-out"). ("coding_data_sharing", SettingKind::Enum { default, .. }) => { - let expected = "opt-in"; + let expected = "opt-out"; assert_eq!( *default, expected, - "coding_data_sharing registry default must be 'opt-in' — \ + "coding_data_sharing registry default must be 'opt-out' — \ the on-disk source of truth is `AuthEntry::coding_data_retention_opt_out: \ - bool` (defaults to `false`, i.e. user has NOT opted out)", + bool` (defaults to `true`, i.e. user has opted out until they \ + explicitly share or the server opts them in)", ); } // CLI batch: fields live on CliConfig, not UiConfig. diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/btw.rs b/crates/codegen/xai-grok-pager/src/slash/commands/btw.rs index 14e8529..cfed6d6 100644 --- a/crates/codegen/xai-grok-pager/src/slash/commands/btw.rs +++ b/crates/codegen/xai-grok-pager/src/slash/commands/btw.rs @@ -41,3 +41,14 @@ impl SlashCommand for BtwCommand { CommandResult::Action(Action::SendBtw(args.trim().to_string())) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::slash::command::SlashCommand; + + #[test] + fn available_in_minimal_by_default() { + assert!(BtwCommand.available_in_minimal()); + } +} diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/mod.rs b/crates/codegen/xai-grok-pager/src/slash/commands/mod.rs index 2148ecd..355bb6d 100644 --- a/crates/codegen/xai-grok-pager/src/slash/commands/mod.rs +++ b/crates/codegen/xai-grok-pager/src/slash/commands/mod.rs @@ -621,11 +621,18 @@ mod tests { #[test] fn recap_registered_in_builtin_commands() { let mut reg = CommandRegistry::new(builtin_commands()); + assert!(reg.get("recap").is_none()); + assert!(reg.get("summarize").is_none()); reg.set_recap_visible(true); assert!( reg.get("recap").is_some(), "/recap should be registered in builtins" ); + assert_eq!( + reg.get("summarize").map(|c| c.name()), + Some("recap"), + "/summarize should alias /recap" + ); } #[test] fn gboom_bare_invocation_opens_game() { diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/recap.rs b/crates/codegen/xai-grok-pager/src/slash/commands/recap.rs index 387d1e2..ab7fc1c 100644 --- a/crates/codegen/xai-grok-pager/src/slash/commands/recap.rs +++ b/crates/codegen/xai-grok-pager/src/slash/commands/recap.rs @@ -1,4 +1,4 @@ -//! `/recap` -- summarize the session so far ("where was I"). +//! `/recap` (alias `/summarize`) -- summarize the session so far ("where was I"). //! //! Returns `CommandResult::Action(Action::SendRecap { auto: false })` so the //! dispatch layer fires it as an ACP ext method (`x.ai/recap`) that bypasses @@ -15,6 +15,10 @@ impl SlashCommand for RecapCommand { "recap" } + fn aliases(&self) -> &[&str] { + &["summarize"] + } + fn description(&self) -> &str { "Summarize the session so far" } diff --git a/crates/codegen/xai-grok-pager/src/views/agents_modal.rs b/crates/codegen/xai-grok-pager/src/views/agents_modal.rs index b4b99cc..eeed771 100644 --- a/crates/codegen/xai-grok-pager/src/views/agents_modal.rs +++ b/crates/codegen/xai-grok-pager/src/views/agents_modal.rs @@ -4,6 +4,7 @@ //! [`ModalWindow`](super::modal_window) chrome. Blocks all input until //! closed with `Esc`. use crate::app::bundle::{BundleState, PersonaDetail}; +use crate::input::line_editor::{LineEditOutcome, LineEditor}; use crate::theme::Theme; use crate::views::modal_window::{ self, ModalContentArea, ModalSizing, ModalWindowConfig, ModalWindowState, Shortcut, @@ -155,14 +156,71 @@ pub enum CreateField { } /// State for the inline create-persona form. pub struct PersonaCreateInput { - pub name: String, - pub name_cursor: usize, - pub description: String, - pub desc_cursor: usize, - pub instructions: String, - pub instructions_cursor: usize, - pub scope: ConfigFileScope, - pub active_field: CreateField, + name: LineEditor, + description: LineEditor, + instructions: LineEditor, + scope: ConfigFileScope, + active_field: CreateField, +} +impl PersonaCreateInput { + fn new() -> Self { + Self { + name: LineEditor::default(), + description: LineEditor::default(), + instructions: LineEditor::default(), + scope: ConfigFileScope::User, + active_field: CreateField::Name, + } + } + pub fn name(&self) -> &str { + self.name.text() + } + pub fn description(&self) -> &str { + self.description.text() + } + pub fn instructions(&self) -> &str { + self.instructions.text() + } + pub fn scope(&self) -> ConfigFileScope { + self.scope + } + pub fn active_field(&self) -> CreateField { + self.active_field + } + fn name_editor(&self) -> &LineEditor { + &self.name + } + fn description_editor(&self) -> &LineEditor { + &self.description + } + fn instructions_editor(&self) -> &LineEditor { + &self.instructions + } + fn active_editor_mut(&mut self) -> Option<&mut LineEditor> { + let field = self.active_field; + self.field_editor_mut(field) + } + fn field_editor_mut(&mut self, field: CreateField) -> Option<&mut LineEditor> { + match field { + CreateField::Name => Some(&mut self.name), + CreateField::Description => Some(&mut self.description), + CreateField::Instructions => Some(&mut self.instructions), + CreateField::Scope => None, + } + } + #[cfg(test)] + fn set_field_text(&mut self, field: CreateField, text: impl Into) { + if let Some(editor) = self.field_editor_mut(field) { + editor.set_text(text); + } + } + #[cfg(test)] + fn set_field_cursor_byte(&mut self, field: CreateField, cursor_byte: usize) -> LineEditOutcome { + self.field_editor_mut(field) + .map_or(LineEditOutcome::Unhandled, |editor| { + editor.set_cursor_byte(cursor_byte) + }) + } } /// Pending confirmation action (delete local persona). pub enum PersonaConfirmAction { @@ -181,7 +239,7 @@ pub struct AgentsModalState { pub agents: Vec, pub selected: usize, pub scroll: usize, - pub search_query: String, + search: LineEditor, pub search_active: bool, /// Maps screen Y position to agent index. Rebuilt every render frame /// for mouse click → agent selection. @@ -242,7 +300,7 @@ impl AgentsModalState { agents, selected: 0, scroll: 0, - search_query: String::new(), + search: LineEditor::default(), search_active: false, row_map: Vec::new(), content_rect: None, @@ -283,6 +341,41 @@ impl AgentsModalState { AgentsTab::Personas => self.refresh_personas(), } } + pub fn search_query(&self) -> &str { + self.search.text() + } + pub fn search_cursor_byte(&self) -> usize { + self.search.cursor_byte() + } + fn search_editor(&self) -> &LineEditor { + &self.search + } + #[cfg(test)] + fn search_viewport(&self, width: usize) -> xai_ratatui_textarea::SingleLineViewport { + self.search.viewport(width) + } + #[cfg(test)] + fn set_search_query(&mut self, query: impl Into) { + self.search.set_text(query); + } + #[cfg(test)] + fn set_search_cursor_byte(&mut self, cursor_byte: usize) -> LineEditOutcome { + self.search.set_cursor_byte(cursor_byte) + } + fn reset_selection_after_search_change(&mut self) { + match self.active_tab { + AgentsTab::Agents => { + if let Some(&first) = self.filtered_indices().first() { + self.selected = first; + } + } + AgentsTab::Personas => { + if let Some(&first) = self.filtered_persona_indices().first() { + self.persona_selected = first; + } + } + } + } } /// Build the full agent list: user-visible built-ins first, then /// file-based agents from discovery, with dedup. @@ -794,10 +887,10 @@ fn render_prompt_body(body: &str, tool_config: &ToolServerConfig) -> String { impl AgentsModalState { /// Indices of agents matching the current search query. pub fn filtered_indices(&self) -> Vec { - if self.search_query.is_empty() { + if self.search_query().is_empty() { return (0..self.agents.len()).collect(); } - let q = self.search_query.to_lowercase(); + let q = self.search_query().to_lowercase(); self.agents .iter() .enumerate() @@ -843,10 +936,10 @@ impl AgentsModalState { } /// Indices of personas matching the current search query. pub fn filtered_persona_indices(&self) -> Vec { - if self.search_query.is_empty() { + if self.search_query().is_empty() { return (0..self.personas.len()).collect(); } - let q = self.search_query.to_lowercase(); + let q = self.search_query().to_lowercase(); self.personas .iter() .enumerate() @@ -1089,6 +1182,58 @@ fn build_personas_tab_shortcuts<'a>(state: &AgentsModalState) -> Vec 0 { + buf.set_string( + editor_x, + area.y, + visible, + Style::default().fg(theme.accent_user), + ); + } + if focused { + let cursor_offset = painted_prefix_width + .saturating_add(viewport.cursor_display_column as u16) + .min(area.width - 1); + if let Some(cell) = buf.cell_mut((area.x + cursor_offset, area.y)) { + cell.set_style(Style::default().fg(theme.bg_base).bg(theme.text_primary)); + } + } +} /// Render the Agents tab content (existing agents list). fn render_agents_tab( buf: &mut Buffer, @@ -1101,18 +1246,14 @@ fn render_agents_tab( if let Some(ref msg) = state.message { y = render_modal_message_line(buf, content_area.x, y, w, msg, theme); } - if state.search_active || !state.search_query.is_empty() { - let prompt_str = format!("/ {}", state.search_query); - let display = crate::render::line_utils::truncate_str(&prompt_str, w); - let style = Style::default().fg(theme.accent_user); - buf.set_string(content_area.x, y, &display, style); - let used = display.width() as u16; - for x in content_area.x + used..content_area.x + content_area.width { - if let Some(cell) = buf.cell_mut((x, y)) { - cell.set_char(' '); - cell.set_style(Style::default().fg(theme.gray_dim)); - } - } + if state.search_active || !state.search_query().is_empty() { + render_agents_search( + buf, + Rect::new(content_area.x, y, content_area.width, 1), + state.search_editor(), + state.search_active, + theme, + ); y += 1; y += 1; } @@ -1122,7 +1263,7 @@ fn render_agents_tab( } let filtered = state.filtered_indices(); if filtered.is_empty() { - let msg = if state.search_query.is_empty() { + let msg = if state.search_query().is_empty() { "No agents found" } else { "No matching agents" @@ -1381,18 +1522,14 @@ fn render_personas_tab( let blurb2 = "Used by skills (e.g. /implement) and by the model when spawning subagents."; buf.set_string(content_area.x, y, blurb2, blurb_style); y += 2; - if state.search_active || !state.search_query.is_empty() { - let prompt_str = format!("/ {}", state.search_query); - let display = crate::render::line_utils::truncate_str(&prompt_str, w); - let style = Style::default().fg(theme.accent_user); - buf.set_string(content_area.x, y, &display, style); - let used = display.width() as u16; - for x in content_area.x + used..content_area.x + content_area.width { - if let Some(cell) = buf.cell_mut((x, y)) { - cell.set_char(' '); - cell.set_style(Style::default().fg(theme.gray_dim)); - } - } + if state.search_active || !state.search_query().is_empty() { + render_agents_search( + buf, + Rect::new(content_area.x, y, content_area.width, 1), + state.search_editor(), + state.search_active, + theme, + ); y += 1; y += 1; } @@ -1647,8 +1784,7 @@ fn render_create_text_field( y: u16, w: usize, label: &str, - text: &str, - cursor: usize, + editor: &LineEditor, active: bool, theme: &Theme, ) -> u16 { @@ -1658,13 +1794,21 @@ fn render_create_text_field( Style::default().fg(theme.gray) }; buf.set_string(content_area.x, y, label, label_style); - let field_x = content_area.x + label.len() as u16; - let remaining = w.saturating_sub(label.len()); - let display: String = text.chars().take(remaining).collect(); + let label_width = label.width(); + let field_x = content_area.x + label_width as u16; + let remaining = w.saturating_sub(label_width); + let viewport = editor.viewport(remaining); + let leading; + let display: &str = if active { + &editor.text()[viewport.visible_byte_range.clone()] + } else { + leading = crate::render::line_utils::truncate_str(editor.text(), remaining); + &leading + }; let field_style = Style::default().fg(theme.text_primary); - buf.set_string(field_x, y, &display, field_style); + buf.set_string(field_x, y, display, field_style); if active { - let cursor_x = field_x + text[..cursor.min(text.len())].width() as u16; + let cursor_x = field_x + viewport.cursor_display_column as u16; if cursor_x < content_area.x + content_area.width && let Some(cell) = buf.cell_mut((cursor_x, y)) { @@ -1704,8 +1848,7 @@ fn render_persona_create_form( y, w, "Name: ", - &input.name, - input.name_cursor, + input.name_editor(), input.active_field == CreateField::Name, theme, ); @@ -1715,8 +1858,7 @@ fn render_persona_create_form( y, w, "Description: ", - &input.description, - input.desc_cursor, + input.description_editor(), input.active_field == CreateField::Description, theme, ); @@ -1726,8 +1868,7 @@ fn render_persona_create_form( y, w, "Instructions: ", - &input.instructions, - input.instructions_cursor, + input.instructions_editor(), input.active_field == CreateField::Instructions, theme, ); @@ -1826,7 +1967,7 @@ fn clear_overlays_for_tab(state: &mut AgentsModalState, tab: AgentsTab) { fn switch_agents_tab(state: &mut AgentsModalState, tab: AgentsTab) { clear_overlays_for_tab(state, tab); state.active_tab = tab; - state.search_query.clear(); + state.search.reset(); state.search_active = false; } /// Handle a key event while the agents modal is open. @@ -1839,72 +1980,27 @@ pub fn handle_agents_key(state: &mut AgentsModalState, key: &KeyEvent) -> Agents return handle_persona_confirm_key(state, key); } if state.search_active { - match key.code { - KeyCode::Esc => { - state.search_query.clear(); - state.search_active = false; - return AgentsModalOutcome::Changed; - } - KeyCode::Enter => { - state.search_active = false; - return AgentsModalOutcome::Changed; - } - KeyCode::Backspace => { - state.search_query.pop(); - match state.active_tab { - AgentsTab::Agents => { - let indices = state.filtered_indices(); - if let Some(&first) = indices.first() { - state.selected = first; - } - } - AgentsTab::Personas => { - let indices = state.filtered_persona_indices(); - if let Some(&first) = indices.first() { - state.persona_selected = first; - } - } - } - return AgentsModalOutcome::Changed; - } - KeyCode::Char(c) - if !key - .modifiers - .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => - { - state.search_query.push(c); - match state.active_tab { - AgentsTab::Agents => { - let indices = state.filtered_indices(); - if let Some(&first) = indices.first() { - state.selected = first; - } - } - AgentsTab::Personas => { - let indices = state.filtered_persona_indices(); - if let Some(&first) = indices.first() { - state.persona_selected = first; - } - } - } - return AgentsModalOutcome::Changed; - } - KeyCode::Tab - if !key.modifiers.intersects( - KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER, - ) => - { - let tab = state.active_tab.next(); - switch_agents_tab(state, tab); - return AgentsModalOutcome::Changed; - } - KeyCode::BackTab => { - let tab = state.active_tab.prev(); - switch_agents_tab(state, tab); - return AgentsModalOutcome::Changed; - } - _ => return AgentsModalOutcome::Unchanged, + if key.code == KeyCode::Esc { + state.search.reset(); + state.search_active = false; + return AgentsModalOutcome::Changed; } + if key.code == KeyCode::Enter { + state.search_active = false; + return AgentsModalOutcome::Changed; + } + if crate::input::key::is_shift_tab(key) { + let tab = state.active_tab.prev(); + switch_agents_tab(state, tab); + return AgentsModalOutcome::Changed; + } + if crate::input::key::KeyShortcut::key(KeyCode::Tab).matches(key) { + let tab = state.active_tab.next(); + switch_agents_tab(state, tab); + return AgentsModalOutcome::Changed; + } + let outcome = state.search.handle_key(key); + return finish_search_edit(state, outcome); } let tab_labels: Vec<&str> = AgentsTab::ALL.iter().map(|t| t.label()).collect(); let config = ModalWindowConfig { @@ -1927,16 +2023,12 @@ pub fn handle_agents_key(state: &mut AgentsModalState, key: &KeyEvent) -> Agents } _ => {} } - if key.code == KeyCode::Tab - && !key - .modifiers - .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER) - { + if crate::input::key::KeyShortcut::key(KeyCode::Tab).matches(key) { let tab = state.active_tab.next(); switch_agents_tab(state, tab); return AgentsModalOutcome::Changed; } - if key.code == KeyCode::BackTab { + if crate::input::key::is_shift_tab(key) { let tab = state.active_tab.prev(); switch_agents_tab(state, tab); return AgentsModalOutcome::Changed; @@ -1946,6 +2038,43 @@ pub fn handle_agents_key(state: &mut AgentsModalState, key: &KeyEvent) -> Agents AgentsTab::Personas => handle_personas_tab_key(state, key), } } +pub fn handle_agents_paste(state: &mut AgentsModalState, text: &str) -> AgentsModalOutcome { + if let Some(input) = state.persona_input.as_mut() { + let Some(editor) = input.active_editor_mut() else { + return AgentsModalOutcome::Unchanged; + }; + let outcome = editor.insert_paste(text); + if outcome == LineEditOutcome::TextChanged { + state.message = None; + } + return finish_line_edit(outcome); + } + if state.search_active { + let outcome = state.search.insert_paste(text); + if outcome == LineEditOutcome::TextChanged { + state.message = None; + } + return finish_search_edit(state, outcome); + } + AgentsModalOutcome::Unchanged +} +fn finish_search_edit( + state: &mut AgentsModalState, + outcome: LineEditOutcome, +) -> AgentsModalOutcome { + if outcome == LineEditOutcome::TextChanged { + state.reset_selection_after_search_change(); + } + finish_line_edit(outcome) +} +fn finish_line_edit(outcome: LineEditOutcome) -> AgentsModalOutcome { + match outcome { + LineEditOutcome::TextChanged + | LineEditOutcome::HandledNoChange + | LineEditOutcome::CursorChanged => AgentsModalOutcome::Changed, + LineEditOutcome::Unhandled => AgentsModalOutcome::Unchanged, + } +} /// Handle key input specific to the Agents tab. fn handle_agents_tab_key(state: &mut AgentsModalState, key: &KeyEvent) -> AgentsModalOutcome { match key.code { @@ -2127,16 +2256,7 @@ fn handle_personas_tab_key(state: &mut AgentsModalState, key: &KeyEvent) -> Agen AgentsModalOutcome::Unchanged } KeyCode::Char('n') => { - state.persona_input = Some(PersonaCreateInput { - name: String::new(), - name_cursor: 0, - description: String::new(), - desc_cursor: 0, - instructions: String::new(), - instructions_cursor: 0, - scope: ConfigFileScope::User, - active_field: CreateField::Name, - }); + state.persona_input = Some(PersonaCreateInput::new()); AgentsModalOutcome::Changed } KeyCode::Char('d') => { @@ -2183,13 +2303,16 @@ fn try_toggle_create_scope( let toggle = matches!( key.code, KeyCode::Char(' ') | KeyCode::Left | KeyCode::Right - ); + ) && key.modifiers.is_empty(); if toggle { *scope = scope.toggle(); } toggle } fn persona_create_form_field_nav(active_field: CreateField, key: &KeyEvent) -> Option { + if !key.modifiers.is_empty() { + return None; + } match key.code { KeyCode::Up => Some(prev_persona_create_field(active_field)), KeyCode::Down => Some(next_persona_create_field(active_field)), @@ -2206,41 +2329,6 @@ fn persona_create_form_field_nav_scroll( prev_persona_create_field(active_field) } } -fn edit_create_field_backspace(text: &mut String, cursor: &mut usize) { - if *cursor > 0 { - let prev = text[..*cursor] - .char_indices() - .next_back() - .map(|(i, _)| i) - .unwrap_or(0); - text.remove(prev); - *cursor = prev; - } -} -fn edit_create_field_left(text: &str, cursor: &mut usize) { - if *cursor > 0 { - let prev = text[..*cursor] - .char_indices() - .next_back() - .map(|(i, _)| i) - .unwrap_or(0); - *cursor = prev; - } -} -fn edit_create_field_right(text: &str, cursor: &mut usize) { - if *cursor < text.len() { - let next = text[*cursor..] - .char_indices() - .nth(1) - .map(|(i, _)| *cursor + i) - .unwrap_or(text.len()); - *cursor = next; - } -} -fn edit_create_field_insert(text: &mut String, cursor: &mut usize, c: char) { - text.insert(*cursor, c); - *cursor += c.len_utf8(); -} /// Handle key input in the persona create form. fn handle_persona_create_form_key( state: &mut AgentsModalState, @@ -2250,91 +2338,53 @@ fn handle_persona_create_form_key( return AgentsModalOutcome::Unchanged; }; let cwd = state.cwd.clone(); - match key.code { - KeyCode::Esc => { - state.persona_input = None; - AgentsModalOutcome::Changed - } - KeyCode::Tab => { - input.active_field = handle_persona_create_form_tab_key(input.active_field, false); - AgentsModalOutcome::Changed - } - KeyCode::BackTab => { - input.active_field = handle_persona_create_form_tab_key(input.active_field, true); - AgentsModalOutcome::Changed - } - _ if try_toggle_create_scope(input.active_field, &mut input.scope, key) => { - AgentsModalOutcome::Changed - } - _ if persona_create_form_field_nav(input.active_field, key).is_some_and(|f| { - input.active_field = f; - true - }) => - { - AgentsModalOutcome::Changed - } - KeyCode::Enter => { - let name = input.name.trim().to_string(); - let description = input.description.trim().to_string(); - let instructions = input.instructions.trim().to_string(); - let scope = input.scope; - if name.is_empty() { - state.message = Some(AgentsModalMessage::error("Name is required")); - return AgentsModalOutcome::Changed; - } - match create_persona_template(&name, &description, &instructions, scope, &cwd) { - Ok(path) => { - let label = path.file_stem().and_then(|s| s.to_str()).unwrap_or(&name); - state.persona_input = None; - state.refresh_personas(); - state.message = Some(AgentsModalMessage::success(format!( - "Created persona '{label}'" - ))); - } - Err(e) => { - state.message = Some(AgentsModalMessage::error(e)); - } - } - AgentsModalOutcome::Changed - } - KeyCode::Backspace if input.active_field != CreateField::Scope => { - let (text, cursor) = persona_active_field_mut(input); - edit_create_field_backspace(text, cursor); - AgentsModalOutcome::Changed - } - KeyCode::Left if input.active_field != CreateField::Scope => { - let (text, cursor) = persona_active_field_mut(input); - edit_create_field_left(text, cursor); - AgentsModalOutcome::Changed - } - KeyCode::Right if input.active_field != CreateField::Scope => { - let (text, cursor) = persona_active_field_mut(input); - edit_create_field_right(text, cursor); - AgentsModalOutcome::Changed - } - KeyCode::Char(c) - if input.active_field != CreateField::Scope - && (!key.modifiers.intersects( - KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER, - ) || crate::input::key::is_altgr(key.modifiers)) => - { - let (text, cursor) = persona_active_field_mut(input); - edit_create_field_insert(text, cursor, c); - AgentsModalOutcome::Changed - } - _ => AgentsModalOutcome::Unchanged, + if key.code == KeyCode::Esc { + state.persona_input = None; + return AgentsModalOutcome::Changed; } -} -fn persona_active_field_mut(input: &mut PersonaCreateInput) -> (&mut String, &mut usize) { - match input.active_field { - CreateField::Name => (&mut input.name, &mut input.name_cursor), - CreateField::Description => (&mut input.description, &mut input.desc_cursor), - CreateField::Instructions => (&mut input.instructions, &mut input.instructions_cursor), - CreateField::Scope => { - debug_assert!(false, "choice fields do not accept text edits"); - (&mut input.instructions, &mut input.instructions_cursor) - } + if crate::input::key::is_shift_tab(key) { + input.active_field = handle_persona_create_form_tab_key(input.active_field, true); + return AgentsModalOutcome::Changed; } + if crate::input::key::KeyShortcut::key(KeyCode::Tab).matches(key) { + input.active_field = handle_persona_create_form_tab_key(input.active_field, false); + return AgentsModalOutcome::Changed; + } + if try_toggle_create_scope(input.active_field, &mut input.scope, key) { + return AgentsModalOutcome::Changed; + } + if let Some(field) = persona_create_form_field_nav(input.active_field, key) { + input.active_field = field; + return AgentsModalOutcome::Changed; + } + if key.code == KeyCode::Enter { + let name = input.name().trim().to_string(); + let description = input.description().trim().to_string(); + let instructions = input.instructions().trim().to_string(); + let scope = input.scope; + if name.is_empty() { + state.message = Some(AgentsModalMessage::error("Name is required")); + return AgentsModalOutcome::Changed; + } + match create_persona_template(&name, &description, &instructions, scope, &cwd) { + Ok(path) => { + let label = path.file_stem().and_then(|s| s.to_str()).unwrap_or(&name); + state.persona_input = None; + state.refresh_personas(); + state.message = Some(AgentsModalMessage::success(format!( + "Created persona '{label}'" + ))); + } + Err(e) => { + state.message = Some(AgentsModalMessage::error(e)); + } + } + return AgentsModalOutcome::Changed; + } + let Some(editor) = input.active_editor_mut() else { + return AgentsModalOutcome::Unchanged; + }; + finish_line_edit(editor.handle_key(key)) } /// Handle key input in the persona confirm dialog. fn handle_persona_confirm_key(state: &mut AgentsModalState, key: &KeyEvent) -> AgentsModalOutcome { @@ -2682,13 +2732,13 @@ mod tests { }; let personas = merge_persona_lists(&bundle, Path::new("/tmp")); let make_state = |query: &str| -> AgentsModalState { - AgentsModalState { + let mut state = AgentsModalState { window: ModalWindowState::with_tabs(2), active_tab: AgentsTab::Personas, agents: Vec::new(), selected: 0, scroll: 0, - search_query: query.to_string(), + search: LineEditor::default(), search_active: false, row_map: Vec::new(), content_rect: None, @@ -2704,7 +2754,9 @@ mod tests { persona_selected: 0, persona_scroll: 0, persona_expanded: std::collections::HashSet::new(), - } + }; + state.set_search_query(query); + state }; let s = make_state(""); assert_eq!(s.filtered_persona_indices(), vec![0, 1]); @@ -2721,13 +2773,13 @@ mod tests { query: &str, selected: usize, ) -> AgentsModalState { - AgentsModalState { + let mut state = AgentsModalState { window: ModalWindowState::with_tabs(2), active_tab: AgentsTab::Personas, agents: Vec::new(), selected: 0, scroll: 0, - search_query: query.to_string(), + search: LineEditor::default(), search_active: false, row_map: Vec::new(), content_rect: None, @@ -2743,7 +2795,9 @@ mod tests { persona_selected: selected, persona_scroll: 0, persona_expanded: std::collections::HashSet::new(), - } + }; + state.set_search_query(query); + state } fn three_personas() -> Vec { vec![ @@ -2925,4 +2979,340 @@ mod tests { ); crate::appearance::cache::set_vim_mode(false); } + #[test] + fn search_text_changes_refilter_but_cursor_moves_do_not() { + let mut state = make_persona_state(three_personas(), "", 0); + state.search_active = true; + let outcome = handle_agents_key( + &mut state, + &KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE), + ); + assert!(matches!(outcome, AgentsModalOutcome::Changed)); + assert_eq!(state.search_query(), "g"); + assert_eq!(state.persona_selected, 2); + let outcome = handle_agents_key( + &mut state, + &KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), + ); + assert!(matches!(outcome, AgentsModalOutcome::Changed)); + assert_eq!(state.search_query(), "g"); + assert_eq!(state.search_cursor_byte(), 0); + assert_eq!(state.persona_selected, 2); + } + #[test] + fn no_form_search_paste_sanitizes_at_cursor_and_resets_selection() { + let mut state = make_persona_state(three_personas(), "ab", 2); + state.search_active = true; + state.message = Some(AgentsModalMessage::error("stale")); + let _ = state.set_search_cursor_byte(1); + let outcome = handle_agents_paste(&mut state, "中\r\n"); + assert!(matches!(outcome, AgentsModalOutcome::Changed)); + assert_eq!(state.search_query(), "a中b"); + assert_eq!(state.persona_selected, 2); + assert!(state.filtered_persona_indices().is_empty()); + assert!(state.message.is_none()); + state.search_active = false; + let outcome = handle_agents_paste(&mut state, "ignored"); + assert!(matches!(outcome, AgentsModalOutcome::Unchanged)); + assert_eq!(state.search_query(), "a中b"); + } + #[test] + fn create_text_field_paste_owns_input_and_clears_message_on_change() { + let mut state = make_persona_state(three_personas(), "hidden", 1); + state.search_active = true; + state.persona_input = Some(PersonaCreateInput::new()); + state.message = Some(AgentsModalMessage::error("stale")); + let outcome = handle_agents_paste(&mut state, "na\r\nme"); + assert!(matches!(outcome, AgentsModalOutcome::Changed)); + assert_eq!( + state.persona_input.as_ref().map(PersonaCreateInput::name), + Some("name") + ); + assert_eq!(state.search_query(), "hidden"); + assert!(state.message.is_none()); + } + #[test] + fn scope_form_paste_is_consumed_without_hidden_search_fallthrough() { + let mut state = make_persona_state(three_personas(), "hidden", 1); + state.search_active = true; + let mut input = PersonaCreateInput::new(); + input.active_field = CreateField::Scope; + state.persona_input = Some(input); + state.message = Some(AgentsModalMessage::error("keep")); + let outcome = handle_agents_paste(&mut state, "must not leak"); + assert!(matches!(outcome, AgentsModalOutcome::Unchanged)); + let input = state.persona_input.as_ref().unwrap(); + assert!(input.name().is_empty()); + assert!(input.description().is_empty()); + assert!(input.instructions().is_empty()); + assert_eq!(state.search_query(), "hidden"); + assert_eq!( + state.message.as_ref().map(|message| message.text.as_str()), + Some("keep") + ); + } + #[test] + fn handled_empty_paste_preserves_messages_for_form_and_search() { + let mut state = make_persona_state(three_personas(), "search", 1); + state.search_active = true; + state.persona_input = Some(PersonaCreateInput::new()); + state.message = Some(AgentsModalMessage::error("form error")); + let outcome = handle_agents_paste(&mut state, "\r\n"); + assert!(matches!(outcome, AgentsModalOutcome::Changed)); + assert_eq!( + state.message.as_ref().map(|message| message.text.as_str()), + Some("form error") + ); + assert!(state.persona_input.as_ref().unwrap().name().is_empty()); + assert_eq!(state.search_query(), "search"); + state.persona_input = None; + state.message = Some(AgentsModalMessage::error("search error")); + let outcome = handle_agents_paste(&mut state, "\r\n"); + assert!(matches!(outcome, AgentsModalOutcome::Changed)); + assert_eq!( + state.message.as_ref().map(|message| message.text.as_str()), + Some("search error") + ); + assert_eq!(state.search_query(), "search"); + } + #[test] + fn search_uses_canonical_word_and_grapheme_editing() { + for key in [ + KeyEvent::new(KeyCode::Left, KeyModifiers::ALT), + KeyEvent::new(KeyCode::Char('b'), KeyModifiers::ALT), + KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL), + ] { + let mut state = make_persona_state(three_personas(), "hello-world", 0); + state.search_active = true; + let outcome = handle_agents_key(&mut state, &key); + assert!(matches!(outcome, AgentsModalOutcome::Changed)); + assert_eq!(state.search_query(), "hello-world"); + assert_eq!(state.search_cursor_byte(), "hello-".len()); + } + for key in [ + KeyEvent::new(KeyCode::Right, KeyModifiers::ALT), + KeyEvent::new(KeyCode::Char('f'), KeyModifiers::ALT), + ] { + let mut state = make_persona_state(three_personas(), "hello-world", 0); + state.search_active = true; + let _ = state.set_search_cursor_byte(0); + let outcome = handle_agents_key(&mut state, &key); + assert!(matches!(outcome, AgentsModalOutcome::Changed)); + assert_eq!(state.search_query(), "hello-world"); + assert_eq!(state.search_cursor_byte(), "hello".len()); + } + let grapheme = "👩🏽\u{200d}💻"; + let mut state = make_persona_state(three_personas(), &format!("a{grapheme}b"), 0); + state.search_active = true; + let _ = state.set_search_cursor_byte(1); + let outcome = handle_agents_key( + &mut state, + &KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE), + ); + assert!(matches!(outcome, AgentsModalOutcome::Changed)); + assert_eq!(state.search_query(), "ab"); + assert_eq!(state.search_cursor_byte(), 1); + } + #[test] + fn persona_create_field_navigation_keeps_jk_as_text() { + let mut state = make_persona_state(three_personas(), "", 0); + let _ = handle_personas_tab_key( + &mut state, + &KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE), + ); + for ch in ['j', 'k'] { + let _ = handle_agents_key( + &mut state, + &KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), + ); + } + let input = state.persona_input.as_ref().unwrap(); + assert_eq!(input.name(), "jk"); + assert_eq!(input.active_field(), CreateField::Name); + let outcome = handle_agents_key( + &mut state, + &KeyEvent::new(KeyCode::Tab, KeyModifiers::CONTROL), + ); + assert!(matches!(outcome, AgentsModalOutcome::Unchanged)); + assert_eq!( + state.persona_input.as_ref().unwrap().active_field(), + CreateField::Name + ); + let _ = handle_agents_key(&mut state, &KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert_eq!( + state.persona_input.as_ref().unwrap().active_field(), + CreateField::Description + ); + let _ = handle_agents_key( + &mut state, + &KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT), + ); + assert_eq!( + state.persona_input.as_ref().unwrap().active_field(), + CreateField::Name + ); + let _ = handle_agents_key( + &mut state, + &KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), + ); + assert_eq!( + state.persona_input.as_ref().unwrap().active_field(), + CreateField::Description + ); + let _ = handle_agents_key(&mut state, &KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!( + state.persona_input.as_ref().unwrap().active_field(), + CreateField::Name + ); + } + #[test] + fn persona_create_validates_sanitizes_persists_and_rejects_duplicates() { + let directory = tempfile::tempdir().unwrap(); + let mut state = make_persona_state(vec![], "", 0); + state.cwd = directory.path().to_path_buf(); + let _ = handle_personas_tab_key( + &mut state, + &KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE), + ); + let outcome = handle_agents_key( + &mut state, + &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert!(matches!(outcome, AgentsModalOutcome::Changed)); + assert!(state.persona_input.is_some()); + assert_eq!( + state.message.as_ref().map(|message| message.text.as_str()), + Some("Name is required") + ); + for ch in "my persona".chars() { + let _ = handle_agents_key( + &mut state, + &KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), + ); + } + let _ = handle_agents_key(&mut state, &KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + for ch in "helps".chars() { + let _ = handle_agents_key( + &mut state, + &KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), + ); + } + let _ = handle_agents_key(&mut state, &KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + for ch in "be useful".chars() { + let _ = handle_agents_key( + &mut state, + &KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), + ); + } + let _ = handle_agents_key(&mut state, &KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + let _ = handle_agents_key( + &mut state, + &KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE), + ); + assert_eq!( + state.persona_input.as_ref().unwrap().scope(), + ConfigFileScope::Project + ); + let _ = handle_agents_key( + &mut state, + &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + let path = directory + .path() + .join(".grok") + .join("personas") + .join("my-persona.toml"); + let content = std::fs::read_to_string(&path).unwrap(); + assert!(content.contains("description = \"helps\"")); + assert!(content.contains("instructions = \"be useful\"")); + assert!(state.persona_input.is_none()); + let _ = handle_personas_tab_key( + &mut state, + &KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE), + ); + for ch in "my persona".chars() { + let _ = handle_agents_key( + &mut state, + &KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), + ); + } + for _ in 0..3 { + let _ = handle_agents_key(&mut state, &KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + } + let _ = handle_agents_key( + &mut state, + &KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE), + ); + let _ = handle_agents_key( + &mut state, + &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert!(state.persona_input.is_some()); + assert!( + state + .message + .as_ref() + .is_some_and(|message| message.text.contains("already exists")) + ); + } + #[test] + fn search_and_create_renderers_keep_unicode_cursor_visible() { + let grapheme = "👩🏽\u{200d}💻"; + let text = format!("12345678901234567890中e\u{301}{grapheme}z"); + let theme = Theme::current(); + let mut state = make_persona_state(three_personas(), &text, 0); + state.search_active = true; + let _ = state.set_search_cursor_byte(text.len() - 1); + let search_area = Rect::new(0, 0, 18, 1); + let mut search_buffer = Buffer::empty(search_area); + render_agents_search( + &mut search_buffer, + search_area, + state.search_editor(), + true, + &theme, + ); + let search_view = state.search_viewport(16); + let search_visible = &state.search_query()[search_view.visible_byte_range.clone()]; + assert!(search_visible.contains('中')); + assert!(search_visible.contains("e\u{301}")); + assert!(search_visible.contains(grapheme)); + let search_cursor_x = 2 + search_view.cursor_display_column as u16; + assert_eq!(search_buffer[(search_cursor_x, 0)].bg, theme.text_primary); + state.search_active = false; + let mut unfocused_search = Buffer::empty(search_area); + render_agents_search( + &mut unfocused_search, + search_area, + state.search_editor(), + false, + &theme, + ); + let unfocused_text = (2..search_area.width) + .map(|x| unfocused_search[(x, 0)].symbol()) + .collect::(); + assert!(unfocused_text.starts_with("1234567890")); + let mut input = PersonaCreateInput::new(); + input.set_field_text(CreateField::Name, &text); + let _ = input.set_field_cursor_byte(CreateField::Name, text.len() - 1); + let create_area = Rect::new(0, 0, 24, 12); + let mut create_buffer = Buffer::empty(create_area); + render_persona_create_form(&mut create_buffer, &create_area, &input, None, &theme); + let editor_width = create_area.width as usize - "Name: ".len(); + let create_view = input.name_editor().viewport(editor_width); + let create_visible = &input.name()[create_view.visible_byte_range.clone()]; + assert!(create_visible.contains('中')); + assert!(create_visible.contains("e\u{301}")); + assert!(create_visible.contains(grapheme)); + let create_cursor_x = "Name: ".len() as u16 + create_view.cursor_display_column as u16; + assert_eq!(create_buffer[(create_cursor_x, 2)].bg, theme.text_primary); + input.set_field_text(CreateField::Description, &text); + let _ = input.set_field_cursor_byte(CreateField::Description, text.len() - 1); + let mut inactive_buffer = Buffer::empty(create_area); + render_persona_create_form(&mut inactive_buffer, &create_area, &input, None, &theme); + let description_text = ("Description: ".len() as u16..create_area.width) + .map(|x| inactive_buffer[(x, 4)].symbol()) + .collect::(); + assert!(description_text.starts_with("1234567890")); + } } diff --git a/crates/codegen/xai-grok-pager/src/views/block_viewer.rs b/crates/codegen/xai-grok-pager/src/views/block_viewer.rs index 87a5a15..6678e42 100644 --- a/crates/codegen/xai-grok-pager/src/views/block_viewer.rs +++ b/crates/codegen/xai-grok-pager/src/views/block_viewer.rs @@ -1161,6 +1161,15 @@ impl BlockViewerPane { consumed } + pub fn handle_paste(&mut self, text: &str) -> bool { + self.rebuild_unified_cache(); + let consumed = self.list_state.handle_paste(text, &self.cached_unified); + if consumed { + self.text_drag = None; + } + consumed + } + /// Generate a patch string from the diff metadata in the given item range. /// /// Returns `None` if this isn't an edit viewer or the range has no diff lines. diff --git a/crates/codegen/xai-grok-pager/src/views/dashboard/render.rs b/crates/codegen/xai-grok-pager/src/views/dashboard/render.rs index 34c0553..ab9e9db 100644 --- a/crates/codegen/xai-grok-pager/src/views/dashboard/render.rs +++ b/crates/codegen/xai-grok-pager/src/views/dashboard/render.rs @@ -5,12 +5,13 @@ use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::Span; +use unicode_width::UnicodeWidthStr; use super::layout::{MIN_DASHBOARD_WIDTH, compute_layout}; use super::row::{DashboardRow, RowBadge, build_rows_with_roster}; use super::state::{ - DashboardRowId, DashboardState, Filter, Focusable, Grouping, LocationPickerState, RowState, - SectionKey, + DashboardRowId, DashboardState, Filter, Focusable, Grouping, LocationPickerState, RenameDraft, + RowState, SectionKey, }; use crate::app::agent::AgentId; use crate::app::agent_view::AgentView; @@ -286,16 +287,13 @@ pub fn render_dashboard( // Header. render_header(buf, layout.header, &theme, &rows, state, upgrade_cta); - // Body. - // - // Three distinct branches: - // (a) no agents at all → "no agents yet" hint - // (b) agents exist but filter hides all → "no match" hint - // (c) otherwise → render rows - if agents.is_empty() { - render_empty_state(buf, layout.list, &theme, dashboard_sessions_loading); - } else if rows.is_empty() { - render_no_match(buf, layout.list, &theme, &state.filter); + // Body: key off visible rows (local agents + roster), not the local map alone. + if rows.is_empty() { + if state.filter.is_active() { + render_no_match(buf, layout.list, &theme, &state.filter); + } else { + render_empty_state(buf, layout.list, &theme, dashboard_sessions_loading); + } } else if area.width < MIN_DASHBOARD_WIDTH { render_narrow_rows(buf, layout.list, &theme, &rows, state); } else { @@ -480,63 +478,78 @@ pub fn render_dashboard( return None; } - // Return a visible cursor for the dispatch input - // / rename overlay so the user sees where typing lands. - // Rename takes precedence — it paints over the row list. - // - // Cursor width is computed from the SANITISED - // draft (matching what the renderer paints). The previous - // `rn.draft.as_str()` form drifted right of the actual end of - // typed text when the draft contained control characters. - // - // Clamp `cx` to the row's right edge so a - // wider-than-rect draft doesn't park the cursor off the row. + // An active rename replaces the dispatch caret with its row-local editor caret. if let Some(pos) = rename_cursor_pos(state, &rows) { return Some(pos); } dispatch_cursor } -/// Cursor position for the in-flight rename overlay: one cell past the -/// end of the typed draft, on the renamed row's title line. `None` when -/// no rename is active (or its row isn't on screen). -/// -/// The overlay paints `rename: {draft}` at the title's own column — -/// past the marker (1) + gap (1) + indent + icon + gap (1) chrome — in -/// BOTH layouts (`render_row` / `render_narrow_rows` share the -/// formula). The cursor mirrors that chrome math; anything else parks -/// it over the `rename:` prefix or past the typed text. Indent and -/// icon width come from the live row so a Working spinner or a -/// (future) indented renameable row can't drift the formula. -/// -/// Width is computed from the SANITISED draft (matching -/// what the renderer paints). Clamped to the row's right -/// edge so a wider-than-rect draft doesn't park the cursor off the row. +const RENAME_PREFIX: &str = "rename: "; + +fn rename_editor_view(draft: &RenameDraft, width: u16) -> (&str, u16) { + let prefix_width = UnicodeWidthStr::width(RENAME_PREFIX) as u16; + let editor_width = width.saturating_sub(prefix_width); + let viewport = draft.viewport(editor_width as usize); + let visible = &draft.text()[viewport.visible_byte_range]; + let cursor_offset = prefix_width + .saturating_add(viewport.cursor_display_column as u16) + .min(width.saturating_sub(1)); + (visible, cursor_offset) +} + +fn render_rename_editor( + buf: &mut Buffer, + x: u16, + y: u16, + width: u16, + style: Style, + draft: &RenameDraft, +) { + if width == 0 { + return; + } + let prefix_width = UnicodeWidthStr::width(RENAME_PREFIX) as u16; + buf.set_span( + x, + y, + &Span::styled(RENAME_PREFIX, style), + prefix_width.min(width), + ); + let (visible, _) = rename_editor_view(draft, width); + if !visible.is_empty() && prefix_width < width { + buf.set_span( + x + prefix_width, + y, + &Span::styled(visible, style), + width - prefix_width, + ); + } +} + +/// Return the in-flight rename caret when its row is visible. fn rename_cursor_pos(state: &DashboardState, rows: &[DashboardRow]) -> Option<(u16, u16)> { - use unicode_width::UnicodeWidthStr; let rn = state.rename.as_ref()?; let (_, rect) = state.row_rects.iter().find(|(id, _)| *id == rn.row)?; - let safe_draft = crate::views::session_title::sanitize_display_text(&rn.draft); - let prefix_w = UnicodeWidthStr::width("rename: ") as u16; - let draft_w = UnicodeWidthStr::width(safe_draft.as_ref()) as u16; - let (indent_w, icon_w) = rows + let (marker_width, indent_width, icon_width) = rows .iter() .find(|r| r.id == rn.row) .map(|r| { ( + UnicodeWidthStr::width(crate::glyphs::selection_bar()) as u16, (r.indent as u16) * 2, UnicodeWidthStr::width(state_icon(r.state, state.spinner_tick)) as u16, ) }) - .unwrap_or((0, 1)); - let chrome_w = 1 + 1 + indent_w + icon_w + 1; - let unbounded_cx = rect - .x - .saturating_add(chrome_w) - .saturating_add(prefix_w) - .saturating_add(draft_w); - let cx_max = rect.x.saturating_add(rect.width.saturating_sub(1)); - Some((unbounded_cx.min(cx_max), rect.y)) + .unwrap_or((1, 0, 1)); + let chrome_width = marker_width + 1 + indent_width + icon_width + 1; + let content_x = rect.x.saturating_add(chrome_width); + let content_width = rect.x.saturating_add(rect.width).saturating_sub(content_x); + let (_, cursor_offset) = rename_editor_view(rn, content_width); + let cursor_x = content_x + .saturating_add(cursor_offset) + .min(rect.x.saturating_add(rect.width.saturating_sub(1))); + Some((cursor_x, rect.y)) } /// Render the compact dashboard "banner" used when an agent is @@ -685,7 +698,6 @@ fn render_header( upgrade_cta: Option>, ) { use ratatui::text::{Line, Span}; - use unicode_width::UnicodeWidthStr; use crate::views::agent_status::AgentStatusBar; @@ -1004,7 +1016,8 @@ fn render_location_picker( ModalSizing, ModalWindowConfig, Shortcut, push_vim_nav_search_hint, render_modal_window, }; use crate::views::picker::{ - PickerEntry, PickerRow, render_divider, render_picker_content, render_search_bar_with_label, + PickerEntry, PickerRow, render_divider, render_picker_content, + render_picker_search_bar_with_label, }; let mut shortcuts = vec![ @@ -1090,17 +1103,16 @@ fn render_location_picker( } else { (content_area.width, None) }; - render_search_bar_with_label( + render_picker_search_bar_with_label( buf, content_area.x, content_area.y, path_w, theme, " path: ", - &modal.picker.query, + &modal.picker, /* active */ false, /* show_hint */ false, - modal.picker.query_cursor, Some(theme.bg_base), ); modal.worktree_hit.set(wt_rect); @@ -1176,7 +1188,6 @@ fn render_location_picker( // `render_picker_row`'s layout (fold prefix 2, gap 2, trailing 1); the // `-1` conservatively reserves a scrollbar column. let details: Vec = { - use unicode_width::UnicodeWidthStr; const PREFIX: u16 = 2; const GAP: u16 = 2; const TRAILING: u16 = 1; @@ -1757,7 +1768,6 @@ fn render_group_header( selected: bool, hovered: bool, ) { - use unicode_width::UnicodeWidthStr; let bg = Style::default().bg(theme.bg_base); let fill = " ".repeat(rect.width as usize); buf.set_string(rect.x, rect.y, fill, bg); @@ -2043,7 +2053,6 @@ fn render_row( row: &DashboardRow, state: &DashboardState, ) { - use unicode_width::UnicodeWidthStr; if rect.area() == 0 { return; } @@ -2127,19 +2136,17 @@ fn render_row( icon, Style::default().fg(icon_color).bg(bg), ); - let prefix = "rename: "; - let safe_draft = crate::views::session_title::sanitize_display_text(&rn.draft).into_owned(); - let line = format!("{prefix}{safe_draft}"); - let avail = (rect.x + rect.width).saturating_sub(content_start_x + 1); - let truncated = truncate_str(&line, avail as usize); - buf.set_string( + let available = (rect.x + rect.width).saturating_sub(content_start_x); + render_rename_editor( + buf, content_start_x, title_y, - truncated, + available, Style::default() .fg(theme.accent_user) .bg(bg) .add_modifier(Modifier::BOLD), + rn, ); return; } @@ -2358,7 +2365,6 @@ fn render_narrow_rows( // form would push too many rows off-screen on a 40-col terminal). // We still emit group headers and the selection marker so the // visual vocabulary stays consistent. - use unicode_width::UnicodeWidthStr; let lines = build_dashboard_lines( rows, state.grouping, @@ -2486,18 +2492,16 @@ fn render_narrow_rows( &chrome, Style::default().fg(theme.text_primary).bg(bg), ); - let safe_draft = - crate::views::session_title::sanitize_display_text(&rn.draft).into_owned(); - let line = format!("rename: {safe_draft}"); - let truncated = truncate_str(&line, body_width.saturating_sub(chrome_w) as usize); - buf.set_string( + render_rename_editor( + buf, area.x + chrome_w, y, - truncated, + body_width.saturating_sub(chrome_w), Style::default() .fg(theme.accent_user) .bg(bg) .add_modifier(Modifier::BOLD), + rn, ); } else { let marker = if selected { @@ -2621,8 +2625,6 @@ fn paint_dispatch_feedback_badge( theme: &Theme, error_toast: Option<&str>, ) { - use unicode_width::UnicodeWidthStr; - let Some(err) = error_toast else { return; }; @@ -2746,7 +2748,6 @@ fn render_dispatch( overlay_area: Option, ) -> Option<(u16, u16)> { use ratatui::widgets::{Block, BorderType, Borders, Widget}; - use unicode_width::UnicodeWidthStr; use crate::views::prompt_widget::PromptStyle; @@ -2806,7 +2807,7 @@ fn render_dispatch( height: 1, } }; - if content.width < 4 { + if content.width == 0 { return None; } @@ -2817,36 +2818,63 @@ fn render_dispatch( if state.search_mode { let prefix = "Search: "; let prefix_w = UnicodeWidthStr::width(prefix) as u16; - buf.set_string( + let painted_prefix_w = prefix_w.min(content.width); + buf.set_span( content.x, content.y, - prefix, - Style::default() - .fg(theme.warning) - .bg(theme.bg_base) - .add_modifier(Modifier::BOLD), + &Span::styled( + prefix, + Style::default() + .fg(theme.warning) + .bg(theme.bg_base) + .add_modifier(Modifier::BOLD), + ), + painted_prefix_w, ); - let avail = content.width.saturating_sub(prefix_w); - let (to_show, style) = if state.dispatch.text().is_empty() { - ( - "Type to filter sessions\u{2026}".to_string(), - Style::default().fg(theme.gray_dim).bg(theme.bg_base), - ) + let editor_x = content.x + painted_prefix_w; + let avail = content.width - painted_prefix_w; + let cursor_column = if state.dispatch.text().is_empty() { + if avail > 0 { + let placeholder = truncate_str("Type to filter sessions\u{2026}", avail as usize); + buf.set_string( + editor_x, + content.y, + placeholder, + Style::default().fg(theme.gray_dim).bg(theme.bg_base), + ); + } + 0 } else { - ( - state.dispatch.text().to_string(), - Style::default().fg(theme.text_primary).bg(theme.bg_base), + let viewport = xai_ratatui_textarea::EditBuffer::from_parts( + state.dispatch.text(), + state.dispatch.cursor(), ) + .single_line_viewport(avail as usize); + let visible = &state.dispatch.text()[viewport.visible_byte_range]; + if avail > 0 { + buf.set_span( + editor_x, + content.y, + &Span::styled( + visible, + Style::default().fg(theme.text_primary).bg(theme.bg_base), + ), + (UnicodeWidthStr::width(visible) as u16).min(avail), + ); + } + viewport.cursor_display_column as u16 }; - let trunc = truncate_str(&to_show, avail as usize); - buf.set_string(content.x + prefix_w, content.y, trunc, style); - let text_disp_w: u16 = UnicodeWidthStr::width(state.dispatch.text()) - .try_into() - .unwrap_or(u16::MAX); - let cx = content.x + prefix_w + text_disp_w.min(avail.saturating_sub(1)); + let cursor_offset = painted_prefix_w + .saturating_add(cursor_column) + .min(content.width - 1); + let cx = content.x + cursor_offset; return input_focused.then_some((cx, content.y)); } + if content.width < 4 { + return None; + } + let prefix = "\u{276F} "; let prefix_w = UnicodeWidthStr::width(prefix) as u16; @@ -3825,7 +3853,6 @@ pub fn render_popup_overlay( let title_text = format!(" \u{2771} {title_label} "); - use unicode_width::UnicodeWidthStr; let close_label = crate::glyphs::ballot_x_button(); let close_w = UnicodeWidthStr::width(close_label) as u16; // Reserve close-affordance width + a 1-cell gap on the right; @@ -4047,8 +4074,6 @@ fn paint_session_title_bar( left_inset: u16, right_inset: u16, ) -> (Option, Option, Option) { - use unicode_width::UnicodeWidthStr; - // `‹` / `›` / `✗` are all painted as plain bracketed text // (no button background fills). Hover only changes the fg // color (`text_primary` vs `gray`) for subtle clickability @@ -4267,6 +4292,51 @@ mod tests { ); } + #[test] + fn render_dashboard_shows_roster_when_local_agents_empty() { + use crate::app::roster::{RosterActivity, RosterEntry, RosterOrigin}; + + let area = Rect::new(0, 0, 100, 24); + let mut buf = Buffer::empty(area); + let mut agents: IndexMap = IndexMap::new(); + let mut state = DashboardState::new(); + let registry = crate::actions::ActionRegistry::defaults(); + let roster = [RosterEntry { + session_id: "sess-fleet-1".into(), + title: Some("Fix fleet dashboard".into()), + cwd: "/repo/work".into(), + is_worktree: false, + model_id: None, + yolo: false, + activity: RosterActivity::Working, + resident: true, + last_change_unix_ms: 1_725_000_000_000, + origin: RosterOrigin::default(), + }]; + + let _ = render_dashboard( + &mut buf, + area, + &mut state, + &mut agents, + ®istry, + None, + &roster, + false, + None, + ); + + let content = buf_to_text(&buf); + assert!( + content.contains("Fix fleet dashboard"), + "roster-only working session must paint when local agents are empty, got: {content:?}" + ); + assert!( + !content.contains("No agents yet"), + "must not show empty-state while roster rows exist, got: {content:?}" + ); + } + /// While the local session roster is still loading the empty body /// shows a loading hint instead of the "no agents" copy. #[test] @@ -5385,13 +5455,10 @@ mod tests { ); } - /// The in-flight rename overlay sanitises the - /// draft before painting so a smuggled ANSI escape never lands in - /// the buffer (test wide-mode and narrow-mode separately). + /// RenameDraft sanitation keeps control characters out of both render paths. #[test] - fn render_rename_overlay_strips_control_chars_from_live_draft() { + fn sanitized_rename_draft_is_safe_in_both_render_paths() { use crate::app::agent::AgentId; - use crate::views::dashboard::state::RenameDraft; let id = DashboardRowId::TopLevel(AgentId(7)); let row = DashboardRow { id: id.clone(), @@ -5420,10 +5487,7 @@ mod tests { let mut buf = Buffer::empty(Rect::new(0, 0, 80, 3)); let mut state = DashboardState::new(); state.selected = Some(id.clone()); - state.rename = Some(RenameDraft { - row: id.clone(), - draft: "a\x1b[31m".to_string(), - }); + state.rename = Some(RenameDraft::new(id.clone(), "a\x1b[31m")); render_rows(&mut buf, Rect::new(0, 0, 80, 3), &theme, &rows, &mut state); let content = buf_to_text(&buf); assert!( @@ -5441,10 +5505,7 @@ mod tests { let mut buf = Buffer::empty(Rect::new(0, 0, 30, 3)); let mut state = DashboardState::new(); state.selected = Some(id.clone()); - state.rename = Some(RenameDraft { - row: id.clone(), - draft: "a\x1b[31m".to_string(), - }); + state.rename = Some(RenameDraft::new(id.clone(), "a\x1b[31m")); render_narrow_rows(&mut buf, Rect::new(0, 0, 30, 3), &theme, &rows, &mut state); let content = buf_to_text(&buf); assert!( @@ -5458,13 +5519,10 @@ mod tests { } } - /// The rename overlay keeps the row's chrome (state icon) and paints - /// `rename:` at the title's own column, so the editing row stays - /// aligned with its neighbours (wide and narrow layouts). + /// Rename rendering preserves row chrome and title alignment in both layouts. #[test] fn render_rename_overlay_aligns_with_title_and_keeps_icon() { use crate::app::agent::AgentId; - use crate::views::dashboard::state::RenameDraft; let id = DashboardRowId::TopLevel(AgentId(7)); let row = DashboardRow { id: id.clone(), @@ -5506,10 +5564,7 @@ mod tests { { let mut buf = Buffer::empty(Rect::new(0, 0, 80, 5)); let mut state = DashboardState::new(); - state.rename = Some(RenameDraft { - row: id.clone(), - draft: "new name".to_string(), - }); + state.rename = Some(RenameDraft::new(id.clone(), "new name")); render_rows(&mut buf, Rect::new(0, 0, 80, 5), &theme, &rows, &mut state); let line = row_text(&buf, 2, 80); assert_eq!( @@ -5533,10 +5588,7 @@ mod tests { ); // With an empty draft the cursor sits immediately after // `rename: ` (the position typing lands at). - state.rename = Some(RenameDraft { - row: id.clone(), - draft: String::new(), - }); + state.rename = Some(RenameDraft::new(id.clone(), "")); assert_eq!( rename_cursor_pos(&state, &rows), Some((title_col + prefix_w, 2)), @@ -5556,10 +5608,7 @@ mod tests { { let mut buf = Buffer::empty(Rect::new(0, 0, 30, 3)); let mut state = DashboardState::new(); - state.rename = Some(RenameDraft { - row: id.clone(), - draft: "nn".to_string(), - }); + state.rename = Some(RenameDraft::new(id.clone(), "nn")); render_narrow_rows(&mut buf, Rect::new(0, 0, 30, 3), &theme, &rows, &mut state); let line = row_text(&buf, 1, 30); assert_eq!( @@ -5575,6 +5624,97 @@ mod tests { } } + #[test] + fn rename_viewport_handles_long_unicode_in_wide_and_narrow_rows() { + use crate::app::agent::AgentId; + use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; + + let id = DashboardRowId::TopLevel(AgentId(7)); + let row = DashboardRow { + id: id.clone(), + label: "row label".to_string(), + subtitle: None, + state: RowState::Idle, + activity: None, + secondary_line: None, + cwd_display: String::new(), + cwd: std::path::PathBuf::from("/tmp"), + last_change_at: std::time::SystemTime::now(), + pinned: false, + is_active: false, + badges: Vec::new(), + context_pct: None, + indent: 0, + parent_label: None, + is_more_placeholder: false, + more_count: 0, + }; + let rows = vec![row]; + let text = format!("{}中e\u{301}👩🏽\u{200d}💻", "x".repeat(90)); + let theme = Theme::current(); + let registry = crate::actions::ActionRegistry::defaults(); + + for (width, narrow, row_y) in [(80, false, 2), (30, true, 1)] { + let area = Rect::new(0, 0, width, if narrow { 3 } else { 5 }); + let mut buffer = Buffer::empty(area); + let mut state = DashboardState::new(); + state.rename = Some(RenameDraft::new(id.clone(), text.clone())); + if narrow { + render_narrow_rows(&mut buffer, area, &theme, &rows, &mut state); + } else { + render_rows(&mut buffer, area, &theme, &rows, &mut state); + } + let line = (0..width) + .map(|x| buffer[(x, row_y)].symbol().to_string()) + .collect::(); + assert!(line.contains('中'), "CJK tail missing: {line:?}"); + assert!(line.contains("e\u{301}"), "combining tail split: {line:?}"); + assert!(line.contains("👩🏽\u{200d}💻"), "ZWJ tail split: {line:?}",); + let end_cursor = rename_cursor_pos(&state, &rows).expect("end cursor"); + + let _ = state.handle_input( + &Event::Key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)), + ®istry, + ); + for _ in 0..20 { + let _ = state.handle_input( + &Event::Key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)), + ®istry, + ); + } + let mut middle_buffer = Buffer::empty(area); + if narrow { + render_narrow_rows(&mut middle_buffer, area, &theme, &rows, &mut state); + } else { + render_rows(&mut middle_buffer, area, &theme, &rows, &mut state); + } + let middle_cursor = rename_cursor_pos(&state, &rows).expect("middle cursor"); + assert_ne!( + state.rename.as_ref().expect("rename draft").cursor_byte(), + text.len() + ); + if !narrow { + assert_ne!(middle_cursor, end_cursor); + } + let prefix_x = (0..width) + .find(|x| middle_buffer[(*x, row_y)].symbol() == "r") + .expect("rename prefix"); + let row_rect = state + .row_rects + .iter() + .find(|(row_id, _)| row_id == &id) + .map(|(_, rect)| *rect) + .expect("rename row rect"); + let editor_x = prefix_x + RENAME_PREFIX.len() as u16; + let editor_width = row_rect + .x + .saturating_add(row_rect.width) + .saturating_sub(editor_x); + let expected_cursor = editor_x + 20u16.min(editor_width.saturating_sub(1)); + assert_eq!(middle_cursor, (expected_cursor, row_y)); + } + } + /// On a 3-row rect the dispatch input /// paints a rounded-box chrome so it reads as a real input /// field. The text row contains the `❯` prefix. @@ -5598,6 +5738,52 @@ mod tests { ); } + #[test] + fn render_search_mode_uses_textarea_cursor_not_text_end() { + let area = Rect::new(0, 0, 40, 3); + let mut buffer = Buffer::empty(area); + let theme = Theme::current(); + let mut state = DashboardState::new(); + state.search_mode = true; + state.dispatch.set_text("abcdef"); + state.dispatch.set_cursor(2); + + let cursor = render_dispatch(&mut buffer, area, &theme, &mut state, None) + .expect("focused search cursor"); + let prefix_x = (0..area.width) + .find(|x| buffer[(*x, cursor.1)].symbol() == "S") + .expect("Search prefix"); + assert_eq!(cursor.0, prefix_x + "Search: ".len() as u16 + 2); + } + + #[test] + fn render_search_mode_clips_prefix_and_cursor_at_widths_one_through_nine() { + let theme = Theme::current(); + for width in 1..=9 { + let full = Rect::new(0, 0, 14, 1); + let area = Rect::new(2, 0, width, 1); + let mut buffer = Buffer::empty(full); + buffer.set_string(0, 0, "#".repeat(full.width as usize), Style::default()); + let mut state = DashboardState::new(); + state.search_mode = true; + state.dispatch.set_text("abcdef"); + state.dispatch.set_cursor(2); + + let cursor = render_dispatch(&mut buffer, area, &theme, &mut state, None) + .expect("focused narrow search cursor"); + assert!(cursor.0 >= area.x && cursor.0 < area.x + area.width); + for x in 0..full.width { + if x < area.x || x >= area.x + area.width { + assert_eq!( + buffer[(x, 0)].symbol(), + "#", + "width {width} wrote outside at column {x}", + ); + } + } + } + } + #[test] fn render_dispatch_keeps_generic_paste_preview_but_suppresses_image_preview() { let area = Rect::new(0, 17, 80, 3); @@ -7340,8 +7526,7 @@ mod tests { std::path::PathBuf::from("/base"), std::collections::HashMap::new(), ); - modal.picker.query = "/tmp/zzz".to_string(); - modal.picker.query_cursor = modal.picker.query.len(); + modal.picker.set_query("/tmp/zzz"); render_location_picker(&mut buf, area, &theme, &mut modal); let content = buf_to_text(&buf); assert!( @@ -8255,21 +8440,16 @@ mod tests { ); } - /// An in-flight rename swaps the footer for its two actions — - /// Enter saves, Esc cancels — and hides the normal nav/stop chips. + /// Rename mode shows only save and cancel actions. #[test] fn render_footer_rename_shows_save_and_cancel() { use crate::app::agent::AgentId; - use crate::views::dashboard::state::RenameDraft; let theme = Theme::current(); let registry = crate::actions::ActionRegistry::defaults(); let mut state = DashboardState::new(); let id = DashboardRowId::TopLevel(AgentId(0)); state.focus_row(id.clone()); - state.rename = Some(RenameDraft { - row: id, - draft: String::new(), - }); + state.rename = Some(RenameDraft::new(id, "")); let mut buf = Buffer::empty(Rect::new(0, 0, 200, 1)); render_footer( &mut buf, diff --git a/crates/codegen/xai-grok-pager/src/views/dashboard/row.rs b/crates/codegen/xai-grok-pager/src/views/dashboard/row.rs index 2b2e6ba..c97c020 100644 --- a/crates/codegen/xai-grok-pager/src/views/dashboard/row.rs +++ b/crates/codegen/xai-grok-pager/src/views/dashboard/row.rs @@ -389,9 +389,9 @@ pub fn classify_top_level(agent: &AgentView) -> RowState { /// (`run_terminal_command` with `background=true`), a running `monitor` /// (a background task with `is_monitor`), or an active scheduled `/loop`. /// Mirrors the agent view's idle "watching" cue -/// (`crate::views::turn_status::Watchers`) but also counts plain -/// background tasks — any in-flight background work the user dispatched -/// should read as "Working" on the dashboard. +/// (`crate::views::turn_status::Watchers`, minus subagents — the dashboard +/// lists those as their own rows) — any in-flight background work the user +/// dispatched should read as "Working" on the dashboard. pub fn has_background_work(agent: &AgentView) -> bool { agent .session diff --git a/crates/codegen/xai-grok-pager/src/views/dashboard/state.rs b/crates/codegen/xai-grok-pager/src/views/dashboard/state.rs index 54ab678..5071118 100644 --- a/crates/codegen/xai-grok-pager/src/views/dashboard/state.rs +++ b/crates/codegen/xai-grok-pager/src/views/dashboard/state.rs @@ -14,6 +14,7 @@ use crate::actions::ActionRegistry; use crate::app::actions::Action; use crate::app::agent::AgentId; use crate::app::app_view::InputOutcome; +use crate::input::line_editor::{LineEditOutcome, LineEditor}; use crate::key; use crate::views::prompt_widget::PromptWidget; @@ -56,7 +57,7 @@ impl DashboardRowId { pub(crate) struct PeekViewportLease { pub row: DashboardRowId, pub snapshot: crate::scrollback::state::ViewportSnapshot, - pub page_flip_entry: Option, + pub page_flip_entry: Option, } pub(crate) fn scrollback_mut_for_row<'a>( @@ -766,7 +767,51 @@ pub struct ShortcutsModalState { #[derive(Debug, Clone)] pub struct RenameDraft { pub row: DashboardRowId, - pub draft: String, + editor: LineEditor, +} + +const MAX_RENAME_SCALARS: usize = 100; + +impl RenameDraft { + pub fn new(row: DashboardRowId, text: impl Into) -> Self { + let mut draft = Self { + row, + editor: LineEditor::default(), + }; + draft.set_text(text); + draft + } + + pub fn text(&self) -> &str { + self.editor.text() + } + + pub fn cursor_byte(&self) -> usize { + self.editor.cursor_byte() + } + + pub(crate) fn viewport(&self, width: usize) -> xai_ratatui_textarea::SingleLineViewport { + self.editor.viewport(width) + } + + pub(crate) fn set_text(&mut self, text: impl Into) { + let text = text + .into() + .chars() + .filter(|character| rename_wire_character_allowed(*character)) + .take(MAX_RENAME_SCALARS) + .collect::(); + self.editor.set_text(text); + } +} + +fn rename_character_allowed(character: char) -> bool { + !crate::render::line_utils::is_unsafe_display_char(character) +} + +fn rename_wire_character_allowed(character: char) -> bool { + // Preserve an existing emoji ZWJ sequence; interactive inserts still reject format chars. + character == '\u{200d}' || rename_character_allowed(character) } /// One selectable directory in the location picker (see @@ -844,10 +889,7 @@ impl LocationPickerState { base_cwd: PathBuf, worktrees: std::collections::HashMap, ) -> Self { - let picker = crate::views::picker::PickerState { - search_active: true, - ..crate::views::picker::PickerState::default() - }; + let picker = crate::views::picker::PickerState::input_active(); Self { picker, window: crate::views::modal_window::ModalWindowState::new(), @@ -880,7 +922,7 @@ impl LocationPickerState { /// Whether the current query should be treated as a filesystem path /// (directory completion) rather than a fuzzy filter over recents. pub fn query_is_path(&self) -> bool { - let q = &self.picker.query; + let q = self.picker.query(); q.starts_with('/') || q.starts_with('~') || q.contains('/') @@ -896,7 +938,7 @@ impl LocationPickerState { /// home; relative parents join [`Self::base_cwd`]. The separator is `/` /// on all hosts and additionally `\` on Windows. fn path_query_parts(&self) -> (PathBuf, String) { - let q = self.picker.query.as_str(); + let q = self.picker.query(); // Last path separator: `/` always; `\` additionally on Windows. let sep = match (q.rfind('/'), cfg!(windows).then(|| q.rfind('\\')).flatten()) { (Some(a), Some(b)) => Some(a.max(b)), @@ -963,7 +1005,7 @@ impl LocationPickerState { .cloned() .collect() } else { - let q = self.picker.query.trim().to_lowercase(); + let q = self.picker.query().trim().to_lowercase(); self.recents .iter() .filter(|c| { @@ -986,7 +1028,7 @@ impl LocationPickerState { if let Some(c) = visible.get(self.picker.selected) { return Some(c.path.to_string_lossy().into_owned()); } - let q = self.picker.query.trim(); + let q = self.picker.query().trim(); if !q.is_empty() { return Some(q.to_string()); } @@ -1778,7 +1820,9 @@ impl DashboardState { let w = lease.snapshot.last_width; let h = lease.snapshot.viewport_height; sb.restore_viewport_snapshot(lease.snapshot); - if let Some(idx) = page_flip { + if let Some(entry_id) = page_flip + && let Some(idx) = sb.index_of_id(entry_id) + { if w > 0 && h > 0 { sb.prepare_layout(w, h); } @@ -1814,32 +1858,11 @@ impl DashboardState { }); } - pub fn note_page_flip_for_lease( + pub(crate) fn note_page_flip_for_lease( &mut self, agent_id: AgentId, - agents: &mut indexmap::IndexMap, - ) { - let Some(row) = self - .peek_viewport - .as_ref() - .filter(|lease| lease.row.matches_top_level_agent(agent_id)) - .map(|lease| lease.row.clone()) - else { - return; - }; - let Some(sb) = scrollback_mut_for_row(&row, agents) else { - return; - }; - let selected = sb.selected(); - let current_turn = sb.current_turn(); - self.note_page_flip_from_scroll(agent_id, selected, current_turn); - } - - pub(crate) fn note_page_flip_from_scroll( - &mut self, - agent_id: AgentId, - selected: Option, - current_turn: Option, + entry_id: crate::scrollback::EntryId, + agents: &indexmap::IndexMap, ) { let Some(lease) = self.peek_viewport.as_mut() else { return; @@ -1847,11 +1870,16 @@ impl DashboardState { if !lease.row.matches_top_level_agent(agent_id) { return; } - lease.page_flip_entry = selected; - lease.snapshot.follow_mode = true; - lease.snapshot.follow_preserve_scroll = true; - lease.snapshot.selected = selected; - lease.snapshot.current_turn = current_turn; + let Some(sb) = agents.get(&agent_id).map(|agent| &agent.scrollback) else { + return; + }; + if sb.index_of_id(entry_id).is_none() { + return; + } + if !sb.is_follow_preserve_scroll() { + return; + } + lease.page_flip_entry = Some(entry_id); } /// Clear the peek reply draft AND its undo history. @@ -1989,12 +2017,14 @@ impl DashboardState { return self.handle_worktree_dialog_input(ev); } - // Rename mode owns the keyboard until Enter / Esc. + // Rename mode owns input until committed or cancelled. if let Some(ref mut rn) = self.rename { - if let Event::Key(key) = ev - && key.kind != KeyEventKind::Release - { - return handle_rename_key(rn, key); + match ev { + Event::Key(key) if key.kind != KeyEventKind::Release => { + return handle_rename_key(rn, key); + } + Event::Paste(text) => return handle_rename_paste(rn, text), + _ => {} } return InputOutcome::Unchanged; } @@ -3478,7 +3508,7 @@ impl DashboardState { // Forward to the prompt widget (single-line). let old = self.dispatch.text().to_string(); - let _ = self.dispatch.handle_key(key); + let event = self.dispatch.handle_key(key); let new = self.dispatch.text().to_string(); if old != new { // Live-update the filter as the user types ONLY in search @@ -3511,6 +3541,8 @@ impl DashboardState { self.manual_scroll_active = false; } InputOutcome::Changed + } else if event == crate::views::prompt_widget::PromptEvent::Edited { + InputOutcome::Changed } else { InputOutcome::Unchanged } @@ -3958,8 +3990,7 @@ impl DashboardState { if !filled.ends_with('/') { filled.push('/'); } - lp.picker.query = filled; - lp.picker.query_cursor = lp.picker.query.len(); + lp.picker.set_query(filled); lp.picker.selected = 0; lp.picker.scroll_offset = None; // The path changed — drop any stale "Not a directory" error. @@ -3970,23 +4001,22 @@ impl DashboardState { let entry_count = lp.visible_candidates().len(); let config = location_picker_config(); - let query_before = lp.picker.query.clone(); let outcome = crate::views::picker::handle_picker_input(ev, &mut lp.picker, entry_count, &config); // When the user edits the path, drop the stale validation error so a // corrected (possibly valid) path isn't shown next to a red // "Not a directory" left over from the previous failed attempt. - if lp.picker.query != query_before { + if matches!(&outcome, crate::views::picker::PickerOutcome::QueryChanged) { lp.error = None; + // Re-list only when the edited path changes; cursor motion is redraw-only. + lp.refresh_suggestions(); } - // The query may have changed (typing / backspace / Ctrl+U) — re-list - // the parent directory if its path-mode parent moved. - lp.refresh_suggestions(); match outcome { crate::views::picker::PickerOutcome::Closed => { InputOutcome::Action(Action::DashboardCloseLocationPicker) } - crate::views::picker::PickerOutcome::Changed => InputOutcome::Changed, + crate::views::picker::PickerOutcome::Changed + | crate::views::picker::PickerOutcome::QueryChanged => InputOutcome::Changed, _ => InputOutcome::Unchanged, } } @@ -4078,14 +4108,12 @@ impl DashboardState { let Some(dialog) = self.worktree_dialog.as_mut() else { return InputOutcome::Unchanged; }; - let Event::Key(key) = ev else { - // Consume mouse / resize while the dialog is modal. - return InputOutcome::Unchanged; + let outcome = match ev { + Event::Key(key) if key.kind != KeyEventKind::Release => dialog.handle_key(key), + Event::Paste(text) => dialog.insert_paste(text), + _ => return InputOutcome::Unchanged, }; - if key.kind == KeyEventKind::Release { - return InputOutcome::Unchanged; - } - match dialog.handle_key(key) { + match outcome { NewWorktreeDialogOutcome::Submitted(label) => { self.worktree_dialog = None; InputOutcome::Action(Action::DashboardConfirmWorktree { label }) @@ -4121,7 +4149,7 @@ impl DashboardState { /// chrome + picker pipeline via `handle_modal_key`. fn handle_shortcuts_modal_input(&mut self, ev: &Event) -> InputOutcome { use crate::views::shortcuts_help::{ - ModalKeyOutcome, ShortcutsHelpOutcome, handle_modal_key, handle_mouse, + ModalKeyOutcome, ShortcutsHelpOutcome, handle_modal_key, handle_mouse, handle_paste, toggle_membership, }; @@ -4213,6 +4241,10 @@ impl DashboardState { ShortcutsHelpOutcome::Unchanged => InputOutcome::Unchanged, } } + Event::Paste(text) => match handle_paste(text, &mut modal.state, &modal.mode) { + ShortcutsHelpOutcome::Changed => InputOutcome::Changed, + _ => InputOutcome::Unchanged, + }, _ => InputOutcome::Unchanged, } } @@ -4440,40 +4472,43 @@ fn dashboard_action_for_id( fn handle_rename_key(draft: &mut RenameDraft, key: &KeyEvent) -> InputOutcome { use crate::input::key::is_altgr; - // Reject Ctrl/Alt-modified character keys so - // Ctrl+R / Ctrl+A / Ctrl+V don't smuggle a bare letter into the - // draft. Ctrl+C is explicitly mapped to cancel. - if key.modifiers.contains(KeyModifiers::CONTROL) - && !is_altgr(key.modifiers) - && let KeyCode::Char(c) = key.code - { - if c == 'c' { + match key.code { + KeyCode::Esc => return InputOutcome::Action(Action::DashboardCancelRename), + KeyCode::Enter if key.modifiers.is_empty() => { + return InputOutcome::Action(Action::DashboardCommitRename); + } + KeyCode::Char('c') + if key.modifiers.contains(KeyModifiers::CONTROL) && !is_altgr(key.modifiers) => + { return InputOutcome::Action(Action::DashboardCancelRename); } - return InputOutcome::Unchanged; + _ => {} } - if key.modifiers.contains(KeyModifiers::ALT) && !is_altgr(key.modifiers) { - return InputOutcome::Unchanged; - } - match key.code { - KeyCode::Esc => InputOutcome::Action(Action::DashboardCancelRename), - KeyCode::Enter => InputOutcome::Action(Action::DashboardCommitRename), - KeyCode::Backspace => { - draft.draft.pop(); - InputOutcome::Action(Action::DashboardRenameInput(draft.draft.clone())) - } - KeyCode::Char(c) => { - // Reject control characters and zero-width chars. - if c.is_control() { - return InputOutcome::Unchanged; - } - // Cap at 100 chars to match the worktree dialog input. - if draft.draft.chars().count() < 100 { - draft.draft.push(c); - } - InputOutcome::Action(Action::DashboardRenameInput(draft.draft.clone())) - } - _ => InputOutcome::Unchanged, + + let can_insert = draft.text().chars().count() < MAX_RENAME_SCALARS; + let outcome = draft + .editor + .handle_key_with_insert_policy(key, |character| { + can_insert && rename_character_allowed(character) + }); + rename_edit_outcome(outcome) +} + +fn handle_rename_paste(draft: &mut RenameDraft, text: &str) -> InputOutcome { + let remaining = MAX_RENAME_SCALARS.saturating_sub(draft.text().chars().count()); + let outcome = + draft + .editor + .insert_paste_with_policy(text, rename_wire_character_allowed, remaining); + rename_edit_outcome(outcome) +} + +fn rename_edit_outcome(outcome: LineEditOutcome) -> InputOutcome { + match outcome { + LineEditOutcome::TextChanged + | LineEditOutcome::HandledNoChange + | LineEditOutcome::CursorChanged => InputOutcome::Changed, + LineEditOutcome::Unhandled => InputOutcome::Unchanged, } } @@ -5388,45 +5423,38 @@ mod tests { /// Rename cap is honored exactly. #[test] fn rename_at_cap_drops_extra_char() { - let mut draft = RenameDraft { - row: DashboardRowId::TopLevel(AgentId(0)), - draft: "a".repeat(100), - }; + let mut draft = RenameDraft::new(DashboardRowId::TopLevel(AgentId(0)), "a".repeat(100)); let key = KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE); - let _ = handle_rename_key(&mut draft, &key); - assert_eq!(draft.draft.chars().count(), 100); + let outcome = handle_rename_key(&mut draft, &key); + assert!(matches!(outcome, InputOutcome::Changed)); + assert_eq!(draft.text().chars().count(), 100); assert!( - draft.draft.ends_with('a'), + draft.text().ends_with('a'), "char at cap should NOT be replaced: got {:?}", - draft.draft + draft.text() ); } /// under-cap appends correctly. #[test] fn rename_under_cap_appends() { - let mut draft = RenameDraft { - row: DashboardRowId::TopLevel(AgentId(0)), - draft: "a".repeat(99), - }; + let mut draft = RenameDraft::new(DashboardRowId::TopLevel(AgentId(0)), "a".repeat(99)); let key = KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE); - let _ = handle_rename_key(&mut draft, &key); - assert_eq!(draft.draft.chars().count(), 100); - assert!(draft.draft.ends_with('b')); + let outcome = handle_rename_key(&mut draft, &key); + assert!(matches!(outcome, InputOutcome::Changed)); + assert_eq!(draft.text().chars().count(), 100); + assert!(draft.text().ends_with('b')); } /// Ctrl+letter in rename mode rejected (does not type /// the bare letter into the draft); Ctrl+C cancels. #[test] fn rename_rejects_ctrl_chars() { - let mut draft = RenameDraft { - row: DashboardRowId::TopLevel(AgentId(0)), - draft: "hello".to_string(), - }; + let mut draft = RenameDraft::new(DashboardRowId::TopLevel(AgentId(0)), "hello"); let ctrl_r = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL); let outcome = handle_rename_key(&mut draft, &ctrl_r); assert!(matches!(outcome, InputOutcome::Unchanged)); - assert_eq!(draft.draft, "hello", "draft must not gain 'r'"); + assert_eq!(draft.text(), "hello", "draft must not gain 'r'"); // Ctrl+C → cancel. let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); let outcome = handle_rename_key(&mut draft, &ctrl_c); @@ -5436,6 +5464,125 @@ mod tests { )); } + #[test] + fn rename_word_motion_is_canonical_and_cursor_only() { + for key in [ + KeyEvent::new(KeyCode::Left, KeyModifiers::ALT), + KeyEvent::new(KeyCode::Char('b'), KeyModifiers::ALT), + KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL), + ] { + let mut draft = RenameDraft::new(DashboardRowId::TopLevel(AgentId(0)), "hello-world"); + let outcome = handle_rename_key(&mut draft, &key); + assert!(matches!(outcome, InputOutcome::Changed)); + assert_eq!(draft.text(), "hello-world"); + assert_eq!(draft.cursor_byte(), "hello-".len()); + } + + for key in [ + KeyEvent::new(KeyCode::Right, KeyModifiers::ALT), + KeyEvent::new(KeyCode::Char('f'), KeyModifiers::ALT), + ] { + let mut draft = RenameDraft::new(DashboardRowId::TopLevel(AgentId(0)), "hello-world"); + let _ = handle_rename_key( + &mut draft, + &KeyEvent::new(KeyCode::Home, KeyModifiers::NONE), + ); + let outcome = handle_rename_key(&mut draft, &key); + assert!(matches!(outcome, InputOutcome::Changed)); + assert_eq!(draft.cursor_byte(), "hello".len()); + } + + let mut draft = RenameDraft::new(DashboardRowId::TopLevel(AgentId(0)), "hello-world"); + let outcome = handle_rename_key( + &mut draft, + &KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT), + ); + assert!(matches!(outcome, InputOutcome::Changed)); + assert_eq!(draft.text(), "hello-"); + } + + #[test] + fn rename_grapheme_delete_and_middle_insert() { + let grapheme = "👩🏽\u{200d}💻"; + let mut draft = RenameDraft::new( + DashboardRowId::TopLevel(AgentId(0)), + format!("a{grapheme}b"), + ); + let _ = handle_rename_key( + &mut draft, + &KeyEvent::new(KeyCode::Home, KeyModifiers::NONE), + ); + let _ = handle_rename_key( + &mut draft, + &KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), + ); + let outcome = handle_rename_key( + &mut draft, + &KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE), + ); + assert!(matches!(outcome, InputOutcome::Changed)); + assert_eq!(draft.text(), "ab"); + + let outcome = handle_rename_key( + &mut draft, + &KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE), + ); + assert!(matches!(outcome, InputOutcome::Changed)); + assert_eq!(draft.text(), "aXb"); + } + + #[test] + fn rename_policy_and_paste_preserve_scalar_cap() { + let mut draft = RenameDraft::new(DashboardRowId::TopLevel(AgentId(0)), "a".repeat(99)); + let outcome = handle_rename_key( + &mut draft, + &KeyEvent::new(KeyCode::Char('\u{202e}'), KeyModifiers::NONE), + ); + assert!(matches!(outcome, InputOutcome::Changed)); + assert_eq!(draft.text().chars().count(), 99); + + let outcome = handle_rename_paste(&mut draft, "中\r\n文"); + assert!(matches!(outcome, InputOutcome::Changed)); + assert_eq!(draft.text().chars().count(), 100); + assert!(draft.text().ends_with('中')); + } + + #[test] + fn modified_enter_does_not_commit_rename() { + let mut draft = RenameDraft::new(DashboardRowId::TopLevel(AgentId(0)), "name"); + for modifiers in [KeyModifiers::ALT, KeyModifiers::SHIFT] { + let outcome = handle_rename_key(&mut draft, &KeyEvent::new(KeyCode::Enter, modifiers)); + assert!(!matches!( + outcome, + InputOutcome::Action(Action::DashboardCommitRename) + )); + } + } + + #[test] + fn rename_paste_preserves_emoji_zwj_sequences() { + let mut draft = RenameDraft::new(DashboardRowId::TopLevel(AgentId(0)), ""); + let outcome = handle_rename_paste(&mut draft, "👩‍💻"); + assert!(matches!(outcome, InputOutcome::Changed)); + assert_eq!(draft.text(), "👩‍💻"); + } + + #[test] + fn rename_mode_routes_bracketed_paste_only_to_rename_editor() { + let mut state = DashboardState::new(); + state.dispatch.set_text("hidden dispatch"); + state.rename = Some(RenameDraft::new(DashboardRowId::TopLevel(AgentId(0)), "ab")); + let registry = crate::actions::ActionRegistry::defaults(); + let _ = state.handle_input( + &Event::Key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)), + ®istry, + ); + let outcome = state.handle_input(&Event::Paste("中\r\n".to_owned()), ®istry); + assert!(matches!(outcome, InputOutcome::Changed)); + assert_eq!(state.rename.as_ref().map(RenameDraft::text), Some("a中b")); + assert_eq!(state.dispatch.text(), "hidden dispatch"); + } + /// Esc-cancelling the worktree-label dialog must restore the stashed /// prompt (from the prompt-send path) to the dispatch input instead of /// silently discarding the user's typed text. Mirrors the restore in @@ -8694,6 +8841,25 @@ mod tests { ); } + #[test] + fn search_mode_cursor_only_edit_redraws_without_filter_change() { + let mut state = DashboardState::new(); + let registry = crate::actions::ActionRegistry::defaults(); + state.enter_search_mode(); + state.dispatch.set_text("auth"); + state.dispatch.set_cursor(0); + state.filter = Filter::Substring("auth".to_owned()); + + let outcome = state.handle_input( + &Event::Key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)), + ®istry, + ); + assert!(matches!(outcome, InputOutcome::Changed)); + assert_eq!(state.dispatch.text(), "auth"); + assert_eq!(state.dispatch.cursor(), 1); + assert!(matches!(&state.filter, Filter::Substring(text) if text == "auth")); + } + /// Esc in search mode CANCELS: clears the filter and exits. #[test] fn search_mode_esc_cancels_and_clears_filter() { @@ -9361,7 +9527,7 @@ mod tests { let m = s.shortcuts_modal.as_ref().unwrap(); ( m.state.selected, - m.state.query.clone(), + m.state.query().to_owned(), m.filter_active, m.collapsed_sections.clone(), m.expanded_ids.clone(), @@ -10119,7 +10285,7 @@ mod tests { location_candidate("/home/me/alpha", "alpha"), location_candidate("/home/me/beta", "beta"), ]); - lp.picker.query = "bet".to_string(); + lp.picker.set_query("bet"); assert_eq!(visible_labels(&lp), vec!["beta"]); } @@ -10127,11 +10293,11 @@ mod tests { fn location_query_is_path_detection() { let mut lp = location_picker(vec![]); for q in ["/abs", "~/x", "rel/sub", "~"] { - lp.picker.query = q.to_string(); + lp.picker.set_query(q); assert!(lp.query_is_path(), "`{q}` should be path mode"); } for q in ["", "alpha", "bet"] { - lp.picker.query = q.to_string(); + lp.picker.set_query(q); assert!(!lp.query_is_path(), "`{q}` should be recents mode"); } } @@ -10154,7 +10320,7 @@ mod tests { fn location_chosen_input_falls_back_to_typed_path() { let mut lp = location_picker(vec![location_candidate("/home/me/alpha", "alpha")]); // A path with no matching suggestion → the raw typed path is used. - lp.picker.query = "/no/such/dir".to_string(); + lp.picker.set_query("/no/such/dir"); assert_eq!(lp.chosen_input().as_deref(), Some("/no/such/dir")); } @@ -10177,7 +10343,7 @@ mod tests { let mut lp = location_picker(vec![]); // Trailing slash → list the (non-hidden) subdirs. - lp.picker.query = format!("{}/", tmp.path().display()); + lp.picker.set_query(format!("{}/", tmp.path().display())); lp.refresh_suggestions(); let labels = visible_labels(&lp); assert!(labels.contains(&"alpha".to_string()), "got: {labels:?}"); @@ -10188,12 +10354,12 @@ mod tests { ); // Prefix filter on the final segment. - lp.picker.query = format!("{}/al", tmp.path().display()); + lp.picker.set_query(format!("{}/al", tmp.path().display())); lp.refresh_suggestions(); assert_eq!(visible_labels(&lp), vec!["alpha"]); // A leading dot in the partial reveals dot-directories. - lp.picker.query = format!("{}/.h", tmp.path().display()); + lp.picker.set_query(format!("{}/.h", tmp.path().display())); lp.refresh_suggestions(); assert_eq!(visible_labels(&lp), vec![".hidden"]); } @@ -10209,7 +10375,7 @@ mod tests { worktrees.insert(canon.join("wt"), "my-feature".to_string()); let mut lp = location_picker_with_worktrees(vec![], worktrees); - lp.picker.query = format!("{}/", tmp.path().display()); + lp.picker.set_query(format!("{}/", tmp.path().display())); lp.refresh_suggestions(); let visible = lp.visible_candidates(); @@ -10239,7 +10405,7 @@ mod tests { worktrees.insert(real_canon, "linked-wt".to_string()); let mut lp = location_picker_with_worktrees(vec![], worktrees); - lp.picker.query = format!("{}/", parent.path().display()); + lp.picker.set_query(format!("{}/", parent.path().display())); lp.refresh_suggestions(); let link = lp @@ -10351,8 +10517,8 @@ mod tests { InputOutcome::Changed )); let lp = state.location_picker.as_ref().unwrap(); - assert_eq!(lp.picker.query, "/opt/projects/beta/"); - assert_eq!(lp.picker.query_cursor, lp.picker.query.len()); + assert_eq!(lp.picker.query(), "/opt/projects/beta/"); + assert_eq!(lp.picker.query_cursor(), lp.picker.query().len()); } #[test] @@ -10360,7 +10526,7 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); std::fs::create_dir(tmp.path().join("alpha")).unwrap(); let mut lp = location_picker(vec![]); - lp.picker.query = format!("{}/al", tmp.path().display()); + lp.picker.set_query(format!("{}/al", tmp.path().display())); lp.refresh_suggestions(); let mut state = DashboardState::new(); @@ -10458,19 +10624,21 @@ mod tests { let (id, mut agents) = lease_fixture_agent(); let mut dash = DashboardState::new(); dash.begin_peek_viewport(DashboardRowId::TopLevel(id), &mut agents); - { + let page_flip_entry = { let sb = &mut agents.get_mut(&id).unwrap().scrollback; sb.prepare_layout(40, 6); let last = sb.len().saturating_sub(1); + let entry_id = sb.entry(last).unwrap().id; sb.set_selected(Some(last)); sb.scroll_to_entry_top(last); sb.enable_follow_with_preserve(); - } + entry_id + }; assert!(agents[&id].scrollback.is_follow_preserve_scroll()); - dash.note_page_flip_for_lease(id, &mut agents); + dash.note_page_flip_for_lease(id, page_flip_entry, &agents); assert_eq!( dash.peek_viewport.as_ref().and_then(|l| l.page_flip_entry), - Some(agents[&id].scrollback.len().saturating_sub(1)) + Some(page_flip_entry) ); dash.restore_peek_viewport(&mut agents); @@ -10513,11 +10681,17 @@ mod tests { } #[test] - fn note_page_flip_from_scroll_only_when_row_matches() { + fn note_page_flip_only_when_row_and_entry_match() { let (id, mut agents) = lease_fixture_agent(); let mut dash = DashboardState::new(); dash.begin_peek_viewport(DashboardRowId::TopLevel(id), &mut agents); - dash.note_page_flip_from_scroll(AgentId(99), Some(3), Some(1)); + let entry_id = agents[&id].scrollback.entry(3).unwrap().id; + agents + .get_mut(&id) + .unwrap() + .scrollback + .enable_follow_with_preserve(); + dash.note_page_flip_for_lease(AgentId(99), entry_id, &agents); assert!( dash.peek_viewport .as_ref() @@ -10525,11 +10699,45 @@ mod tests { .page_flip_entry .is_none() ); - dash.note_page_flip_from_scroll(id, Some(3), Some(1)); + dash.note_page_flip_for_lease(id, crate::scrollback::EntryId::new(u64::MAX), &agents); + assert!( + dash.peek_viewport + .as_ref() + .unwrap() + .page_flip_entry + .is_none() + ); + dash.note_page_flip_for_lease(id, entry_id, &agents); let lease = dash.peek_viewport.as_ref().unwrap(); - assert_eq!(lease.page_flip_entry, Some(3)); - assert!(lease.snapshot.follow_preserve_scroll); - assert_eq!(lease.snapshot.selected, Some(3)); + assert_eq!(lease.page_flip_entry, Some(entry_id)); + assert!(!lease.snapshot.follow_preserve_scroll); + assert_eq!(lease.snapshot.selected, Some(0)); + } + + #[test] + fn restore_ignores_page_flip_entry_removed_during_lease() { + let (id, mut agents) = lease_fixture_agent(); + let pre = agents[&id].scrollback.capture_viewport_snapshot(); + let mut dash = DashboardState::new(); + dash.begin_peek_viewport(DashboardRowId::TopLevel(id), &mut agents); + let entry_id = agents[&id].scrollback.entry(2).unwrap().id; + agents + .get_mut(&id) + .unwrap() + .scrollback + .enable_follow_with_preserve(); + dash.note_page_flip_for_lease(id, entry_id, &agents); + agents + .get_mut(&id) + .unwrap() + .scrollback + .remove_entry(entry_id); + + dash.restore_peek_viewport(&mut agents); + + assert!(dash.peek_viewport.is_none()); + assert_eq!(agents[&id].scrollback.selected(), pre.selected); + assert_eq!(agents[&id].scrollback.is_follow_mode(), pre.follow_mode); } #[test] @@ -10549,21 +10757,22 @@ mod tests { }, &mut agents, ); - dash.note_page_flip_from_scroll(id, Some(3), Some(1)); + let entry_id = agents[&id].scrollback.entry(3).unwrap().id; + dash.note_page_flip_for_lease(id, entry_id, &agents); assert!( dash.peek_viewport .as_ref() .unwrap() .page_flip_entry .is_none(), - "parent drain must not write parent indices onto a subagent lease" + "parent drain must not write parent entries onto a subagent lease" ); agents .get_mut(&id) .unwrap() .scrollback .enable_follow_with_preserve(); - dash.note_page_flip_for_lease(id, &mut agents); + dash.note_page_flip_for_lease(id, entry_id, &agents); assert!( dash.peek_viewport .as_ref() diff --git a/crates/codegen/xai-grok-pager/src/views/extensions_modal.rs b/crates/codegen/xai-grok-pager/src/views/extensions_modal.rs index 9fbbce3..d0aa743 100644 --- a/crates/codegen/xai-grok-pager/src/views/extensions_modal.rs +++ b/crates/codegen/xai-grok-pager/src/views/extensions_modal.rs @@ -4,12 +4,13 @@ //! chrome, opened by the `/hooks` and `/plugins` slash commands. //! Blocks all input until closed with `Esc`. -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use crossterm::event::{KeyCode, KeyEvent}; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::{Modifier, Style}; use unicode_width::UnicodeWidthStr; +use crate::input::line_editor::{LineEditOutcome, LineEditor}; use crate::theme::Theme; use crate::views::modal_window::{ self, ModalContentArea, ModalSizing, ModalWindowConfig, ModalWindowState, Shortcut, @@ -673,10 +674,10 @@ pub struct ModalInput { /// Command prefix used to build the typed action on submit. pub command_prefix: String, /// Input fields. - pub fields: Vec, + fields: Vec, /// Index of the currently focused field. - pub focused: usize, - /// Inline error message shown below the form. Cleared on next keystroke. + focused: usize, + /// Inline error cleared by text edits, completion, or field navigation. pub error: Option, } @@ -684,37 +685,63 @@ pub struct ModalInput { #[derive(Debug, Clone)] pub struct ModalInputField { /// Human-readable label shown before the input field. - pub label: String, - /// Current text in the input field. - pub text: String, - /// Cursor position (byte offset into `text`). - pub cursor: usize, + label: String, + editor: LineEditor, /// Whether the field must be non-empty to submit. - pub required: bool, + required: bool, /// Placeholder text shown when the field is empty. - pub placeholder: Option, + placeholder: Option, } impl ModalInputField { - /// Sanitize pasted text and insert at cursor. Strips `\n` and `\r`. - /// Returns `true` if text was inserted. - pub fn insert_paste(&mut self, text: &str) -> bool { - let cleaned: String = text.chars().filter(|c| *c != '\n' && *c != '\r').collect(); - if cleaned.is_empty() { - return false; + fn new(spec: FieldSpec) -> Self { + Self { + label: spec.label, + editor: LineEditor::default(), + required: spec.required, + placeholder: spec.placeholder, } - self.text.insert_str(self.cursor, &cleaned); - self.cursor += cleaned.len(); - true } - /// Delete the word before the cursor (readline backward-kill-word). - pub fn delete_word_backward(&mut self) { - if self.cursor > 0 { - let boundary = prev_word_boundary(&self.text, self.cursor); - self.text.drain(boundary..self.cursor); - self.cursor = boundary; - } + pub fn label(&self) -> &str { + &self.label + } + + pub fn text(&self) -> &str { + self.editor.text() + } + + pub fn cursor_byte(&self) -> usize { + self.editor.cursor_byte() + } + + pub fn required(&self) -> bool { + self.required + } + + pub fn placeholder(&self) -> Option<&str> { + self.placeholder.as_deref() + } + + pub(crate) fn set_text(&mut self, text: impl Into) { + self.editor.set_text(text); + } + + #[cfg(test)] + fn set_cursor_byte(&mut self, cursor_byte: usize) -> LineEditOutcome { + self.editor.set_cursor_byte(cursor_byte) + } + + fn insert_paste(&mut self, text: &str) -> LineEditOutcome { + self.editor.insert_paste(text) + } + + fn handle_key(&mut self, key: &KeyEvent) -> LineEditOutcome { + self.editor.handle_key(key) + } + + pub(crate) fn viewport(&self, width: usize) -> xai_ratatui_textarea::SingleLineViewport { + self.editor.viewport(width) } } @@ -722,16 +749,7 @@ impl ModalInput { /// Build from a command prefix and field specs. pub fn from_specs(command_prefix: String, specs: Vec) -> Self { debug_assert!(!specs.is_empty(), "ModalInput needs at least one field"); - let fields = specs - .into_iter() - .map(|s| ModalInputField { - label: s.label, - text: String::new(), - cursor: 0, - required: s.required, - placeholder: s.placeholder, - }) - .collect(); + let fields = specs.into_iter().map(ModalInputField::new).collect(); Self { command_prefix, fields, @@ -740,8 +758,24 @@ impl ModalInput { } } - /// The currently focused field (mutable). - pub fn focused_field_mut(&mut self) -> Option<&mut ModalInputField> { + pub fn fields(&self) -> &[ModalInputField] { + &self.fields + } + + pub fn field(&self, index: usize) -> Option<&ModalInputField> { + self.fields.get(index) + } + + #[cfg(test)] + fn field_mut(&mut self, index: usize) -> Option<&mut ModalInputField> { + self.fields.get_mut(index) + } + + pub fn focused_index(&self) -> usize { + self.focused + } + + fn focused_field_mut(&mut self) -> Option<&mut ModalInputField> { self.fields.get_mut(self.focused) } @@ -752,18 +786,16 @@ impl ModalInput { /// Collect all field texts into a Vec for submission. pub fn field_texts(&self) -> Vec { - self.fields.iter().map(|f| f.text.clone()).collect() + self.fields + .iter() + .map(|field| field.text().to_owned()) + .collect() } /// Process a key event on the input form. Returns what the caller /// should do (submit, cancel, nothing, etc.) without coupling to /// `AgentView` or `InputOutcome`. pub fn handle_key(&mut self, key: &KeyEvent) -> ModalInputOutcome { - // Clear inline error on any keystroke except Esc. - if key.code != KeyCode::Esc { - self.error = None; - } - match key { KeyEvent { code: KeyCode::Esc, .. @@ -779,9 +811,9 @@ impl ModalInput { .iter() .enumerate() .filter(|(i, f)| { - f.required && field_texts.get(*i).is_none_or(|t| t.trim().is_empty()) + f.required() && field_texts.get(*i).is_none_or(|t| t.trim().is_empty()) }) - .map(|(_, f)| f.label.as_str()) + .map(|(_, f)| f.label()) .collect(); if !empty_required.is_empty() { self.error = Some(format!("Required: {}", empty_required.join(", "))); @@ -793,19 +825,11 @@ impl ModalInput { } } - // Field navigation (multi-field forms). - KeyEvent { - code: KeyCode::Tab, - modifiers, - .. - } if !modifiers.contains(KeyModifiers::SHIFT) && self.is_multi_field() => { - self.focused = (self.focused + 1) % self.fields.len(); - ModalInputOutcome::Changed - } - KeyEvent { - code: KeyCode::BackTab, - .. - } if self.is_multi_field() => { + _ if crate::input::key::is_shift_tab(key) => { + if !self.is_multi_field() { + return ModalInputOutcome::Unchanged; + } + self.error = None; self.focused = if self.focused == 0 { self.fields.len() - 1 } else { @@ -813,283 +837,74 @@ impl ModalInput { }; ModalInputOutcome::Changed } - // Tab in single-field forms: path completion. - KeyEvent { - code: KeyCode::Tab, .. - } => { - if let Some(field) = self.focused_field_mut() { - let partial = field.text[..field.cursor].to_string(); - if let Some(completed) = tab_complete_path(&partial) { - let len = completed.len(); - field.text = completed; - field.cursor = len; - return ModalInputOutcome::Changed; + _ if crate::input::key::KeyShortcut::key(KeyCode::Tab).matches(key) => { + if self.is_multi_field() { + self.error = None; + self.focused = (self.focused + 1) % self.fields.len(); + return ModalInputOutcome::Changed; + } + let completed = self.focused_field_mut().and_then(|field| { + let partial = field.text()[..field.cursor_byte()].to_owned(); + tab_complete_path(&partial) + }); + if let Some(completed) = completed { + if let Some(field) = self.focused_field_mut() { + field.set_text(completed); } - } - ModalInputOutcome::Unchanged - } - - // Single-char backspace. - KeyEvent { - code: KeyCode::Backspace, - modifiers: KeyModifiers::NONE, - .. - } => { - if let Some(field) = self.focused_field_mut() - && field.cursor > 0 - { - let prev = field.text[..field.cursor] - .char_indices() - .next_back() - .map(|(i, _)| i) - .unwrap_or(0); - field.text.remove(prev); - field.cursor = prev; + self.error = None; return ModalInputOutcome::Changed; } ModalInputOutcome::Unchanged } - // Forward-delete: Delete key, Ctrl+D. - KeyEvent { - code: KeyCode::Delete, - .. - } - | KeyEvent { - code: KeyCode::Char('d'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - if let Some(field) = self.focused_field_mut() - && field.cursor < field.text.len() - { - field.text.remove(field.cursor); - return ModalInputOutcome::Changed; - } - ModalInputOutcome::Unchanged - } - - // Word-delete backward: Alt+Backspace, Ctrl+Backspace, Ctrl+W. - KeyEvent { - code: KeyCode::Backspace, - modifiers: KeyModifiers::ALT, - .. - } - | KeyEvent { - code: KeyCode::Backspace, - modifiers: KeyModifiers::CONTROL, - .. - } - | KeyEvent { - code: KeyCode::Char('w'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - if let Some(field) = self.focused_field_mut() { - field.delete_word_backward(); - } - ModalInputOutcome::Changed - } - // Word-delete backward: Ctrl+Alt+H. - KeyEvent { - code: KeyCode::Char('h'), - modifiers, - .. - } if *modifiers == (KeyModifiers::CONTROL | KeyModifiers::ALT) => { - if let Some(field) = self.focused_field_mut() { - field.delete_word_backward(); - } - ModalInputOutcome::Changed - } - - // Delete to start of line: Cmd+Backspace (Super), Ctrl+U. - KeyEvent { - code: KeyCode::Backspace, - modifiers: KeyModifiers::SUPER, - .. - } - | KeyEvent { - code: KeyCode::Char('u'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - if let Some(field) = self.focused_field_mut() - && field.cursor > 0 - { - field.text.drain(..field.cursor); - field.cursor = 0; - } - ModalInputOutcome::Changed - } - - // Delete to end of line: Ctrl+K. - KeyEvent { - code: KeyCode::Char('k'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - if let Some(field) = self.focused_field_mut() - && field.cursor < field.text.len() - { - field.text.truncate(field.cursor); - } - ModalInputOutcome::Changed - } - - // Word movement: Alt+Left/Right, Ctrl+Left/Right. - KeyEvent { - code: KeyCode::Left, - modifiers, - .. - } if modifiers.contains(KeyModifiers::ALT) - || modifiers.contains(KeyModifiers::CONTROL) => - { - if let Some(field) = self.focused_field_mut() { - field.cursor = prev_word_boundary(&field.text, field.cursor); - } - ModalInputOutcome::Changed - } - KeyEvent { - code: KeyCode::Right, - modifiers, - .. - } if modifiers.contains(KeyModifiers::ALT) - || modifiers.contains(KeyModifiers::CONTROL) => - { - if let Some(field) = self.focused_field_mut() { - field.cursor = next_word_boundary(&field.text, field.cursor); - } - ModalInputOutcome::Changed - } - - // Alt+B / Alt+F (readline word movement). - KeyEvent { - code: KeyCode::Char('b'), - modifiers: KeyModifiers::ALT, - .. - } => { - if let Some(field) = self.focused_field_mut() { - field.cursor = prev_word_boundary(&field.text, field.cursor); - } - ModalInputOutcome::Changed - } - KeyEvent { - code: KeyCode::Char('f'), - modifiers: KeyModifiers::ALT, - .. - } => { - if let Some(field) = self.focused_field_mut() { - field.cursor = next_word_boundary(&field.text, field.cursor); - } - ModalInputOutcome::Changed - } - - // Line start/end: Cmd+Left/Right (Super), Home/End, Ctrl+A/E. - KeyEvent { - code: KeyCode::Left, - modifiers: KeyModifiers::SUPER, - .. - } - | KeyEvent { - code: KeyCode::Home, - .. - } - | KeyEvent { - code: KeyCode::Char('a'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - if let Some(field) = self.focused_field_mut() { - field.cursor = 0; - } - ModalInputOutcome::Changed - } - KeyEvent { - code: KeyCode::Right, - modifiers: KeyModifiers::SUPER, - .. - } - | KeyEvent { - code: KeyCode::End, .. - } - | KeyEvent { - code: KeyCode::Char('e'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - if let Some(field) = self.focused_field_mut() { - field.cursor = field.text.len(); - } - ModalInputOutcome::Changed - } - - // Single-char cursor movement. - KeyEvent { - code: KeyCode::Left, - .. - } => { - if let Some(field) = self.focused_field_mut() - && field.cursor > 0 - { - let prev = field.text[..field.cursor] - .char_indices() - .next_back() - .map(|(i, _)| i) - .unwrap_or(0); - field.cursor = prev; - } - ModalInputOutcome::Changed - } - KeyEvent { - code: KeyCode::Right, - .. - } => { - if let Some(field) = self.focused_field_mut() - && field.cursor < field.text.len() - { - let next = field.text[field.cursor..] - .char_indices() - .nth(1) - .map(|(i, _)| field.cursor + i) - .unwrap_or(field.text.len()); - field.cursor = next; - } - ModalInputOutcome::Changed - } - - // Clipboard paste (Ctrl+V fallback). - KeyEvent { - code: KeyCode::Char('v'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - if let Some(field) = self.focused_field_mut() - && let Some(clip) = crate::clipboard::system_clipboard_get() - && field.insert_paste(&clip) - { + _ if crate::input::key::is_paste_key(key) => { + let Some(clip) = crate::clipboard::system_clipboard_get() else { + return ModalInputOutcome::Unchanged; + }; + let outcome = self + .focused_field_mut() + .map_or(LineEditOutcome::Unhandled, |field| { + field.insert_paste(&clip) + }); + if outcome == LineEditOutcome::TextChanged { + self.error = None; ModalInputOutcome::Changed } else { ModalInputOutcome::Unchanged } } - // Plain character insertion (no CONTROL/ALT/SUPER modifier). - KeyEvent { - code: KeyCode::Char(c), - modifiers, - .. - } if !modifiers - .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER) - || crate::input::key::is_altgr(*modifiers) => - { - if let Some(field) = self.focused_field_mut() { - field.text.insert(field.cursor, *c); - field.cursor += c.len_utf8(); - } + _ => { + let outcome = self + .focused_field_mut() + .map_or(LineEditOutcome::Unhandled, |field| field.handle_key(key)); + self.finish_line_edit(outcome) + } + } + } + + fn finish_line_edit(&mut self, outcome: LineEditOutcome) -> ModalInputOutcome { + match outcome { + LineEditOutcome::TextChanged => { + self.error = None; ModalInputOutcome::Changed } + LineEditOutcome::HandledNoChange | LineEditOutcome::CursorChanged => { + ModalInputOutcome::Changed + } + LineEditOutcome::Unhandled => ModalInputOutcome::Unchanged, + } + } - _ => ModalInputOutcome::Unchanged, + fn insert_paste(&mut self, text: &str) -> bool { + let outcome = self + .focused_field_mut() + .map_or(LineEditOutcome::Unhandled, |field| field.insert_paste(text)); + if outcome == LineEditOutcome::TextChanged { + self.error = None; + true + } else { + false } } } @@ -1097,7 +912,7 @@ impl ModalInput { /// Result of processing a key event on the modal input form. #[derive(Debug)] pub enum ModalInputOutcome { - /// State was modified, redraw needed. + /// Event was consumed and a redraw is needed. Changed, /// No state change, skip redraw. Unchanged, @@ -1678,66 +1493,6 @@ fn longest_common_prefix(strings: &[String]) -> String { .map(|(a, _)| a) .collect() } -// --------------------------------------------------------------------------- -// Word boundary helpers (for readline-style editing in modal input fields) -// --------------------------------------------------------------------------- - -/// Byte offset of the start of the previous word. -/// -/// Skips whitespace backward from `cursor`, then skips non-whitespace backward. -/// Returns 0 if already at the beginning. -pub fn prev_word_boundary(text: &str, cursor: usize) -> usize { - let before = &text[..cursor]; - let mut iter = before.char_indices().rev(); - // Phase 1: skip whitespace. - let mut pos = cursor; - for (i, c) in iter.by_ref() { - if !c.is_whitespace() { - pos = i; - break; - } - pos = i; - } - if pos == cursor && cursor > 0 { - // Entire prefix was whitespace (or empty). - return 0; - } - // Phase 2: skip non-whitespace. - for (i, c) in iter { - if c.is_whitespace() { - return i + c.len_utf8(); - } - } - 0 -} - -/// Byte offset of the end of the next word. -/// -/// Skips whitespace forward from `cursor`, then skips non-whitespace forward. -/// Returns `text.len()` if already at the end. -pub fn next_word_boundary(text: &str, cursor: usize) -> usize { - let after = &text[cursor..]; - let mut iter = after.char_indices(); - // Phase 1: skip whitespace. - let mut offset = after.len(); - for (i, c) in iter.by_ref() { - if !c.is_whitespace() { - offset = i; - break; - } - } - if offset == after.len() { - return text.len(); - } - // Phase 2: skip non-whitespace. - for (i, c) in iter { - if c.is_whitespace() { - return cursor + i; - } - } - text.len() -} - /// Collect characters from `text` until the accumulated display width /// reaches `max_w`. Prevents wide characters (CJK, emoji) from /// overflowing a fixed-width column. @@ -2087,7 +1842,7 @@ impl ExtensionsModalState { /// (the Add form, an error/confirmation overlay, an in-flight /// `[processing]` badge, the picker selection / scroll / expansion /// state) is cleared so the new tab opens in a clean browse view. - /// The user's search query (`picker_state.query`) is intentionally + /// The user's search query (`picker_state.query()`) is intentionally /// preserved across tabs — current behavior elsewhere in the modal. pub fn switch_tab(&mut self, tab: ExtensionsTab) { self.active_tab = tab; @@ -2130,7 +1885,7 @@ impl ExtensionsModalState { /// `marketplace_collapsed_sources` (or `picker_state.expanded` for /// error-source headers), and other tabs use `picker_state.expanded`. pub fn is_group_expanded(&self, sel: usize, group_key: &str) -> bool { - let searching = !self.picker_state.query.is_empty(); + let searching = !self.picker_state.query().is_empty(); match self.active_tab { // During active search we force all hook groups open so matches @@ -2182,20 +1937,12 @@ impl ExtensionsModalState { /// Strips `\n` and `\r`. Returns `true` if any state was modified. pub fn apply_paste(&mut self, text: &str) -> bool { if let Some(ref mut input) = self.input { - let Some(field) = input.fields.get_mut(input.focused) else { - return false; - }; - field.insert_paste(text) + input.insert_paste(text) } else if self.picker_state.search_active { - let cleaned: String = text.chars().filter(|c| *c != '\n' && *c != '\r').collect(); - if cleaned.is_empty() { - return false; - } - self.picker_state - .query - .insert_str(self.picker_state.query_cursor, &cleaned); - self.picker_state.query_cursor += cleaned.len(); - true + matches!( + self.picker_state.paste_query(text), + crate::input::line_editor::LineEditOutcome::TextChanged + ) } else { false } @@ -2713,7 +2460,7 @@ pub fn render_extensions_modal( // force-expand all items of that tab. This ensures search filtering shows // every match explicitly, even inside groups that were collapsed before // the user switched tabs. - if !state.picker_state.query.is_empty() { + if !state.picker_state.query().is_empty() { state.picker_state.expand_all_for_search(8192); } @@ -2779,7 +2526,7 @@ pub fn render_extensions_modal( ExtensionsTab::Skills => { if let TabDataState::Loaded(ref skills) = state.skills_data { let filtered = - filter_and_sort_skills(skills, &state.picker_state.query, filter); + filter_and_sort_skills(skills, state.picker_state.query(), filter); for &(si, _) in &filtered.matches { let skill = &skills[si]; let source = skill_source_str(skill); @@ -2846,7 +2593,7 @@ pub fn render_extensions_modal( // Group plugins by source. let mut groups = GroupedPlugins::new(); for (pi, plugin) in response.plugins.iter().enumerate() { - if !fuzzy_matches(&plugin.name, &state.picker_state.query) { + if !fuzzy_matches(&plugin.name, state.picker_state.query()) { continue; } if !filter.matches(plugin.enabled) { @@ -2861,7 +2608,7 @@ pub fn render_extensions_modal( for ((_, label, group_key), plugins) in &groups { // While searching we ignore previous collapse state so // every plugin inside the group can be seen and matched. - let searching = !state.picker_state.query.is_empty(); + let searching = !state.picker_state.query().is_empty(); let collapsed = !searching && state.plugins_collapsed_groups.contains(group_key); entry_labels.push(format!( @@ -2948,7 +2695,7 @@ pub fn render_extensions_modal( Vec<(usize, &xai_hooks_plugins_types::HookInfo)>, > = std::collections::BTreeMap::new(); for (i, hook) in data.hooks.iter().enumerate() { - if !fuzzy_matches_hook(hook, &state.picker_state.query) { + if !fuzzy_matches_hook(hook, state.picker_state.query()) { continue; } if !state.hooks_filter.matches(!hook.disabled) { @@ -2963,7 +2710,7 @@ pub fn render_extensions_modal( let (label, _is_custom) = derive_source_label(source_dir); // While searching we ignore previous collapse state so // every hook inside the group can be seen and matched. - let searching = !state.picker_state.query.is_empty(); + let searching = !state.picker_state.query().is_empty(); let collapsed = !searching && state.hooks_collapsed_groups.contains(source_dir); entry_labels.push(format!("{} ({} hooks)", label, hooks.len())); @@ -3034,7 +2781,7 @@ pub fn render_extensions_modal( for (si, source) in data.sources.iter().enumerate() { // Force all marketplace sources open while searching so their // plugins are considered for matching and displayed. - let searching = !state.picker_state.query.is_empty(); + let searching = !state.picker_state.query().is_empty(); let collapsed = !searching && state.marketplace_collapsed_sources.contains(&si); entry_labels.push(format!( @@ -3066,7 +2813,7 @@ pub fn render_extensions_modal( continue; } for plugin in &source.plugins { - if !fuzzy_matches(&plugin.name, &state.picker_state.query) { + if !fuzzy_matches(&plugin.name, state.picker_state.query()) { continue; } let status_label = match plugin.install_status.as_str() { @@ -3174,14 +2921,14 @@ pub fn render_extensions_modal( servers, ); - let searching = !state.picker_state.query.is_empty(); + let searching = !state.picker_state.query().is_empty(); let mut sections: std::collections::BTreeMap< McpSectionId, Vec<(usize, &crate::views::mcps_modal::McpServerInfo)>, > = std::collections::BTreeMap::new(); for (si, server) in servers.iter().enumerate() { let display_name = server.display_name.as_deref().unwrap_or(&server.name); - if !fuzzy_matches(display_name, &state.picker_state.query) { + if !fuzzy_matches(display_name, state.picker_state.query()) { continue; } if !state.mcps_filter.matches(server.enabled) { @@ -3568,16 +3315,15 @@ pub fn render_extensions_modal( if !in_input_mode { // Search bar at top of content area. let search_active_render = state.picker_state.search_active; - picker::render_search_bar( + picker::render_picker_search_bar( buf, content_area.x, content_area.y, search_width, &theme, - &state.picker_state.query, + &state.picker_state, search_active_render, true, // show_search_hint - state.picker_state.query_cursor, Some(theme.bg_base), ); } @@ -4036,7 +3782,7 @@ fn render_input_form(buf: &mut Buffer, area: Rect, input: &ModalInput, theme: &T // Per field: 1 label row + 3 rows for the bordered input // (top border + content + bottom border). - let field_count = input.fields.len() as u16; + let field_count = input.fields().len() as u16; const ROWS_PER_FIELD: u16 = 4; let separators = field_count.saturating_sub(1); // 1 blank row between fields let form_rows = field_count * ROWS_PER_FIELD + separators; @@ -4071,12 +3817,12 @@ fn render_input_form(buf: &mut Buffer, area: Rect, input: &ModalInput, theme: &T let prompt_w = prompt_prefix.width() as u16; let mut cur_y = form_top; - for (fi, field) in input.fields.iter().enumerate() { + for (fi, field) in input.fields().iter().enumerate() { if cur_y >= area.y + area.height { break; } - let is_focused = fi == input.focused; + let is_focused = fi == input.focused_index(); // Row 1: Label (sits above the bordered input, not inside). let ls = if is_focused { @@ -4084,7 +3830,7 @@ fn render_input_form(buf: &mut Buffer, area: Rect, input: &ModalInput, theme: &T } else { label_dim_style }; - buf.set_string(label_x, cur_y, &field.label, ls); + buf.set_string(label_x, cur_y, field.label(), ls); cur_y += 1; // Rows 2-4: Rounded border around the single-line input. @@ -4118,11 +3864,11 @@ fn render_input_form(buf: &mut Buffer, area: Rect, input: &ModalInput, theme: &T buf.set_string(content_x, content_y, prompt_prefix, prompt_style); let text_x = content_x + prompt_w; - if field.text.is_empty() { + if field.text().is_empty() { // Placeholder only renders when the field is NOT focused — // matches the prompt widget convention so the cursor isn't // overlapping placeholder text on the active row. - if !is_focused && let Some(ref ph) = field.placeholder { + if !is_focused && let Some(ph) = field.placeholder() { let display: String = take_by_width(ph, max_text_w); buf.set_string(text_x, content_y, &display, placeholder_style); } @@ -4130,31 +3876,12 @@ fn render_input_form(buf: &mut Buffer, area: Rect, input: &ModalInput, theme: &T cell.set_style(Style::default().fg(theme.bg_base).bg(theme.text_primary)); } } else { - // Compute scroll offset once, tracking actual display widths - // so wide characters (CJK, emoji) don't misalign the cursor. - let cursor_col = field.text[..field.cursor].width(); - let scroll = cursor_col.saturating_sub(max_text_w.saturating_sub(1)); - - // Skip `scroll` display-width columns, tracking the actual - // width skipped (may differ from `scroll` when a wide char - // straddles the boundary). - let mut skipped_w = 0; - let visible: String = field - .text - .chars() - .skip_while(|c| { - if skipped_w >= scroll { - return false; - } - skipped_w += unicode_width::UnicodeWidthChar::width(*c).unwrap_or(0); - true - }) - .collect(); - let visible = take_by_width(&visible, max_text_w); - buf.set_string(text_x, content_y, &visible, text_style); + let viewport = field.viewport(max_text_w); + let visible = &field.text()[viewport.visible_byte_range]; + buf.set_string(text_x, content_y, visible, text_style); if is_focused { - let cx = text_x + (cursor_col.saturating_sub(skipped_w)) as u16; + let cx = text_x + viewport.cursor_display_column as u16; if cx < inner.x + inner.width && let Some(cell) = buf.cell_mut((cx, content_y)) { @@ -4166,7 +3893,7 @@ fn render_input_form(buf: &mut Buffer, area: Rect, input: &ModalInput, theme: &T cur_y += 3; // top border + content + bottom border // Blank separator between fields (skip after last field). - if fi + 1 < input.fields.len() { + if fi + 1 < input.fields().len() { cur_y += 1; } } @@ -4185,6 +3912,7 @@ fn render_input_form(buf: &mut Buffer, area: Rect, input: &ModalInput, theme: &T #[cfg(test)] mod tests { use super::*; + use crossterm::event::KeyModifiers; #[test] fn derive_source_label_detects_project_scoped_plugins() { @@ -4439,7 +4167,7 @@ mod tests { state .mcps_collapsed_sections .insert("mcp-section:managed".to_string()); - state.picker_state.query = "linear".into(); + state.picker_state.set_query("linear"); assert!(state.is_group_expanded(0, "mcp-section:managed")); } @@ -5242,34 +4970,37 @@ mod tests { #[test] fn apply_paste_inserts_url_into_focused_field_and_strips_newline() { let mut state = ExtensionsModalState::new(ExtensionsTab::McpServers); - let mut input = single_field_input("test"); - input.focused = 0; - state.input = Some(input); + state.input = Some(single_field_input("test")); assert!(state.apply_paste("https://mcp.linear.app/mcp\n")); - let field = &state.input.as_ref().unwrap().fields[0]; - assert_eq!(field.text, "https://mcp.linear.app/mcp"); - assert_eq!(field.cursor, "https://mcp.linear.app/mcp".len()); + let field = state.input.as_ref().unwrap().field(0).unwrap(); + assert_eq!(field.text(), "https://mcp.linear.app/mcp"); + assert_eq!(field.cursor_byte(), "https://mcp.linear.app/mcp".len()); } #[test] fn apply_paste_inserts_at_cursor_position() { let mut state = ExtensionsModalState::new(ExtensionsTab::McpServers); let mut input = single_field_input("test"); - input.fields[0].text = "AB".into(); - input.fields[0].cursor = 1; + input.field_mut(0).unwrap().set_text("AB"); + let _ = input.field_mut(0).unwrap().set_cursor_byte(1); state.input = Some(input); assert!(state.apply_paste("XY")); - let field = &state.input.as_ref().unwrap().fields[0]; - assert_eq!(field.text, "AXYB"); - assert_eq!(field.cursor, 3); + let field = state.input.as_ref().unwrap().field(0).unwrap(); + assert_eq!(field.text(), "AXYB"); + assert_eq!(field.cursor_byte(), 3); } #[test] fn apply_paste_strips_crlf() { let mut state = ExtensionsModalState::new(ExtensionsTab::McpServers); state.input = Some(single_field_input("test")); + state.input.as_mut().unwrap().error = Some("Required: URL".to_owned()); assert!(state.apply_paste("foo\r\nbar")); - assert_eq!(state.input.as_ref().unwrap().fields[0].text, "foobar"); + assert_eq!( + state.input.as_ref().unwrap().field(0).unwrap().text(), + "foobar" + ); + assert!(state.input.as_ref().unwrap().error.is_none()); } #[test] @@ -5277,8 +5008,9 @@ mod tests { let mut state = ExtensionsModalState::new(ExtensionsTab::McpServers); state.input = Some(single_field_input("test")); assert!(!state.apply_paste("\n\r")); - assert_eq!(state.input.as_ref().unwrap().fields[0].text, ""); - assert_eq!(state.input.as_ref().unwrap().fields[0].cursor, 0); + let field = state.input.as_ref().unwrap().field(0).unwrap(); + assert_eq!(field.text(), ""); + assert_eq!(field.cursor_byte(), 0); } #[test] @@ -5286,14 +5018,14 @@ mod tests { let mut state = ExtensionsModalState::new(ExtensionsTab::Plugins); state.picker_state.search_active = true; assert!(state.apply_paste("query")); - assert_eq!(state.picker_state.query, "query"); + assert_eq!(state.picker_state.query(), "query"); } #[test] fn apply_paste_ignored_when_idle() { let mut state = ExtensionsModalState::new(ExtensionsTab::Plugins); assert!(!state.apply_paste("hello")); - assert_eq!(state.picker_state.query, ""); + assert_eq!(state.picker_state.query(), ""); assert!(state.input.is_none()); } @@ -5303,28 +5035,30 @@ mod tests { state.input = Some(single_field_input("test")); state.picker_state.search_active = true; assert!(state.apply_paste("url")); - assert_eq!(state.input.as_ref().unwrap().fields[0].text, "url"); - assert_eq!(state.picker_state.query, ""); + assert_eq!( + state.input.as_ref().unwrap().field(0).unwrap().text(), + "url" + ); + assert_eq!(state.picker_state.query(), ""); } #[test] fn apply_paste_targets_focused_field_in_multi_field() { let mut state = ExtensionsModalState::new(ExtensionsTab::McpServers); let mut input = mcp_add_input(); - input.focused = 0; // URL field (first in the new order) + let _ = input.handle_key(&key_event(KeyCode::Tab, KeyModifiers::NONE)); state.input = Some(input); - assert!(state.apply_paste("https://example.com")); - let fields = &state.input.as_ref().unwrap().fields; - assert_eq!(fields[0].text, "https://example.com"); - assert_eq!(fields[1].text, ""); + assert!(state.apply_paste("my-server")); + let input = state.input.as_ref().unwrap(); + assert_eq!(input.field(0).unwrap().text(), ""); + assert_eq!(input.field(1).unwrap().text(), "my-server"); } #[test] fn multi_field_form_field_texts() { let mut input = mcp_add_input(); - // Field order: [URL, Name]. - input.fields[0].text = "https://example.com".into(); - input.fields[1].text = "my-server".into(); + input.field_mut(0).unwrap().set_text("https://example.com"); + input.field_mut(1).unwrap().set_text("my-server"); let texts = input.field_texts(); assert_eq!(texts, vec!["https://example.com", "my-server"]); } @@ -5332,108 +5066,12 @@ mod tests { #[test] fn from_specs_creates_empty_fields() { let input = mcp_add_input(); - assert_eq!(input.fields.len(), 2); - assert!(input.fields[0].text.is_empty()); - assert!(input.fields[1].text.is_empty()); - // New order: [URL (required), Name (optional)]. - assert!(input.fields[0].required); - assert!(!input.fields[1].required); - assert_eq!(input.focused, 0); - } - - // ── Word boundary helpers ─────────────────────────────────────── - - #[test] - fn prev_word_boundary_basic() { - assert_eq!(prev_word_boundary("hello world", 11), 6); - assert_eq!(prev_word_boundary("hello world", 6), 0); - assert_eq!(prev_word_boundary("hello world", 5), 0); - assert_eq!(prev_word_boundary("hello world", 0), 0); - } - - #[test] - fn prev_word_boundary_multiple_spaces() { - assert_eq!(prev_word_boundary("a b c", 7), 6); - assert_eq!(prev_word_boundary("a b c", 6), 3); - assert_eq!(prev_word_boundary("a b c", 3), 0); - } - - #[test] - fn prev_word_boundary_url() { - let url = "https://mcp.linear.app/mcp"; - assert_eq!(prev_word_boundary(url, url.len()), 0); - } - - #[test] - fn next_word_boundary_basic() { - assert_eq!(next_word_boundary("hello world", 0), 5); - assert_eq!(next_word_boundary("hello world", 5), 11); - assert_eq!(next_word_boundary("hello world", 6), 11); - assert_eq!(next_word_boundary("hello world", 11), 11); - } - - #[test] - fn next_word_boundary_multiple_spaces() { - assert_eq!(next_word_boundary("a b c", 0), 1); - assert_eq!(next_word_boundary("a b c", 1), 4); - assert_eq!(next_word_boundary("a b c", 4), 7); - } - - #[test] - fn next_word_boundary_url() { - let url = "https://mcp.linear.app/mcp"; - assert_eq!(next_word_boundary(url, 0), url.len()); - } - - #[test] - fn prev_word_boundary_mid_word() { - assert_eq!(prev_word_boundary("hello world", 3), 0); - } - - #[test] - fn next_word_boundary_mid_word() { - assert_eq!(next_word_boundary("hello world", 3), 5); - } - - #[test] - fn prev_word_boundary_in_whitespace_run() { - assert_eq!(prev_word_boundary("a b", 3), 0); - } - - // ── delete_word_backward ──────────────────────────────────────── - - fn make_field(text: &str, cursor: usize) -> ModalInputField { - ModalInputField { - label: String::new(), - text: text.into(), - cursor, - required: false, - placeholder: None, - } - } - - #[test] - fn delete_word_backward_at_end() { - let mut f = make_field("hello world", 11); - f.delete_word_backward(); - assert_eq!(f.text, "hello "); - assert_eq!(f.cursor, 6); - } - - #[test] - fn delete_word_backward_mid_word() { - let mut f = make_field("hello world", 8); - f.delete_word_backward(); - assert_eq!(f.text, "hello rld"); - assert_eq!(f.cursor, 6); - } - - #[test] - fn delete_word_backward_at_start_is_noop() { - let mut f = make_field("hello", 0); - f.delete_word_backward(); - assert_eq!(f.text, "hello"); - assert_eq!(f.cursor, 0); + assert_eq!(input.fields().len(), 2); + assert!(input.field(0).unwrap().text().is_empty()); + assert!(input.field(1).unwrap().text().is_empty()); + assert!(input.field(0).unwrap().required()); + assert!(!input.field(1).unwrap().required()); + assert_eq!(input.focused_index(), 0); } // ── build_action_from_input / parse_mcp_add_fields ────────────── @@ -5524,7 +5162,7 @@ mod tests { #[test] fn handle_key_esc_cancels() { let mut input = single_field_input("test"); - input.fields[0].text = "some text".into(); + input.field_mut(0).unwrap().set_text("some text"); assert!(matches!( input.handle_key(&key_event(KeyCode::Esc, KeyModifiers::NONE)), ModalInputOutcome::Cancel @@ -5535,8 +5173,8 @@ mod tests { fn handle_key_char_inserts() { let mut input = single_field_input("test"); input.handle_key(&key_event(KeyCode::Char('a'), KeyModifiers::NONE)); - assert_eq!(input.fields[0].text, "a"); - assert_eq!(input.fields[0].cursor, 1); + assert_eq!(input.field(0).unwrap().text(), "a"); + assert_eq!(input.field(0).unwrap().cursor_byte(), 1); } #[test] @@ -5544,57 +5182,72 @@ mod tests { let mut input = single_field_input("test"); let result = input.handle_key(&key_event(KeyCode::Char('x'), KeyModifiers::CONTROL)); assert!(matches!(result, ModalInputOutcome::Unchanged)); - assert!(input.fields[0].text.is_empty()); + assert!(input.field(0).unwrap().text().is_empty()); } #[test] fn handle_key_backspace_deletes() { let mut input = single_field_input("test"); - input.fields[0].text = "ab".into(); - input.fields[0].cursor = 2; + input.field_mut(0).unwrap().set_text("ab"); input.handle_key(&key_event(KeyCode::Backspace, KeyModifiers::NONE)); - assert_eq!(input.fields[0].text, "a"); - assert_eq!(input.fields[0].cursor, 1); + assert_eq!(input.field(0).unwrap().text(), "a"); + assert_eq!(input.field(0).unwrap().cursor_byte(), 1); } #[test] fn handle_key_delete_forward() { let mut input = single_field_input("test"); - input.fields[0].text = "ab".into(); - input.fields[0].cursor = 0; + input.field_mut(0).unwrap().set_text("ab"); + let _ = input.field_mut(0).unwrap().set_cursor_byte(0); input.handle_key(&key_event(KeyCode::Delete, KeyModifiers::NONE)); - assert_eq!(input.fields[0].text, "b"); - assert_eq!(input.fields[0].cursor, 0); + assert_eq!(input.field(0).unwrap().text(), "b"); + assert_eq!(input.field(0).unwrap().cursor_byte(), 0); } #[test] fn handle_key_ctrl_u_kills_to_start() { let mut input = single_field_input("test"); - input.fields[0].text = "hello world".into(); - input.fields[0].cursor = 5; + input.field_mut(0).unwrap().set_text("hello world"); + let _ = input.field_mut(0).unwrap().set_cursor_byte(5); input.handle_key(&key_event(KeyCode::Char('u'), KeyModifiers::CONTROL)); - assert_eq!(input.fields[0].text, " world"); - assert_eq!(input.fields[0].cursor, 0); + assert_eq!(input.field(0).unwrap().text(), " world"); + assert_eq!(input.field(0).unwrap().cursor_byte(), 0); } #[test] fn handle_key_ctrl_k_kills_to_end() { let mut input = single_field_input("test"); - input.fields[0].text = "hello world".into(); - input.fields[0].cursor = 5; + input.field_mut(0).unwrap().set_text("hello world"); + let _ = input.field_mut(0).unwrap().set_cursor_byte(5); input.handle_key(&key_event(KeyCode::Char('k'), KeyModifiers::CONTROL)); - assert_eq!(input.fields[0].text, "hello"); - assert_eq!(input.fields[0].cursor, 5); + assert_eq!(input.field(0).unwrap().text(), "hello"); + assert_eq!(input.field(0).unwrap().cursor_byte(), 5); } #[test] fn handle_key_tab_navigates_multi_field() { let mut input = mcp_add_input(); - assert_eq!(input.focused, 0); + assert_eq!(input.focused_index(), 0); input.handle_key(&key_event(KeyCode::Tab, KeyModifiers::NONE)); - assert_eq!(input.focused, 1); - input.handle_key(&key_event(KeyCode::Tab, KeyModifiers::NONE)); - assert_eq!(input.focused, 0); + assert_eq!(input.focused_index(), 1); + input.handle_key(&key_event(KeyCode::Tab, KeyModifiers::SHIFT)); + assert_eq!(input.focused_index(), 0); + input.handle_key(&key_event(KeyCode::BackTab, KeyModifiers::NONE)); + assert_eq!(input.focused_index(), 1); + } + + #[test] + fn modified_tab_chords_do_not_navigate_multi_field() { + for modifiers in [ + KeyModifiers::CONTROL, + KeyModifiers::ALT, + KeyModifiers::SUPER, + ] { + let mut input = mcp_add_input(); + let outcome = input.handle_key(&key_event(KeyCode::Tab, modifiers)); + assert!(matches!(outcome, ModalInputOutcome::Unchanged)); + assert_eq!(input.focused_index(), 0); + } } #[test] @@ -5609,8 +5262,7 @@ mod tests { #[test] fn handle_key_submit_succeeds() { let mut input = mcp_add_input(); - // URL is the first (required) field in the new order. - input.fields[0].text = "https://example.com".into(); + input.field_mut(0).unwrap().set_text("https://example.com"); let result = input.handle_key(&key_event(KeyCode::Enter, KeyModifiers::NONE)); assert!(matches!(result, ModalInputOutcome::Submit { .. })); } @@ -5618,12 +5270,211 @@ mod tests { #[test] fn handle_key_home_end() { let mut input = single_field_input("test"); - input.fields[0].text = "hello".into(); - input.fields[0].cursor = 3; + input.field_mut(0).unwrap().set_text("hello"); + let _ = input.field_mut(0).unwrap().set_cursor_byte(3); input.handle_key(&key_event(KeyCode::Home, KeyModifiers::NONE)); - assert_eq!(input.fields[0].cursor, 0); + assert_eq!(input.field(0).unwrap().cursor_byte(), 0); input.handle_key(&key_event(KeyCode::End, KeyModifiers::NONE)); - assert_eq!(input.fields[0].cursor, 5); + assert_eq!(input.field(0).unwrap().cursor_byte(), 5); + } + + #[test] + fn handle_key_tab_completes_single_field_path() { + let directory = tempfile::tempdir().unwrap(); + let completed = directory.path().join("plugin-source"); + std::fs::write(&completed, "").unwrap(); + let partial = directory.path().join("plugin-s"); + + let mut input = single_field_input("test"); + input + .field_mut(0) + .unwrap() + .set_text(partial.to_string_lossy()); + let outcome = input.handle_key(&key_event(KeyCode::Tab, KeyModifiers::NONE)); + + assert!(matches!(outcome, ModalInputOutcome::Changed)); + let field = input.field(0).unwrap(); + assert_eq!(field.text(), completed.to_string_lossy().as_ref()); + assert_eq!(field.cursor_byte(), field.text().len()); + } + + #[test] + fn modified_tab_chords_do_not_complete_single_field_path() { + let directory = tempfile::tempdir().unwrap(); + let completed = directory.path().join("plugin-source"); + std::fs::write(&completed, "").unwrap(); + let partial = directory + .path() + .join("plugin-s") + .to_string_lossy() + .into_owned(); + + for key in [ + key_event(KeyCode::Tab, KeyModifiers::SHIFT), + key_event(KeyCode::BackTab, KeyModifiers::NONE), + key_event(KeyCode::Tab, KeyModifiers::CONTROL), + key_event(KeyCode::Tab, KeyModifiers::ALT), + key_event(KeyCode::Tab, KeyModifiers::SUPER), + ] { + let mut input = single_field_input("test"); + input.field_mut(0).unwrap().set_text(&partial); + let outcome = input.handle_key(&key); + assert!(matches!(outcome, ModalInputOutcome::Unchanged)); + assert_eq!(input.field(0).unwrap().text(), partial); + assert_eq!(input.focused_index(), 0); + } + } + + #[test] + fn canonical_paste_shortcuts_include_super_and_exclude_altgr() { + crate::clipboard::set_clipboard_probe_hook( + crate::clipboard::ClipboardProbeHook::no_raster(Some("foo\r\nbar")), + ); + let mut super_paste = single_field_input("test"); + let outcome = super_paste.handle_key(&key_event(KeyCode::Char('v'), KeyModifiers::SUPER)); + crate::clipboard::clear_clipboard_probe_hook(); + assert!(matches!(outcome, ModalInputOutcome::Changed)); + assert_eq!(super_paste.field(0).unwrap().text(), "foobar"); + + crate::clipboard::set_clipboard_probe_hook( + crate::clipboard::ClipboardProbeHook::no_raster(Some("clipboard")), + ); + let mut altgr = single_field_input("test"); + let _ = altgr.handle_key(&key_event( + KeyCode::Char('v'), + KeyModifiers::CONTROL | KeyModifiers::ALT, + )); + crate::clipboard::clear_clipboard_probe_hook(); + assert_eq!( + altgr.field(0).unwrap().text(), + if cfg!(target_os = "windows") { "v" } else { "" } + ); + } + + #[test] + fn canonical_small_word_delete_differs_from_ctrl_w() { + const URL: &str = "https://mcp.linear.app/mcp"; + for modifiers in [KeyModifiers::ALT, KeyModifiers::CONTROL] { + let mut input = single_field_input("test"); + input.field_mut(0).unwrap().set_text(URL); + let outcome = input.handle_key(&key_event(KeyCode::Backspace, modifiers)); + assert!(matches!(outcome, ModalInputOutcome::Changed)); + assert_eq!(input.field(0).unwrap().text(), "https://mcp.linear.app/"); + } + + let mut input = single_field_input("test"); + input.field_mut(0).unwrap().set_text(URL); + let outcome = input.handle_key(&key_event(KeyCode::Char('w'), KeyModifiers::CONTROL)); + assert!(matches!(outcome, ModalInputOutcome::Changed)); + assert_eq!(input.field(0).unwrap().text(), ""); + } + + #[test] + fn alt_word_arrows_and_readline_bindings_are_equivalent() { + for key in [ + key_event(KeyCode::Left, KeyModifiers::ALT), + key_event(KeyCode::Char('b'), KeyModifiers::ALT), + key_event(KeyCode::Left, KeyModifiers::CONTROL), + ] { + let mut input = single_field_input("test"); + input.field_mut(0).unwrap().set_text("hello-world"); + assert!(matches!(input.handle_key(&key), ModalInputOutcome::Changed)); + assert_eq!(input.field(0).unwrap().cursor_byte(), "hello-".len()); + } + + for key in [ + key_event(KeyCode::Right, KeyModifiers::ALT), + key_event(KeyCode::Char('f'), KeyModifiers::ALT), + key_event(KeyCode::Right, KeyModifiers::CONTROL), + ] { + let mut input = single_field_input("test"); + input.field_mut(0).unwrap().set_text("hello-world"); + let _ = input.field_mut(0).unwrap().set_cursor_byte(0); + assert!(matches!(input.handle_key(&key), ModalInputOutcome::Changed)); + assert_eq!(input.field(0).unwrap().cursor_byte(), "hello".len()); + } + } + + #[test] + fn grapheme_delete_and_middle_insert_are_atomic() { + let grapheme = "👩🏽\u{200d}💻"; + let mut input = single_field_input("test"); + input + .field_mut(0) + .unwrap() + .set_text(format!("a{grapheme}b")); + let _ = input.field_mut(0).unwrap().set_cursor_byte(1); + + assert!(matches!( + input.handle_key(&key_event(KeyCode::Delete, KeyModifiers::NONE)), + ModalInputOutcome::Changed + )); + assert_eq!(input.field(0).unwrap().text(), "ab"); + assert_eq!(input.field(0).unwrap().cursor_byte(), 1); + + assert!(matches!( + input.handle_key(&key_event(KeyCode::Char('X'), KeyModifiers::NONE)), + ModalInputOutcome::Changed + )); + assert_eq!(input.field(0).unwrap().text(), "aXb"); + assert_eq!(input.field(0).unwrap().cursor_byte(), 2); + } + + #[test] + fn cursor_and_handled_noop_edits_redraw_without_validation_changes() { + let mut input = single_field_input("test"); + input.field_mut(0).unwrap().set_text("abc"); + input.error = Some("Required: URL".to_owned()); + + let outcome = input.handle_key(&key_event(KeyCode::Left, KeyModifiers::NONE)); + assert!(matches!(outcome, ModalInputOutcome::Changed)); + assert_eq!(input.field(0).unwrap().text(), "abc"); + assert_eq!(input.field(0).unwrap().cursor_byte(), 2); + assert_eq!(input.focused_index(), 0); + assert_eq!(input.error.as_deref(), Some("Required: URL")); + + let _ = input.handle_key(&key_event(KeyCode::Home, KeyModifiers::NONE)); + let outcome = input.handle_key(&key_event(KeyCode::Left, KeyModifiers::NONE)); + assert!(matches!(outcome, ModalInputOutcome::Changed)); + assert_eq!(input.field(0).unwrap().cursor_byte(), 0); + assert_eq!(input.error.as_deref(), Some("Required: URL")); + + let outcome = input.handle_key(&key_event(KeyCode::Char('x'), KeyModifiers::NONE)); + assert!(matches!(outcome, ModalInputOutcome::Changed)); + assert_eq!(input.field(0).unwrap().text(), "xabc"); + assert!(input.error.is_none()); + } + + #[test] + fn narrow_form_viewport_keeps_unicode_and_cursor_visible() { + let grapheme = "👩🏽\u{200d}💻"; + let text = format!("1234567中e\u{301}{grapheme}b"); + let mut input = single_field_input("test"); + input.field_mut(0).unwrap().set_text(&text); + let _ = input.field_mut(0).unwrap().set_cursor_byte(text.len() - 1); + + let area = Rect::new(0, 0, 20, 4); + let theme = Theme::current(); + let mut buffer = Buffer::empty(area); + let prompt_width = crate::glyphs::prompt_arrow().width(); + let editor_width = (area.width as usize - 8 - prompt_width).max(1); + let viewport = input.field(0).unwrap().viewport(editor_width); + let visible = &input.field(0).unwrap().text()[viewport.visible_byte_range.clone()]; + assert!(visible.contains('中')); + assert!(visible.contains("e\u{301}")); + assert!(visible.contains(grapheme)); + + render_input_form(&mut buffer, area, &input, &theme); + let rendered = (0..area.width).fold(String::new(), |mut line, x| { + line.push_str(buffer[(x, 2)].symbol()); + line + }); + assert!(rendered.contains('中')); + assert!(rendered.contains("e\u{301}")); + assert!(rendered.contains(grapheme)); + let text_x = 4 + prompt_width as u16; + let cursor_x = text_x + viewport.cursor_display_column as u16; + assert_eq!(buffer[(cursor_x, 2)].bg, theme.text_primary); } // ── Hook helpers with StatusFilter ─────────────────────────────── @@ -6393,7 +6244,7 @@ mod tests { fn marketplace_is_group_expanded_forced_open_during_search() { let mut state = ExtensionsModalState::new(ExtensionsTab::Marketplace); state.marketplace_collapsed_sources.insert(0); - state.picker_state.query = "debug".into(); + state.picker_state.set_query("debug"); // During search, collapsed sources are forced open. assert!(state.is_group_expanded(0, "0")); } @@ -6936,7 +6787,7 @@ mod tests { assert_eq!(buffer_count(&buf, "User (1 plugin)"), 1); assert_eq!(buffer_count(&buf, "user-tool"), 0); - state.picker_state.query = "user".into(); + state.picker_state.set_query("user"); let buf = render_plugins_into_buffer(&mut state, 100, 40); assert_eq!( buffer_count(&buf, "user-tool"), diff --git a/crates/codegen/xai-grok-pager/src/views/file_search/line_viewer.rs b/crates/codegen/xai-grok-pager/src/views/file_search/line_viewer.rs index 95fc065..618735d 100644 --- a/crates/codegen/xai-grok-pager/src/views/file_search/line_viewer.rs +++ b/crates/codegen/xai-grok-pager/src/views/file_search/line_viewer.rs @@ -1007,13 +1007,13 @@ fn build_source_lines(path: &Path, content: &str) -> Vec { .enumerate() .map(|(i, text)| { let line_number = i + 1; - let styled_line = if text.is_empty() { - // Empty line — still needs a Line (for line number prefix). - Line::from(" ".to_owned()) - } else if let Some(ref mut hl) = highlighter { - highlight_to_ratatui_line(hl, text, &syntect.syntax_set) - } else { - Line::from((*text).to_owned()) + // Feed every line, blank ones included, through the highlighter so + // its parse state stays in sync. Skipping blanks corrupts constructs + // that span multiple lines (block comments, multi-line strings). + let styled_line = match highlighter.as_mut() { + Some(hl) => highlight_to_ratatui_line(hl, text, &syntect.syntax_set), + None if text.is_empty() => Line::from(" ".to_owned()), + None => Line::from((*text).to_owned()), }; SourceLine::new(line_number, styled_line, (*text).to_owned(), max_digits) }) @@ -1128,19 +1128,31 @@ fn highlight_to_ratatui_line( text: &str, syntax_set: &syntect::parsing::SyntaxSet, ) -> Line<'static> { - let highlighted = match hl.highlight_line(text, syntax_set) { + // syntect needs the trailing newline to recognize line-spanning constructs. + // Feed it, then strip the newline back out of the rendered spans. + let with_newline = format!("{text}\n"); + let highlighted = match hl.highlight_line(&with_newline, syntax_set) { Ok(h) => h, + Err(_) if text.is_empty() => return Line::from(" ".to_owned()), Err(_) => return Line::from(text.to_owned()), }; - let spans: Vec> = highlighted - .into_iter() - .map(|(style, content)| { - let fg = syntect_to_ratatui_color(style.foreground); - Span::styled(content.to_owned(), Style::default().fg(fg)) - }) - .collect(); + let mut spans: Vec> = Vec::new(); + for (style, segment) in highlighted { + let mut piece = segment.to_owned(); + while piece.ends_with('\n') || piece.ends_with('\r') { + piece.pop(); + } + if piece.is_empty() { + continue; + } + let fg = syntect_to_ratatui_color(style.foreground); + spans.push(Span::styled(piece, Style::default().fg(fg))); + } + if spans.is_empty() { + return Line::from(" ".to_owned()); + } Line::from(spans) } diff --git a/crates/codegen/xai-grok-pager/src/views/list_pane/state/methods.rs b/crates/codegen/xai-grok-pager/src/views/list_pane/state/methods.rs index 3a8ed63..8b7cbc7 100644 --- a/crates/codegen/xai-grok-pager/src/views/list_pane/state/methods.rs +++ b/crates/codegen/xai-grok-pager/src/views/list_pane/state/methods.rs @@ -1500,6 +1500,29 @@ impl ListPaneState { // Keyboard input // ======================================================================= + /// Paste into the active input bar. Returns `false` when no editor is open. + pub fn handle_paste(&mut self, text: &str, items: &[T]) -> bool { + let Some(mode) = self.input_mode else { + return false; + }; + let old_text = self.input_textarea.text().to_owned(); + if mode == InputBarMode::Comment { + self.input_textarea.insert_str(text); + } else { + let cleaned = crate::input::line_editor::sanitize_single_line(text); + self.input_textarea.insert_str(&cleaned); + } + if self.input_textarea.text() == old_text { + return false; + } + match mode { + InputBarMode::GotoLine => self.apply_goto_line_live(items), + InputBarMode::Search | InputBarMode::Filter => self.apply_input_buffer(items), + InputBarMode::Comment => {} + } + true + } + /// Handle a key event for navigation, search, and filter. /// /// Returns `true` if the key was consumed (state changed), `false` if diff --git a/crates/codegen/xai-grok-pager/src/views/list_pane/state/mod.rs b/crates/codegen/xai-grok-pager/src/views/list_pane/state/mod.rs index 5553745..3cb4696 100644 --- a/crates/codegen/xai-grok-pager/src/views/list_pane/state/mod.rs +++ b/crates/codegen/xai-grok-pager/src/views/list_pane/state/mod.rs @@ -2747,4 +2747,32 @@ mod tests { state.scroll_offset, ); } + + #[test] + fn paste_targets_only_active_list_input_and_preserves_comment_newlines() { + let items = vec![ + TestItem::new(0).with_text("alpha"), + TestItem::new(1).with_text("beta"), + ]; + let mut state = + ListPaneState::new_with_config(WrapMode::NoWrap, false, ListPaneConfig::streaming()); + state.prepare_layout(&items, 80, 4); + assert!(state.handle_key_event(&key!('/').to_key_event(), &items)); + assert!(state.handle_key_event(&key!('a').to_key_event(), &items)); + assert!(state.handle_key_event(&key!('b').to_key_event(), &items)); + assert!(state.handle_key_event(&key!(Left).to_key_event(), &items)); + assert!(state.handle_paste("中\r\n", &items)); + assert_eq!(state.input_text(), "a中b"); + assert_eq!(state.matcher().map(ListMatcher::query), Some("a中b")); + + assert!(!state.handle_paste("\r\n", &items)); + assert_eq!(state.input_text(), "a中b"); + + state.close_input_bar(); + assert!(!state.handle_paste("ignored", &items)); + + state.open_comment_input(""); + assert!(state.handle_paste("a\nb", &items)); + assert_eq!(state.input_text(), "a\nb"); + } } diff --git a/crates/codegen/xai-grok-pager/src/views/memory_modal.rs b/crates/codegen/xai-grok-pager/src/views/memory_modal.rs index 955cc9d..98c4b4a 100644 --- a/crates/codegen/xai-grok-pager/src/views/memory_modal.rs +++ b/crates/codegen/xai-grok-pager/src/views/memory_modal.rs @@ -24,6 +24,7 @@ use unicode_width::UnicodeWidthStr; use crate::app::actions::Action; use crate::app::app_view::InputOutcome; +use crate::input::line_editor::{LineEditOutcome, LineEditor}; use crate::render::SafeBuf; use crate::render::scrollbar::{ScrollbarClickResult, render_scrollbar, scrollbar_click_to_offset}; @@ -69,7 +70,7 @@ pub struct MemoryModalState { pub preview_markdown: Option, pub preview_scroll: usize, pub mode: MemoryModalMode, - pub query: String, + query: LineEditor, /// Whether memory is currently enabled for this session. pub memory_enabled: bool, /// Whether the modal is rendered in fullscreen mode (persisted to config). @@ -96,7 +97,7 @@ impl MemoryModalState { preview_markdown: None, preview_scroll: 0, mode: MemoryModalMode::Browse, - query: String::new(), + query: LineEditor::default(), memory_enabled: true, fullscreen: load_fullscreen_pref(), filtered_cache, @@ -115,8 +116,31 @@ impl MemoryModalState { &self.filtered_cache } + pub fn query(&self) -> &str { + self.query.text() + } + + pub fn query_cursor_byte(&self) -> usize { + self.query.cursor_byte() + } + + #[cfg(test)] + fn set_query(&mut self, query: impl Into) { + self.query.set_text(query); + } + + #[cfg(test)] + fn set_query_cursor_byte(&mut self, cursor_byte: usize) -> LineEditOutcome { + self.query.set_cursor_byte(cursor_byte) + } + + #[cfg(test)] + fn query_viewport(&self, width: usize) -> xai_ratatui_textarea::SingleLineViewport { + self.query.viewport(width) + } + fn invalidate_filter(&mut self) { - self.filtered_cache = compute_filtered(&self.entries, &self.query); + self.filtered_cache = compute_filtered(&self.entries, self.query()); } pub fn selected_entry(&self) -> Option<&MemoryFileEntry> { @@ -448,40 +472,46 @@ pub fn render_memory_modal( fn render_file_list(buf: &mut Buffer, area: Rect, state: &mut MemoryModalState, theme: &Theme) { let search_y = area.y; let filter_focused = matches!(state.mode, MemoryModalMode::FilterFocused); - let (query_display, query_style) = if state.query.is_empty() { + let viewport = state.query.viewport(area.width as usize); + if state.query().is_empty() { let placeholder = if filter_focused { "type to filter..." } else { "/ to filter..." }; - ( - placeholder, - Style::default().fg(theme.gray_dim).bg(theme.bg_base), - ) + buf.set_span( + area.x, + search_y, + &Span::styled( + placeholder, + Style::default().fg(theme.gray_dim).bg(theme.bg_base), + ), + area.width, + ); } else { - ( - state.query.as_str(), - Style::default().fg(theme.text_primary).bg(theme.bg_base), - ) - }; - buf.set_span( - area.x, - search_y, - &Span::styled(query_display, query_style), - area.width, - ); + let leading; + let visible = if filter_focused { + &state.query()[viewport.visible_byte_range.clone()] + } else { + leading = crate::render::line_utils::truncate_str(state.query(), area.width as usize); + &leading + }; + buf.set_span( + area.x, + search_y, + &Span::styled( + visible, + Style::default().fg(theme.text_primary).bg(theme.bg_base), + ), + area.width, + ); + } if filter_focused { - let cursor_x = area.x + state.query.width() as u16; - if cursor_x < area.x + area.width { - buf.set_span( - cursor_x, - search_y, - &Span::styled( - "\u{2588}", - Style::default().fg(theme.accent_user).bg(theme.bg_base), - ), - 1, - ); + let cursor_x = area.x + viewport.cursor_display_column as u16; + if cursor_x < area.x + area.width + && let Some(cell) = buf.cell_mut((cursor_x, search_y)) + { + cell.set_style(Style::default().fg(theme.bg_base).bg(theme.text_primary)); } } @@ -710,6 +740,14 @@ pub fn handle_memory_key(state: &mut MemoryModalState, key: &KeyEvent) -> InputO } } +pub fn handle_memory_paste(state: &mut MemoryModalState, text: &str) -> InputOutcome { + if state.mode != MemoryModalMode::FilterFocused { + return InputOutcome::Unchanged; + } + let outcome = state.query.insert_paste(text); + finish_filter_edit(state, outcome) +} + /// Saturating cast from `usize` to `u16` (caps at `u16::MAX`). fn sat_u16(v: usize) -> u16 { v.min(u16::MAX as usize) as u16 @@ -880,22 +918,23 @@ fn handle_filter_focused(state: &mut MemoryModalState, key: &KeyEvent) -> InputO state.select_prev(); InputOutcome::Changed } - KeyCode::Char(c) if crate::input::key::is_text_input_key(key) => { - state.query.push(c); + _ => { + let outcome = state.query.handle_key(key); + + finish_filter_edit(state, outcome) + } + } +} + +fn finish_filter_edit(state: &mut MemoryModalState, outcome: LineEditOutcome) -> InputOutcome { + match outcome { + LineEditOutcome::TextChanged => { state.invalidate_filter(); state.clamp_selected(); InputOutcome::Changed } - KeyCode::Backspace => { - if state.query.pop().is_some() { - state.invalidate_filter(); - state.clamp_selected(); - InputOutcome::Changed - } else { - InputOutcome::Unchanged - } - } - _ => InputOutcome::Unchanged, + LineEditOutcome::CursorChanged | LineEditOutcome::HandledNoChange => InputOutcome::Changed, + LineEditOutcome::Unhandled => InputOutcome::Unchanged, } } @@ -974,7 +1013,7 @@ fn handle_browse(state: &mut MemoryModalState, key: &KeyEvent) -> InputOutcome { InputOutcome::Changed } KeyCode::Backspace => { - if state.query.pop().is_some() { + if state.query.delete_last_grapheme() == LineEditOutcome::TextChanged { state.invalidate_filter(); state.clamp_selected(); InputOutcome::Changed @@ -1215,7 +1254,7 @@ mod tests { fn filtered_indices_preserves_headers_for_matching_entries() { let entries = build_test_entries(); let mut state = MemoryModalState::new(entries); - state.query = "memory".to_string(); + state.set_query("memory"); state.invalidate_filter(); let indices = state.filtered_indices(); @@ -1313,12 +1352,12 @@ mod tests { let mut state = MemoryModalState::new(entries); assert_eq!(state.filtered_indices().len(), 4); // all entries - state.query = "session".to_string(); + state.set_query("session"); state.invalidate_filter(); // Only the Sessions header + session-log.md should match. assert_eq!(state.filtered_indices().len(), 2); - state.query.clear(); + state.set_query(""); state.invalidate_filter(); assert_eq!(state.filtered_indices().len(), 4); } @@ -1459,6 +1498,143 @@ mod tests { assert_eq!(state.preview_scroll, 5); } + #[test] + fn filter_text_changes_recompute_preview_but_cursor_moves_do_not() { + let mut state = MemoryModalState::new(build_test_entries()); + state.mode = MemoryModalMode::FilterFocused; + state.preview_scroll = 7; + + let outcome = handle_memory_key( + &mut state, + &KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE), + ); + assert!(matches!(outcome, InputOutcome::Changed)); + assert_eq!(state.query(), "m"); + assert_eq!(state.preview_scroll, 0); + let filtered = state.filtered_indices().to_vec(); + + state.preview_scroll = 7; + let outcome = handle_memory_key( + &mut state, + &KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), + ); + assert!(matches!(outcome, InputOutcome::Changed)); + assert_eq!(state.query(), "m"); + assert_eq!(state.query_cursor_byte(), 0); + assert_eq!(state.filtered_indices(), filtered); + assert_eq!(state.preview_scroll, 7); + } + + #[test] + fn filter_paste_recomputes_once_and_consumes_empty_input() { + let mut state = MemoryModalState::new(build_test_entries()); + state.mode = MemoryModalMode::FilterFocused; + state.preview_scroll = 7; + let outcome = handle_memory_paste(&mut state, "mem\r\n"); + assert!(matches!(outcome, InputOutcome::Changed)); + assert_eq!(state.query(), "mem"); + assert_eq!(state.preview_scroll, 0); + + state.preview_scroll = 7; + let outcome = handle_memory_paste(&mut state, "\r\n"); + assert!(matches!(outcome, InputOutcome::Changed)); + assert_eq!(state.query(), "mem"); + assert_eq!(state.preview_scroll, 7); + + state.mode = MemoryModalMode::Browse; + let outcome = handle_memory_paste(&mut state, "ignored"); + assert!(matches!(outcome, InputOutcome::Unchanged)); + assert_eq!(state.query(), "mem"); + } + + #[test] + fn filter_escape_preserves_query_and_enter_stays_focused() { + let mut state = MemoryModalState::new(build_test_entries()); + state.mode = MemoryModalMode::FilterFocused; + state.set_query("memory"); + + let outcome = handle_memory_key( + &mut state, + &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert!(matches!(outcome, InputOutcome::Unchanged)); + assert_eq!(state.mode, MemoryModalMode::FilterFocused); + assert_eq!(state.query(), "memory"); + + let outcome = + handle_memory_key(&mut state, &KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(matches!(outcome, InputOutcome::Changed)); + assert_eq!(state.mode, MemoryModalMode::Browse); + assert_eq!(state.query(), "memory"); + } + + #[test] + fn filter_uses_canonical_word_and_grapheme_editing() { + for key in [ + KeyEvent::new(KeyCode::Left, KeyModifiers::ALT), + KeyEvent::new(KeyCode::Char('b'), KeyModifiers::ALT), + KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL), + ] { + let mut state = MemoryModalState::new(build_test_entries()); + state.mode = MemoryModalMode::FilterFocused; + state.set_query("hello-world"); + let outcome = handle_memory_key(&mut state, &key); + assert!(matches!(outcome, InputOutcome::Changed)); + assert_eq!(state.query(), "hello-world"); + assert_eq!(state.query_cursor_byte(), "hello-".len()); + } + + let grapheme = "👩🏽\u{200d}💻"; + let mut state = MemoryModalState::new(build_test_entries()); + state.mode = MemoryModalMode::FilterFocused; + state.set_query(format!("a{grapheme}b")); + let _ = state.set_query_cursor_byte(1); + let outcome = handle_memory_key( + &mut state, + &KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE), + ); + assert!(matches!(outcome, InputOutcome::Changed)); + assert_eq!(state.query(), "ab"); + assert_eq!(state.query_cursor_byte(), 1); + } + + #[test] + fn browse_backspace_deletes_trailing_grapheme_independent_of_cursor_and_modifiers() { + let mut state = MemoryModalState::new(build_test_entries()); + let grapheme = "👩🏽\u{200d}💻"; + state.set_query(format!("a{grapheme}")); + let _ = state.set_query_cursor_byte(0); + let outcome = handle_memory_key( + &mut state, + &KeyEvent::new(KeyCode::Backspace, KeyModifiers::CONTROL), + ); + assert!(matches!(outcome, InputOutcome::Changed)); + assert_eq!(state.query(), "a"); + assert_eq!(state.query_cursor_byte(), 1); + } + + #[test] + fn filter_render_keeps_unicode_query_and_cursor_visible() { + let mut state = MemoryModalState::new(build_test_entries()); + state.mode = MemoryModalMode::FilterFocused; + let grapheme = "👩🏽\u{200d}💻"; + let text = format!("123456789012中e\u{301}{grapheme}z"); + state.set_query(&text); + let _ = state.set_query_cursor_byte(text.len() - 1); + let area = Rect::new(0, 0, 12, 3); + let theme = Theme::current(); + let mut buffer = Buffer::empty(area); + let viewport = state.query_viewport(area.width as usize); + let visible = &state.query()[viewport.visible_byte_range.clone()]; + assert!(visible.contains('中')); + assert!(visible.contains("e\u{301}")); + assert!(visible.contains(grapheme)); + + render_file_list(&mut buffer, area, &mut state, &theme); + let cursor_x = viewport.cursor_display_column as u16; + assert_eq!(buffer[(cursor_x, 0)].bg, theme.text_primary); + } + #[test] fn apply_scrollbar_jump_edges() { let mut offset = 50; diff --git a/crates/codegen/xai-grok-pager/src/views/modal.rs b/crates/codegen/xai-grok-pager/src/views/modal.rs index 808c008..0bdd28e 100644 --- a/crates/codegen/xai-grok-pager/src/views/modal.rs +++ b/crates/codegen/xai-grok-pager/src/views/modal.rs @@ -1019,10 +1019,10 @@ pub fn render_doc_picker_overlay( render_centered_tip_footer, split_content_for_tip_footer, }; use super::picker::{self, PickerEntry, PickerRow}; - let filtered: Vec<_> = if state.query.is_empty() { + let filtered: Vec<_> = if state.query().is_empty() { entries.iter().enumerate().collect() } else { - let q = state.query.to_lowercase(); + let q = state.query().to_lowercase(); entries .iter() .enumerate() diff --git a/crates/codegen/xai-grok-pager/src/views/new_worktree_dialog.rs b/crates/codegen/xai-grok-pager/src/views/new_worktree_dialog.rs index 080c8a0..83a1143 100644 --- a/crates/codegen/xai-grok-pager/src/views/new_worktree_dialog.rs +++ b/crates/codegen/xai-grok-pager/src/views/new_worktree_dialog.rs @@ -5,7 +5,6 @@ use ratatui::layout::{Constraint, Flex, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::Widget; -use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; use crate::app::app_view::NewWorktreeDialogState; @@ -20,14 +19,12 @@ const LABEL_PREFIX: &str = "Name (optional): "; /// Render the new-worktree popup dialog centered on screen. /// -/// The dialog grows with the typed label (up to the available terminal -/// width) so long names stay fully visible. When the terminal itself is -/// too narrow for the full name, the input scrolls to keep the cursor -/// (end of the label) in view, with a leading `…` when scrolled. +/// The dialog grows with the typed label up to the available width, then +/// scrolls the input viewport to keep the live cursor visible. pub fn render_new_worktree_dialog(area: Rect, buf: &mut Buffer, state: &NewWorktreeDialogState) { let theme = Theme::current(); - let dialog_width = dialog_width_for(area.width, &state.label_input); + let dialog_width = dialog_width_for(area.width, state.label()); if area.height < DIALOG_HEIGHT || area.width < 20 { // Too small to render — draw a minimal "resize" hint so the user @@ -126,19 +123,22 @@ pub fn render_new_worktree_dialog(area: Rect, buf: &mut Buffer, state: &NewWorkt )); title.render(Rect::new(inner_x, dialog.y + 1, inner_width, 1), buf); - // Row 2: Label input — grow with content; scroll when still too wide. + // Row 2: Label input. let prefix_w = LABEL_PREFIX.width() as u16; - let cursor_w = 1u16; - let input_budget = inner_width - .saturating_sub(prefix_w) - .saturating_sub(cursor_w) as usize; - let visible_input = visible_input_suffix(&state.label_input, input_budget); + let input_width = inner_width.saturating_sub(prefix_w); + let viewport = state.viewport(input_width as usize); + let visible_input = &state.label()[viewport.visible_byte_range]; let prefix_span = Span::styled(LABEL_PREFIX, Style::default().fg(theme.gray_bright)); let input_span = Span::styled(visible_input, Style::default().fg(theme.text_primary)); - let cursor_span = Span::styled("\u{2588}", Style::default().fg(theme.accent_user)); - let input_line = Line::from(vec![prefix_span, input_span, cursor_span]); + let input_line = Line::from(vec![prefix_span, input_span]); input_line.render(Rect::new(inner_x, dialog.y + 2, inner_width, 1), buf); + if input_width > 0 { + let cursor_x = inner_x + prefix_w + viewport.cursor_display_column as u16; + if let Some(cell) = buf.cell_mut((cursor_x, dialog.y + 2)) { + cell.set_style(Style::default().fg(theme.bg_dark).bg(theme.text_primary)); + } + } // Row 3: Hints let hints = Line::from(vec![ @@ -168,40 +168,6 @@ fn dialog_width_for(area_width: u16, label: &str) -> u16 { needed.max(MIN_DIALOG_WIDTH).min(max_width) } -/// Return the visible portion of `label` for an end-anchored input field. -/// -/// When `label` fits in `budget` columns, returns it unchanged. Otherwise -/// returns a leading `…` plus the suffix that fits, so the cursor at the -/// end of the label stays visible while typing a long name. -/// -/// Walks Unicode grapheme clusters (not scalar values) so combining marks -/// and ZWJ sequences are never split across the scroll boundary. -fn visible_input_suffix(label: &str, budget: usize) -> String { - if budget == 0 { - return String::new(); - } - if label.width() <= budget { - return label.to_string(); - } - if budget == 1 { - return "…".to_string(); - } - - let suffix_budget = budget - 1; // reserve one column for leading … - let mut width = 0usize; - let mut start = label.len(); - let graphemes: Vec<(usize, &str)> = label.grapheme_indices(true).collect(); - for &(i, g) in graphemes.iter().rev() { - let cw = UnicodeWidthStr::width(g); - if width + cw > suffix_budget { - break; - } - width += cw; - start = i; - } - format!("…{}", &label[start..]) -} - #[cfg(test)] mod tests { use super::*; @@ -210,9 +176,8 @@ mod tests { fn render_to_text(area: Rect, label: &str) -> String { let mut buf = Buffer::empty(area); - let state = NewWorktreeDialogState { - label_input: label.to_string(), - }; + let mut state = NewWorktreeDialogState::new(); + state.set_label(label); render_new_worktree_dialog(area, &mut buf, &state); let mut lines = Vec::new(); for y in 0..area.height { @@ -255,52 +220,6 @@ mod tests { assert_eq!(width, 56); // 60 - 4 } - #[test] - fn visible_suffix_keeps_end_when_scrolled() { - let label = "abcdefghijklmnopqrstuvwxyz0123456789"; - let visible = visible_input_suffix(label, 10); - assert!( - visible.starts_with('…'), - "expected leading ellipsis: {visible}" - ); - assert!( - visible.ends_with("0123456789") || visible.ends_with("123456789"), - "expected end of label visible: {visible}" - ); - assert_eq!(visible.width(), 10); - } - - #[test] - fn visible_suffix_unchanged_when_fits() { - assert_eq!(visible_input_suffix("short", 20), "short"); - } - - #[test] - fn visible_suffix_does_not_split_grapheme_clusters() { - // "e" + combining acute (U+0301) is one grapheme; pad so we must scroll. - let cluster = "e\u{0301}"; - let label = format!("{}{}", "x".repeat(20), cluster); - let visible = visible_input_suffix(&label, 8); - assert!( - visible.starts_with('…'), - "expected leading ellipsis: {visible}" - ); - // Either the full cluster is present, or it was dropped as a unit — - // never a lone combining mark after the ellipsis. - let after_ellipsis = &visible[visible.char_indices().nth(1).map(|(i, _)| i).unwrap_or(0)..]; - assert!( - !after_ellipsis.starts_with('\u{0301}'), - "must not start scrolled suffix on a combining mark: {visible:?}" - ); - if after_ellipsis.contains('e') { - assert!( - after_ellipsis.contains(cluster), - "base 'e' must keep its combining mark: {visible:?}" - ); - } - assert!(visible.width() <= 8, "width overflow: {visible:?}"); - } - #[test] fn long_name_fully_visible_on_wide_terminal() { let area = Rect::new(0, 0, 100, 20); @@ -329,4 +248,24 @@ mod tests { "expected scrolled indicator or tail:\n{text}" ); } + + #[test] + fn narrow_dialog_keeps_middle_unicode_cursor_visible() { + let area = Rect::new(0, 0, 40, 12); + let grapheme = "👩🏽\u{200d}💻"; + let label = format!("xxxxxxxxxxxx中e\u{301}{grapheme}tail"); + let mut state = NewWorktreeDialogState::new(); + state.set_label(&label); + let cursor_byte = "xxxxxxxxxxxx中e\u{301}".len(); + let _ = state.set_cursor_byte(cursor_byte); + let mut buffer = Buffer::empty(area); + render_new_worktree_dialog(area, &mut buffer, &state); + + assert!( + (0..area.height).any(|y| { + (0..area.width).any(|x| buffer[(x, y)].bg == Theme::current().text_primary) + }), + "live cursor cell must remain visible", + ); + } } diff --git a/crates/codegen/xai-grok-pager/src/views/persona_detail.rs b/crates/codegen/xai-grok-pager/src/views/persona_detail.rs index 306e899..8a0a222 100644 --- a/crates/codegen/xai-grok-pager/src/views/persona_detail.rs +++ b/crates/codegen/xai-grok-pager/src/views/persona_detail.rs @@ -7,12 +7,13 @@ use std::path::{Path, PathBuf}; -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent}; +use crossterm::event::{KeyCode, KeyEvent, MouseEvent}; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::{Modifier, Style}; use unicode_width::UnicodeWidthStr; +use crate::input::line_editor::{LineEditOutcome, LineEditor}; use crate::theme::Theme; use crate::views::modal_window::{ self, ModalContentArea, ModalSizing, ModalWindowConfig, ModalWindowState, Shortcut, @@ -81,12 +82,11 @@ impl PersonaField { // --------------------------------------------------------------------------- #[derive(Debug)] -pub enum PersonaDetailMode { +enum PersonaDetailMode { Browse, Editing { field: PersonaField, - buffer: String, - cursor: usize, + editor: LineEditor, original: String, }, } @@ -139,7 +139,7 @@ pub struct PersonaDetailState { pub scope_label: String, pub selected_field: PersonaField, pub scroll_offset: usize, - pub mode: PersonaDetailMode, + mode: PersonaDetailMode, pub dirty: bool, pub instructions_expanded: bool, /// Scroll offset within expanded instructions (line index of first visible line). @@ -276,6 +276,43 @@ impl PersonaDetailState { } } + pub fn is_editing(&self) -> bool { + matches!(&self.mode, PersonaDetailMode::Editing { .. }) + } + + #[cfg(test)] + fn editing_editor(&self) -> Option<&LineEditor> { + match &self.mode { + PersonaDetailMode::Editing { editor, .. } => Some(editor), + PersonaDetailMode::Browse => None, + } + } + + #[cfg(test)] + fn editing_viewport(&self, width: usize) -> Option { + self.editing_editor().map(|editor| editor.viewport(width)) + } + + #[cfg(test)] + fn editing_text(&self) -> Option<&str> { + self.editing_editor().map(LineEditor::text) + } + + #[cfg(test)] + fn set_editing_text(&mut self, text: impl Into) { + if let PersonaDetailMode::Editing { editor, .. } = &mut self.mode { + editor.set_text(text); + } + } + + #[cfg(test)] + fn set_editing_cursor_byte(&mut self, cursor_byte: usize) -> LineEditOutcome { + match &mut self.mode { + PersonaDetailMode::Editing { editor, .. } => editor.set_cursor_byte(cursor_byte), + PersonaDetailMode::Browse => LineEditOutcome::Unhandled, + } + } + /// Save current state back to the TOML file using toml_edit to preserve formatting. fn save_to_file(&self) -> Result<(), String> { let Some(ref path) = self.source_path else { @@ -314,6 +351,26 @@ impl PersonaDetailState { // Rendering // --------------------------------------------------------------------------- +fn render_detail_editor( + buf: &mut Buffer, + x: u16, + y: u16, + width: usize, + editor: &LineEditor, + style: Style, + theme: &Theme, +) { + let viewport = editor.viewport(width); + let visible = &editor.text()[viewport.visible_byte_range]; + buf.set_string(x, y, visible, style); + if width > 0 { + let cursor_x = x + viewport.cursor_display_column as u16; + if let Some(cell) = buf.cell_mut((cursor_x, y)) { + cell.set_style(Style::default().fg(theme.bg_base).bg(theme.text_primary)); + } + } +} + /// Render the persona detail modal. pub fn render_persona_detail( buf: &mut Buffer, @@ -395,24 +452,18 @@ pub fn render_persona_detail( // Check if we're in editing mode for this field. if is_selected && let PersonaDetailMode::Editing { - ref buffer, cursor, .. - } = state.mode + field: editing_field, + editor, + .. + } = &state.mode + && *editing_field == field { - // Render inline editor. - let display: String = buffer.chars().take(value_w).collect(); let field_style = if let Some(bg) = row_bg { Style::default().fg(theme.text_primary).bg(bg) } else { Style::default().fg(theme.text_primary) }; - buf.set_string(value_x, y, &display, field_style); - // Cursor - let cursor_x = value_x + buffer[..cursor.min(buffer.len())].width() as u16; - if cursor_x < content_area.x + content_area.width - && let Some(cell) = buf.cell_mut((cursor_x, y)) - { - cell.set_style(Style::default().fg(theme.bg_base).bg(theme.text_primary)); - } + render_detail_editor(buf, value_x, y, value_w, editor, field_style, theme); } else if field == PersonaField::Instructions { // Multi-line instructions with expand/collapse and scroll. if value.is_empty() { @@ -630,7 +681,7 @@ fn persona_detail_sizing(compact: bool) -> ModalSizing { } fn build_shortcuts(state: &PersonaDetailState) -> Vec> { - if matches!(state.mode, PersonaDetailMode::Editing { .. }) { + if state.is_editing() { vec![ Shortcut { label: "Enter save", @@ -682,12 +733,28 @@ pub fn handle_persona_detail_key( ) -> PersonaDetailOutcome { state.message = None; - match &state.mode { - PersonaDetailMode::Editing { .. } => handle_editing_key(state, key), - PersonaDetailMode::Browse => handle_browse_key(state, key), + if state.is_editing() { + handle_editing_key(state, key) + } else { + handle_browse_key(state, key) } } +pub fn handle_persona_detail_paste( + state: &mut PersonaDetailState, + text: &str, +) -> PersonaDetailOutcome { + if !state.is_editing() { + return PersonaDetailOutcome::Unchanged; + } + state.message = None; + let outcome = match &mut state.mode { + PersonaDetailMode::Editing { editor, .. } => editor.insert_paste(text), + PersonaDetailMode::Browse => unreachable!("editing mode changed before paste"), + }; + finish_edit(outcome) +} + fn handle_browse_key(state: &mut PersonaDetailState, key: &KeyEvent) -> PersonaDetailOutcome { // When instructions are expanded and selected, j/k scrolls within them. let instr_scrolling = @@ -739,11 +806,18 @@ fn handle_browse_key(state: &mut PersonaDetailState, key: &KeyEvent) -> PersonaD return PersonaDetailOutcome::Changed; } let current = state.field_value(field).to_owned(); + if current.contains(['\n', '\r']) { + state.message = + Some("Multiline values must be edited in the source file".to_string()); + return PersonaDetailOutcome::Changed; + } + let mut editor = LineEditor::default(); + editor.set_text(¤t); + let original = current; state.mode = PersonaDetailMode::Editing { field, - cursor: current.len(), - original: current.clone(), - buffer: current, + editor, + original, }; PersonaDetailOutcome::Changed } @@ -763,91 +837,47 @@ fn handle_browse_key(state: &mut PersonaDetailState, key: &KeyEvent) -> PersonaD } fn handle_editing_key(state: &mut PersonaDetailState, key: &KeyEvent) -> PersonaDetailOutcome { - let PersonaDetailMode::Editing { - field, - ref mut buffer, - ref mut cursor, - ref original, - } = state.mode - else { - return PersonaDetailOutcome::Unchanged; - }; - - match key.code { - KeyCode::Esc => { - // Cancel — restore original. - state.mode = PersonaDetailMode::Browse; - PersonaDetailOutcome::Changed - } - KeyCode::Enter => { - // Save the edit. - let new_value = buffer.clone(); - let changed = new_value != *original; + if key.code == KeyCode::Esc { + state.mode = PersonaDetailMode::Browse; + return PersonaDetailOutcome::Changed; + } + if key.code == KeyCode::Enter { + let mode = std::mem::replace(&mut state.mode, PersonaDetailMode::Browse); + let PersonaDetailMode::Editing { + field, + editor, + original, + } = mode + else { + return PersonaDetailOutcome::Unchanged; + }; + let new_value = editor.text().to_owned(); + let changed = new_value != original; + if changed { state.set_field_value(field, new_value); - state.mode = PersonaDetailMode::Browse; - if changed { - state.dirty = true; - if let Err(e) = state.save_to_file() { - state.message = Some(format!("Save failed: {e}")); - } else { - state.message = Some("Saved".to_string()); - } + state.dirty = true; + if let Err(e) = state.save_to_file() { + state.message = Some(format!("Save failed: {e}")); + } else { + state.message = Some("Saved".to_string()); } - PersonaDetailOutcome::Changed } - KeyCode::Backspace => { - if *cursor > 0 { - let prev = buffer[..*cursor] - .char_indices() - .next_back() - .map(|(i, _)| i) - .unwrap_or(0); - buffer.remove(prev); - *cursor = prev; - } - PersonaDetailOutcome::Changed - } - KeyCode::Left => { - if *cursor > 0 { - let prev = buffer[..*cursor] - .char_indices() - .next_back() - .map(|(i, _)| i) - .unwrap_or(0); - *cursor = prev; - } - PersonaDetailOutcome::Changed - } - KeyCode::Right => { - if *cursor < buffer.len() { - let next = buffer[*cursor..] - .char_indices() - .nth(1) - .map(|(i, _)| *cursor + i) - .unwrap_or(buffer.len()); - *cursor = next; - } - PersonaDetailOutcome::Changed - } - KeyCode::Home => { - *cursor = 0; - PersonaDetailOutcome::Changed - } - KeyCode::End => { - *cursor = buffer.len(); - PersonaDetailOutcome::Changed - } - KeyCode::Char(c) - if !key - .modifiers - .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER) - || crate::input::key::is_altgr(key.modifiers) => - { - buffer.insert(*cursor, c); - *cursor += c.len_utf8(); - PersonaDetailOutcome::Changed - } - _ => PersonaDetailOutcome::Unchanged, + return PersonaDetailOutcome::Changed; + } + + let outcome = match &mut state.mode { + PersonaDetailMode::Editing { editor, .. } => editor.handle_key(key), + PersonaDetailMode::Browse => return PersonaDetailOutcome::Unchanged, + }; + finish_edit(outcome) +} + +fn finish_edit(outcome: LineEditOutcome) -> PersonaDetailOutcome { + match outcome { + LineEditOutcome::TextChanged + | LineEditOutcome::CursorChanged + | LineEditOutcome::HandledNoChange => PersonaDetailOutcome::Changed, + LineEditOutcome::Unhandled => PersonaDetailOutcome::Unchanged, } } @@ -896,3 +926,6 @@ fn word_wrap_lines(text: &str, max_width: usize) -> Vec { } lines } + +#[cfg(test)] +mod tests; diff --git a/crates/codegen/xai-grok-pager/src/views/persona_detail/tests.rs b/crates/codegen/xai-grok-pager/src/views/persona_detail/tests.rs new file mode 100644 index 0000000..e2ccae0 --- /dev/null +++ b/crates/codegen/xai-grok-pager/src/views/persona_detail/tests.rs @@ -0,0 +1,189 @@ +use super::*; +use crossterm::event::KeyModifiers; + +fn editable_state() -> (tempfile::TempDir, PathBuf, PersonaDetailState) { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("reviewer.toml"); + std::fs::write( + &path, + concat!( + "name = \"reviewer\"\n", + "description = \"old description\"\n", + "model = \"grok\"\n", + "reasoning_effort = \"high\"\n", + "default_isolation = \"worktree\"\n", + "instructions = \"read only instructions\"\n", + ), + ) + .unwrap(); + let state = PersonaDetailState::from_toml_file(&path, true, "project").unwrap(); + (directory, path, state) +} + +#[test] +fn detail_edit_save_updates_state_and_toml() { + let (_directory, path, mut state) = editable_state(); + state.selected_field = PersonaField::Description; + let _ = handle_persona_detail_key( + &mut state, + &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + let _ = handle_persona_detail_key( + &mut state, + &KeyEvent::new(KeyCode::Home, KeyModifiers::NONE), + ); + let _ = handle_persona_detail_key( + &mut state, + &KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL), + ); + for ch in "new description".chars() { + let _ = handle_persona_detail_key( + &mut state, + &KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), + ); + } + let _ = handle_persona_detail_key( + &mut state, + &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + + assert!(!state.is_editing()); + assert_eq!(state.description, "new description"); + assert!(state.dirty); + let saved = std::fs::read_to_string(path).unwrap(); + assert!(saved.contains("description = \"new description\"")); +} + +#[test] +fn detail_edit_cancel_preserves_original_and_file() { + let (_directory, path, mut state) = editable_state(); + let before = std::fs::read_to_string(&path).unwrap(); + state.selected_field = PersonaField::Name; + let _ = handle_persona_detail_key( + &mut state, + &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + let _ = handle_persona_detail_key( + &mut state, + &KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE), + ); + let _ = handle_persona_detail_key(&mut state, &KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert!(!state.is_editing()); + assert_eq!(state.name, "reviewer"); + assert!(!state.dirty); + assert_eq!(std::fs::read_to_string(path).unwrap(), before); +} + +#[test] +fn detail_unchanged_edit_does_not_write_or_mark_dirty() { + let (_directory, path, mut state) = editable_state(); + let before = std::fs::read_to_string(&path).unwrap(); + state.selected_field = PersonaField::Model; + let _ = handle_persona_detail_key( + &mut state, + &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + let _ = handle_persona_detail_key( + &mut state, + &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + + assert!(!state.is_editing()); + assert!(!state.dirty); + assert!(state.message.is_none()); + assert_eq!(std::fs::read_to_string(path).unwrap(), before); +} + +#[test] +fn multiline_values_require_source_file_editing() { + let (_directory, _path, mut state) = editable_state(); + state.description = "first line\nsecond line".to_owned(); + state.selected_field = PersonaField::Description; + + let outcome = handle_persona_detail_key( + &mut state, + &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert!(matches!(outcome, PersonaDetailOutcome::Changed)); + assert!(!state.is_editing()); + assert_eq!(state.description, "first line\nsecond line"); + assert_eq!( + state.message.as_deref(), + Some("Multiline values must be edited in the source file") + ); +} + +#[test] +fn detail_instructions_remain_read_only_inline() { + let (_directory, _path, mut state) = editable_state(); + state.selected_field = PersonaField::Instructions; + let outcome = handle_persona_detail_key( + &mut state, + &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert!(matches!(outcome, PersonaDetailOutcome::Changed)); + assert!(!state.is_editing()); + assert!(state.instructions_expanded); +} + +#[test] +fn detail_paste_targets_only_active_editor_and_sanitizes() { + let (_directory, _path, mut state) = editable_state(); + state.selected_field = PersonaField::Model; + let _ = handle_persona_detail_key( + &mut state, + &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + state.set_editing_text("ab"); + let _ = state.set_editing_cursor_byte(1); + let outcome = handle_persona_detail_paste(&mut state, "中\r\n"); + assert!(matches!(outcome, PersonaDetailOutcome::Changed)); + assert_eq!(state.editing_text(), Some("a中b")); + + let _ = handle_persona_detail_key(&mut state, &KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + let outcome = handle_persona_detail_paste(&mut state, "ignored"); + assert!(matches!(outcome, PersonaDetailOutcome::Unchanged)); +} + +#[test] +fn detail_editor_uses_canonical_graphemes_and_keeps_cursor_visible() { + let (_directory, _path, mut state) = editable_state(); + state.selected_field = PersonaField::Model; + let _ = handle_persona_detail_key( + &mut state, + &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + let grapheme = "👩🏽\u{200d}💻"; + state.set_editing_text(format!("a{grapheme}b")); + let _ = state.set_editing_cursor_byte(1); + let _ = handle_persona_detail_key( + &mut state, + &KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE), + ); + assert_eq!(state.editing_text(), Some("ab")); + + let text = format!("123456中e\u{301}{grapheme}z"); + state.set_editing_text(&text); + let _ = state.set_editing_cursor_byte(text.len() - 1); + + let width = 10usize; + let theme = Theme::current(); + let mut buffer = Buffer::empty(Rect::new(0, 0, width as u16, 1)); + let viewport = state.editing_viewport(width).unwrap(); + let visible = &state.editing_text().unwrap()[viewport.visible_byte_range.clone()]; + assert!(visible.contains('中')); + assert!(visible.contains("e\u{301}")); + assert!(visible.contains(grapheme)); + render_detail_editor( + &mut buffer, + 0, + 0, + width, + state.editing_editor().unwrap(), + Style::default(), + &theme, + ); + let cursor_x = viewport.cursor_display_column as u16; + assert_eq!(buffer[(cursor_x, 0)].bg, theme.text_primary); +} diff --git a/crates/codegen/xai-grok-pager/src/views/picker.rs b/crates/codegen/xai-grok-pager/src/views/picker.rs index d517d00..51b0b07 100644 --- a/crates/codegen/xai-grok-pager/src/views/picker.rs +++ b/crates/codegen/xai-grok-pager/src/views/picker.rs @@ -20,6 +20,7 @@ use std::collections::HashSet; use std::sync::LazyLock; +use crossterm::event::KeyEvent; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::{Color, Modifier, Style}; @@ -27,6 +28,7 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::Widget; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; +use crate::input::line_editor::{LineEditOutcome, LineEditor}; use crate::render::line_utils::truncate_str; use crate::render::wrapping::word_wrap_line; use crate::theme::Theme; @@ -191,6 +193,52 @@ pub fn compute_scroll_offset( // Search bar // --------------------------------------------------------------------------- +const SEARCH_BAR_LABEL: &str = " search: "; +const SEARCH_BAR_TRAILING_GAP: u16 = 1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SearchBarLayout { + render_width: u16, + input_width: usize, + trailing_width: u16, +} + +impl SearchBarLayout { + pub fn input_width(self) -> usize { + self.input_width + } + + pub fn trailing_width(self) -> u16 { + self.trailing_width + } +} + +pub fn search_bar_layout(width: u16, trailing_width: u16) -> SearchBarLayout { + let label_width = (SEARCH_BAR_LABEL.len() as u16).min(width); + let available_input = width - label_width; + let trailing_reserved = if trailing_width > 0 + && available_input + >= trailing_width + .saturating_add(SEARCH_BAR_TRAILING_GAP) + .saturating_add(1) + { + trailing_width.saturating_add(SEARCH_BAR_TRAILING_GAP) + } else { + 0 + }; + let render_width = width - trailing_reserved; + let input_width = render_width.saturating_sub(label_width) as usize; + SearchBarLayout { + render_width, + input_width, + trailing_width: if trailing_reserved == 0 { + 0 + } else { + trailing_width + }, + } +} + /// Render a search bar row: ` search: {query}_` or ` / to search` hint. /// /// - `active`: whether the cursor blinks (search mode is engaged). @@ -217,7 +265,7 @@ pub fn render_search_bar( y, width, theme, - " search: ", + SEARCH_BAR_LABEL, query, active, show_hint, @@ -226,6 +274,134 @@ pub fn render_search_bar( ); } +#[allow(clippy::too_many_arguments)] +pub fn render_search_bar_with_viewport( + buf: &mut Buffer, + x: u16, + y: u16, + layout: SearchBarLayout, + theme: &Theme, + query: &str, + active: bool, + show_hint: bool, + bg: Option, + viewport: xai_ratatui_textarea::SingleLineViewport, +) { + render_search_bar_with_label_viewport( + buf, + x, + y, + layout.render_width, + theme, + SEARCH_BAR_LABEL, + query, + active, + show_hint, + 0, + bg, + Some(viewport), + ); +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn render_picker_search_bar( + buf: &mut Buffer, + x: u16, + y: u16, + width: u16, + theme: &Theme, + state: &PickerState, + active: bool, + show_hint: bool, + bg: Option, +) { + render_line_editor_search_bar(buf, x, y, width, theme, &state.query, active, show_hint, bg); +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn render_line_editor_search_bar( + buf: &mut Buffer, + x: u16, + y: u16, + width: u16, + theme: &Theme, + editor: &LineEditor, + active: bool, + show_hint: bool, + bg: Option, +) { + render_line_editor_search_bar_with_label( + buf, + x, + y, + width, + theme, + SEARCH_BAR_LABEL, + editor, + active, + show_hint, + bg, + ); +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn render_picker_search_bar_with_label( + buf: &mut Buffer, + x: u16, + y: u16, + width: u16, + theme: &Theme, + label: &str, + state: &PickerState, + active: bool, + show_hint: bool, + bg: Option, +) { + render_line_editor_search_bar_with_label( + buf, + x, + y, + width, + theme, + label, + &state.query, + active, + show_hint, + bg, + ); +} + +#[allow(clippy::too_many_arguments)] +fn render_line_editor_search_bar_with_label( + buf: &mut Buffer, + x: u16, + y: u16, + width: u16, + theme: &Theme, + label: &str, + editor: &LineEditor, + active: bool, + show_hint: bool, + bg: Option, +) { + let input_width = width.saturating_sub(label.len() as u16) as usize; + let viewport = editor.viewport(input_width); + render_search_bar_with_label_viewport( + buf, + x, + y, + width, + theme, + label, + editor.text(), + active, + show_hint, + editor.cursor_byte(), + bg, + Some(viewport), + ); +} + /// Like [`render_search_bar`] but with a caller-supplied prompt `label` /// (e.g. `" path: "`) instead of the default `" search: "`. The label /// width is measured in bytes (ASCII), matching the input-window math. @@ -242,6 +418,37 @@ pub fn render_search_bar_with_label( show_hint: bool, query_cursor: usize, bg: Option, +) { + render_search_bar_with_label_viewport( + buf, + x, + y, + width, + theme, + label, + query, + active, + show_hint, + query_cursor, + bg, + None, + ); +} + +#[allow(clippy::too_many_arguments)] +fn render_search_bar_with_label_viewport( + buf: &mut Buffer, + x: u16, + y: u16, + width: u16, + theme: &Theme, + label: &str, + query: &str, + active: bool, + show_hint: bool, + query_cursor: usize, + bg: Option, + viewport: Option, ) { // Minimal mode renders every UI element background-free. let bg = if crate::views::modal_window::embedded() { @@ -267,53 +474,56 @@ pub fn render_search_bar_with_label( ); let input_x = x + label_w; - let input_max = width.saturating_sub(label_w + 1) as usize; - - // Cursor-following window: find the visible slice of the query - // that keeps the cursor in view. When the cursor is at the end - // (the common case), this is equivalent to tail-scroll. - let mut cursor_byte = query_cursor.min(query.len()); - while cursor_byte > 0 && !query.is_char_boundary(cursor_byte) { - cursor_byte -= 1; - } - let prefix_w = query[..cursor_byte].width(); - let (start_byte, cursor_col) = if prefix_w <= input_max { - // Cursor fits from the start — no scrolling needed. - (0, prefix_w) - } else { - // Walk forward from byte 0, accumulating width, until the - // remaining prefix fits within `input_max`. - let mut skip_w = 0usize; - let mut sb = 0usize; - for (idx, ch) in query.char_indices() { - if idx >= cursor_byte { - break; - } - let cw = ch.width().unwrap_or(0); - if prefix_w - skip_w <= input_max { - break; - } - skip_w += cw; - sb = idx + ch.len_utf8(); + let input_width = width.saturating_sub(label_w) as usize; + let cursor_limit = input_width.saturating_sub(1); + let cursor_col = if let Some(viewport) = viewport { + if !query.is_empty() { + let displayed = &query[viewport.visible_byte_range]; + buf.set_span( + input_x, + y, + &Span::styled(displayed, bg_style(Style::default().fg(theme.text_primary))), + displayed.width() as u16, + ); } - (sb, prefix_w - skip_w) + viewport.cursor_display_column + } else { + // Compatibility path for raw-string inputs that have not adopted LineEditor. + let mut cursor_byte = query_cursor.min(query.len()); + while cursor_byte > 0 && !query.is_char_boundary(cursor_byte) { + cursor_byte -= 1; + } + let prefix_width = query[..cursor_byte].width(); + let (start_byte, cursor_col) = if prefix_width <= cursor_limit { + (0, prefix_width) + } else { + let mut skipped_width = 0usize; + let mut start_byte = 0usize; + for (index, character) in query.char_indices() { + if index >= cursor_byte || prefix_width - skipped_width <= cursor_limit { + break; + } + skipped_width += character.width().unwrap_or(0); + start_byte = index + character.len_utf8(); + } + (start_byte, prefix_width - skipped_width) + }; + if !query.is_empty() { + let displayed = truncate_str(&query[start_byte..], cursor_limit); + buf.set_span( + input_x, + y, + &Span::styled( + &displayed, + bg_style(Style::default().fg(theme.text_primary)), + ), + displayed.width() as u16, + ); + } + cursor_col }; - if !query.is_empty() { - let visible = &query[start_byte..]; - let displayed = truncate_str(visible, input_max); - buf.set_span( - input_x, - y, - &Span::styled( - &displayed, - bg_style(Style::default().fg(theme.text_primary)), - ), - displayed.width() as u16, - ); - } - - let cursor_display_w = (cursor_col as u16).min(input_max as u16); + let cursor_display_w = (cursor_col as u16).min(cursor_limit as u16); if active || always_active { let cursor_x = input_x + cursor_display_w; @@ -1323,7 +1533,7 @@ pub fn render_fullscreen_frame( /// /// Fields used by both the `render_picker()` path (welcome screen) and /// the `ModalWindow` + `render_picker_content()` path (modal popups): -/// `selected`, `query`, `search_active`, `expanded`, `hovered`, +/// `selected`, query editor, `search_active`, `expanded`, `hovered`, /// `scroll_offset`, `hit_areas`. /// /// Fields used **only** by the `render_picker()` path (welcome screen): @@ -1334,13 +1544,8 @@ pub fn render_fullscreen_frame( pub struct PickerState { /// Currently selected index in the filtered entries list. pub selected: usize, - /// Search query string. - pub query: String, - /// Byte offset of the editing cursor within `query`. Invariant: - /// always on a char boundary in `[0, query.len()]`. Operations - /// that mutate `query` must keep this in sync (see helper methods - /// on `PickerState`). - pub query_cursor: usize, + /// Canonical single-line search editor. + query: LineEditor, /// Whether the search input is focused (only relevant when `show_search_hint` is true). pub search_active: bool, /// Indices of expanded entries in original data (empty = all collapsed). Caller-managed. @@ -1378,8 +1583,7 @@ impl Default for PickerState { fn default() -> Self { Self { selected: 0, - query: String::new(), - query_cursor: 0, + query: LineEditor::default(), search_active: false, expanded: HashSet::new(), mode: PickerMode::Floating, @@ -1421,8 +1625,7 @@ impl PickerState { /// preserving the display mode and clearing hit areas. pub fn reset(&mut self) { self.selected = 0; - self.query.clear(); - self.query_cursor = 0; + self.query.reset(); self.search_active = false; self.expanded.clear(); self.close_hovered = false; @@ -1439,14 +1642,34 @@ impl PickerState { /// returning to an empty-query view without touching focus flags /// (`tabs_focused`) or hit areas. Used by the vim Esc-to-nav-mode path. pub fn clear_query(&mut self) { - self.query.clear(); - self.query_cursor = 0; + self.query.reset(); self.scroll_offset = None; self.selected = 0; self.selection_hidden = false; self.expanded.clear(); } + pub fn query(&self) -> &str { + self.query.text() + } + + pub fn query_cursor(&self) -> usize { + self.query.cursor_byte() + } + + /// Replace the query, remove line breaks, and place the cursor at text end. + pub fn set_query(&mut self, query: impl Into) { + self.query.set_text(query); + } + + fn edit_query(&mut self, key: &KeyEvent) -> LineEditOutcome { + self.query.handle_key(key) + } + + pub(crate) fn paste_query(&mut self, text: &str) -> LineEditOutcome { + self.query.insert_paste(text) + } + /// When a search query is active on an expandable picker, force-expand /// all entries so that matches inside collapsed groups/trees are /// explicitly visible. Individual items can still be manually collapsed @@ -1519,7 +1742,7 @@ pub struct PickerConfig<'a> { /// Each entry is `(key_char, description)` shown in shortcuts. pub action_keys: &'a [(char, &'a str)], /// If true, suppress the search bar entirely (and any text input into - /// `state.query`). The first content row is replaced by `config.title` + /// `state.query()`). The first content row is replaced by `config.title` /// rendered as a plain title. Useful for read-only cheatsheet modals. pub disable_search: bool, /// If true, render the bottom shortcuts bar using ONLY `config.shortcuts` @@ -1578,10 +1801,12 @@ pub enum PickerOutcome { /// User wants to copy entry at this index (if config.expandable). Copy(usize), /// User pressed Enter with a non-empty query but no matching entries. - /// Caller can use the query string (from `state.query`) to attempt a direct lookup. + /// Caller can use the query string from `state.query()` to attempt a direct lookup. SubmitQuery, - /// Visual state changed, needs redraw. + /// Visual state or query cursor changed; query text is unchanged. Changed, + /// Query text changed; hosts should refresh filtering/search once. + QueryChanged, /// Nothing changed. Unchanged, /// User switched to tab at given index. @@ -1717,16 +1942,15 @@ pub fn render_picker_in_modal_inner( search_active: bool, show_search_hint: bool, ) { - render_search_bar( + render_picker_search_bar( buf, content_area.x, content_area.y, content_area.width, theme, - &state.query, + state, search_active, show_search_hint, - state.query_cursor, Some(theme.bg_base), ); let sep_y = content_area.y + 1; @@ -2156,16 +2380,15 @@ pub fn render_picker( } } else { // Cursor tracks focus (`search_active`) for every picker — like the Settings pane; `show_search_hint` is input-only and no longer forces an always-on cursor. - render_search_bar( + render_picker_search_bar( buf, content.x, content.y, search_width, theme, - &state.query, + state, state.search_active, true, - state.query_cursor, bg, ); } @@ -2295,11 +2518,30 @@ pub fn render_picker( } } -/// Handle input events for the picker. Returns what happened. -/// -/// The caller provides the number of filtered entries for bounds checking. -/// After receiving `Changed`, the caller should re-filter entries based on -/// `state.query` and call `render_picker()` with the updated entries. +/// Clamp selection to a selectable row after a host changes the picker entries. +pub fn clamp_picker_selection( + state: &mut PickerState, + entry_count: usize, + non_selectable: &[bool], +) { + let is_non_sel = |i: usize| non_selectable.get(i).copied().unwrap_or(false); + if entry_count > 0 { + state.selected = state.selected.min(entry_count.saturating_sub(1)); + while is_non_sel(state.selected) && state.selected < entry_count - 1 { + state.selected += 1; + } + if is_non_sel(state.selected) { + state.selected = 0; + while is_non_sel(state.selected) && state.selected < entry_count - 1 { + state.selected += 1; + } + } + } else { + state.selected = 0; + } +} + +/// Handle one picker event; hosts re-filter only after [`PickerOutcome::QueryChanged`]. pub fn handle_picker_input( ev: &crossterm::event::Event, state: &mut PickerState, @@ -2317,22 +2559,7 @@ pub fn handle_picker_input( }; // Clamp selected to valid range — entries may have changed since last input // (e.g., query filter reduced the list). - if entry_count > 0 { - // Clamp selected into valid range first — entries may have shrunk. - state.selected = state.selected.min(entry_count.saturating_sub(1)); - // Skip non-selectable items (e.g., section headers) - while is_non_sel(state.selected) && state.selected < entry_count - 1 { - state.selected += 1; - } - if is_non_sel(state.selected) { - state.selected = 0; - while is_non_sel(state.selected) && state.selected < entry_count - 1 { - state.selected += 1; - } - } - } else { - state.selected = 0; - } + clamp_picker_selection(state, entry_count, config.non_selectable); // Precompute first/last selectable for boundary-aware Up/Down navigation // (search focus at edges, skipping any non-selectable headers). @@ -2458,15 +2685,36 @@ pub fn handle_picker_input( } } - // Helper to deduplicate paste logic between is_paste_key and Event::Paste. - // Also ensures scroll_offset=None on all paste-driven query mutations, for - // consistency with every other query-mutating arm. - // - // Implemented as a local `fn` (not a closure) so we can legitimately use - // `impl AsRef` in argument position (allowed for fn parameters, not - // for closure parameters). This gives us a single, flexible implementation - // that accepts String, &String, &str, etc. without explicit borrows at the - // call sites, avoiding both the type error and any needless_borrow lint. + fn finish_query_edit( + state: &mut PickerState, + outcome: LineEditOutcome, + ) -> Option { + match outcome { + LineEditOutcome::Unhandled => None, + LineEditOutcome::HandledNoChange | LineEditOutcome::CursorChanged => { + Some(PickerOutcome::Changed) + } + LineEditOutcome::TextChanged => { + state.selected = 0; + state.selection_hidden = false; + state.scroll_offset = None; + state.tabs_focused = false; + state.expanded.clear(); + Some(PickerOutcome::QueryChanged) + } + } + } + + fn is_plain_query_character(key: &KeyEvent) -> bool { + matches!(key.code, KeyCode::Char(_)) + && (key.modifiers.is_empty() || key.modifiers == crossterm::event::KeyModifiers::SHIFT) + } + + fn is_legacy_alt_word_key(key: &KeyEvent) -> bool { + matches!(key.code, KeyCode::Char('b' | 'f')) + && key.modifiers == crossterm::event::KeyModifiers::ALT + } + fn handle_paste( state: &mut PickerState, text: impl AsRef, @@ -2477,25 +2725,11 @@ pub fn handle_picker_input( if config.vim_normal_first && !state.search_active { return PickerOutcome::Unchanged; } - let cleaned: String = text - .as_ref() - .chars() - .filter(|c| *c != '\n' && *c != '\r') - .collect(); - if cleaned.is_empty() { - return PickerOutcome::Unchanged; - } - state.query.insert_str(state.query_cursor, &cleaned); - state.query_cursor += cleaned.len(); - if config.show_search_hint { + let outcome = state.paste_query(text.as_ref()); + if outcome == LineEditOutcome::TextChanged && config.show_search_hint { state.search_active = true; } - state.selected = 0; - state.selection_hidden = false; - state.expanded.clear(); - state.scroll_offset = None; - state.tabs_focused = false; - PickerOutcome::Changed + finish_query_edit(state, outcome).unwrap_or(PickerOutcome::Unchanged) } // ── Key handling ── @@ -2515,76 +2749,23 @@ pub fn handle_picker_input( return PickerOutcome::Unchanged; } - // ── Left/Right cursor movement (only when search input is focused) ── - let search_input_active = - !config.disable_search && (state.search_active || !config.show_search_hint); - if search_input_active && !state.query.is_empty() { - if key.code == KeyCode::Left { - if state.query_cursor > 0 { - let new = state.query[..state.query_cursor] - .char_indices() - .next_back() - .map_or(0, |(i, _)| i); - state.query_cursor = new; - } - return PickerOutcome::Changed; - } - if key.code == KeyCode::Right { - if state.query_cursor < state.query.len() { - let rest = &state.query[state.query_cursor..]; - let ch_len = rest.chars().next().map_or(0, |c| c.len_utf8()); - state.query_cursor += ch_len; - } - return PickerOutcome::Changed; - } - } - // Search mode (currently active). Also reachable for vim_normal_first // pickers without a search hint, so typing/Esc/Backspace work once // search is entered via `i`/`/`. if (config.show_search_hint || config.vim_normal_first) && state.search_active { if key.code == KeyCode::Esc { + let query_changed = config.vim_normal_first && !state.query().is_empty(); state.search_active = false; // vim_normal_first: Esc leaves search for nav mode and clears the // query in one step (mirrors scrollback vim-mode). if config.vim_normal_first { state.clear_query(); } - return PickerOutcome::Changed; - } - if key.code == KeyCode::Char('u') - && key - .modifiers - .contains(crossterm::event::KeyModifiers::CONTROL) - { - state.query.clear(); - state.query_cursor = 0; - state.scroll_offset = None; - state.selected = 0; - state.selection_hidden = false; - state.expanded.clear(); - state.tabs_focused = false; - return PickerOutcome::Changed; - } - if key.code == KeyCode::Backspace { - if state.query_cursor > 0 { - let prev = state.query[..state.query_cursor] - .char_indices() - .next_back() - .map_or(0, |(i, _)| i); - state.query.drain(prev..state.query_cursor); - state.query_cursor = prev; - } - state.scroll_offset = None; - state.selected = 0; - state.selection_hidden = false; - state.tabs_focused = false; - if config.expandable && !state.query.is_empty() { - state.expand_all_for_search(entry_count); + return if query_changed { + PickerOutcome::QueryChanged } else { - state.expanded.clear(); - } - return PickerOutcome::Changed; + PickerOutcome::Changed + }; } if let Some(tabs) = config.tabs { let tab_count = tabs.len(); @@ -2628,25 +2809,20 @@ pub fn handle_picker_input( state.scroll_offset = None; return PickerOutcome::Changed; } - } else if let KeyCode::Char(c) = key.code - && !key - .modifiers - .contains(crossterm::event::KeyModifiers::CONTROL) - { - let mut buf = [0u8; 4]; - let s = c.encode_utf8(&mut buf); - state.query.insert_str(state.query_cursor, s); - state.query_cursor += s.len(); - state.scroll_offset = None; - state.selected = 0; - state.selection_hidden = false; - if config.expandable { - state.expand_all_for_search(entry_count); - } else { - state.expanded.clear(); + } + if config.expandable && state.query().is_empty() { + if key.code == KeyCode::Right { + return PickerOutcome::Expand(state.selected); + } + if key.code == KeyCode::Left { + return PickerOutcome::Collapse(state.selected); + } + } + if key.code != KeyCode::Enter { + let outcome = state.edit_query(key); + if let Some(outcome) = finish_query_edit(state, outcome) { + return outcome; } - return PickerOutcome::Changed; - } else { return PickerOutcome::Unchanged; } } @@ -2718,27 +2894,23 @@ pub fn handle_picker_input( } if !config.search_only_on_slash && !config.vim_normal_first - && let KeyCode::Char(c) = key.code - && !key - .modifiers - .contains(crossterm::event::KeyModifiers::CONTROL) + && is_legacy_alt_word_key(key) { - state.tabs_focused = false; - state.search_active = true; - let mut buf = [0u8; 4]; - let s = c.encode_utf8(&mut buf); - state.query.insert_str(state.query_cursor, s); - state.query_cursor += s.len(); - state.scroll_offset = None; - state.selected = 0; - state.selection_hidden = false; - if config.expandable { - state.expand_all_for_search(entry_count); - } else { - state.expanded.clear(); - } return PickerOutcome::Changed; } + if !config.search_only_on_slash + && !config.vim_normal_first + && is_plain_query_character(key) + { + let outcome = state.edit_query(key); + if outcome == LineEditOutcome::TextChanged { + state.tabs_focused = false; + state.search_active = true; + } + if let Some(outcome) = finish_query_edit(state, outcome) { + return outcome; + } + } } // For other keys (including action keys, Esc, etc.) while tabs @@ -2759,15 +2931,10 @@ pub fn handle_picker_input( // Esc. if key.code == KeyCode::Esc { - if config.esc_clears_query && !state.query.is_empty() { - state.query.clear(); - state.query_cursor = 0; - state.scroll_offset = None; - state.selected = 0; - state.selection_hidden = false; - state.expanded.clear(); // back to default collapsed when search ends + if config.esc_clears_query && !state.query().is_empty() { + state.clear_query(); state.tabs_focused = false; - return PickerOutcome::Changed; + return PickerOutcome::QueryChanged; } state.tabs_focused = false; return PickerOutcome::Closed; @@ -2778,7 +2945,7 @@ pub fn handle_picker_input( if entry_count > 0 && !is_non_sel(state.selected) { return PickerOutcome::Selected(state.selected); } - if entry_count == 0 && !state.query.is_empty() { + if entry_count == 0 && !state.query().is_empty() { return PickerOutcome::SubmitQuery; } return PickerOutcome::Changed; @@ -2892,10 +3059,8 @@ pub fn handle_picker_input( if key.code == KeyCode::Char('E') { return PickerOutcome::Collapse(state.selected); } - // Left/Right arrows: collapse/expand when query is empty - // (when query is non-empty, Left/Right handle cursor movement - // earlier in this function and never reach here). - // Only when the list content has focus (not the tabs region). + // Outside search, arrows always act on the selected row; the + // retained query only filters which rows are visible. if key.code == KeyCode::Right { return PickerOutcome::Expand(state.selected); } @@ -2977,101 +3142,46 @@ pub fn handle_picker_input( state.tabs_focused = false; return PickerOutcome::Changed; } - // Always-active search (no hint). Suppressed entirely when the caller - // asked to disable search (e.g. read-only cheatsheet), and under - // vim_normal_first (where typing instead flows through the search-active - // block once `i`/`/` enters search). if !config.show_search_hint && !config.disable_search && !config.vim_normal_first { - if key.code == KeyCode::Char('u') - && key - .modifiers - .contains(crossterm::event::KeyModifiers::CONTROL) - { - state.query.clear(); - state.query_cursor = 0; - state.scroll_offset = None; - state.selected = 0; - state.expanded.clear(); - state.tabs_focused = false; - return PickerOutcome::Changed; - } - if key.code == KeyCode::Backspace { - if state.query_cursor > 0 { - let prev = state.query[..state.query_cursor] - .char_indices() - .next_back() - .map_or(0, |(i, _)| i); - state.query.drain(prev..state.query_cursor); - state.query_cursor = prev; - } - state.scroll_offset = None; - state.selected = 0; - state.expanded.clear(); - state.tabs_focused = false; - return PickerOutcome::Changed; - } - // `/` activates search instead of self-inserting when it can't - // plausibly be query text. The "/ to search" placeholder renders - // exactly while `!search_active` with an empty query, even for - // always-active pickers (e.g. the `/docs` how-to picker), so - // this condition mirrors the renderer's: the advertised chord - // must not type a literal `/` into the query. A `/` typed - // mid-query (non-empty) or while the search bar is already - // focused (`input_active()` pickers, the dashboard location - // picker's leading-`/` paths) still inserts, so path-like - // queries keep working. if key.code == KeyCode::Char('/') && key.modifiers.is_empty() - && state.query.is_empty() + && state.query().is_empty() && !state.search_active { state.search_active = true; state.tabs_focused = false; return PickerOutcome::Changed; } - if let KeyCode::Char(c) = key.code - && (key.modifiers.is_empty() - || key.modifiers == crossterm::event::KeyModifiers::SHIFT) - { - let mut buf = [0u8; 4]; - let s = c.encode_utf8(&mut buf); - state.query.insert_str(state.query_cursor, s); - state.query_cursor += s.len(); - state.scroll_offset = None; - state.selected = 0; - state.expanded.clear(); - state.tabs_focused = false; - return PickerOutcome::Changed; + let outcome = state.edit_query(key); + if let Some(outcome) = finish_query_edit(state, outcome) { + return outcome; } } - // Hint-based search, not active. if config.show_search_hint && !state.search_active { if key.code == KeyCode::Char('/') && key.modifiers.is_empty() { state.search_active = true; return PickerOutcome::Changed; } - // Auto-activate search on any printable char — opt-out via - // `search_only_on_slash` for tabs where letters are action - // keys (e.g. extensions modal Skills tab), and via - // `vim_normal_first` where bare letters never type. if !config.search_only_on_slash && !config.vim_normal_first - && let KeyCode::Char(c) = key.code - && !key - .modifiers - .contains(crossterm::event::KeyModifiers::CONTROL) + && is_legacy_alt_word_key(key) { - state.search_active = true; - state.tabs_focused = false; - let mut buf = [0u8; 4]; - let s = c.encode_utf8(&mut buf); - state.query.insert_str(state.query_cursor, s); - state.query_cursor += s.len(); - state.selected = 0; - state.expanded.clear(); return PickerOutcome::Changed; } + if !config.search_only_on_slash + && !config.vim_normal_first + && is_plain_query_character(key) + { + let outcome = state.edit_query(key); + if outcome == LineEditOutcome::TextChanged { + state.search_active = true; + state.tabs_focused = false; + } + if let Some(outcome) = finish_query_edit(state, outcome) { + return outcome; + } + } } return PickerOutcome::Unchanged; // unhandled key — no state change @@ -3138,8 +3248,8 @@ mod tests { let config = cfg(false, false); let mut state = PickerState::default(); let outcome = handle_picker_input(&press('a'), &mut state, 3, &config); - assert!(matches!(outcome, PickerOutcome::Changed)); - assert_eq!(state.query, "a"); + assert!(matches!(outcome, PickerOutcome::QueryChanged)); + assert_eq!(state.query(), "a"); } #[test] @@ -3148,9 +3258,9 @@ mod tests { let config = cfg(true, false); let mut state = PickerState::default(); let outcome = handle_picker_input(&press('a'), &mut state, 3, &config); - assert!(matches!(outcome, PickerOutcome::Changed)); + assert!(matches!(outcome, PickerOutcome::QueryChanged)); assert!(state.search_active); - assert_eq!(state.query, "a"); + assert_eq!(state.query(), "a"); } #[test] @@ -3163,11 +3273,11 @@ mod tests { let outcome = handle_picker_input(&press('/'), &mut state, 3, &config); assert!(matches!(outcome, PickerOutcome::Changed)); assert!(state.search_active); - assert!(state.query.is_empty()); + assert!(state.query().is_empty()); // Typing after the activation chord filters normally. let outcome = handle_picker_input(&press('t'), &mut state, 3, &config); - assert!(matches!(outcome, PickerOutcome::Changed)); - assert_eq!(state.query, "t"); + assert!(matches!(outcome, PickerOutcome::QueryChanged)); + assert_eq!(state.query(), "t"); } #[test] @@ -3178,10 +3288,10 @@ mod tests { let mut state = PickerState::default(); handle_picker_input(&press('a'), &mut state, 3, &config); handle_picker_input(&press('b'), &mut state, 3, &config); - assert_eq!(state.query, "ab"); + assert_eq!(state.query(), "ab"); let outcome = handle_picker_input(&press('/'), &mut state, 3, &config); - assert!(matches!(outcome, PickerOutcome::Changed)); - assert_eq!(state.query, "ab/"); + assert!(matches!(outcome, PickerOutcome::QueryChanged)); + assert_eq!(state.query(), "ab/"); assert!(!state.search_active); } @@ -3194,8 +3304,8 @@ mod tests { let config = cfg(false, false); let mut state = PickerState::input_active(); let outcome = handle_picker_input(&press('/'), &mut state, 3, &config); - assert!(matches!(outcome, PickerOutcome::Changed)); - assert_eq!(state.query, "/"); + assert!(matches!(outcome, PickerOutcome::QueryChanged)); + assert_eq!(state.query(), "/"); } #[test] @@ -3206,10 +3316,10 @@ mod tests { let outcome = handle_picker_input(&press('/'), &mut state, 3, &config); assert!(matches!(outcome, PickerOutcome::Changed)); assert!(state.search_active); - assert!(state.query.is_empty()); + assert!(state.query().is_empty()); let outcome = handle_picker_input(&press('t'), &mut state, 3, &config); - assert!(matches!(outcome, PickerOutcome::Changed)); - assert_eq!(state.query, "t"); + assert!(matches!(outcome, PickerOutcome::QueryChanged)); + assert_eq!(state.query(), "t"); } #[test] @@ -3261,6 +3371,111 @@ mod tests { ); } + #[test] + fn viewport_search_bar_reserves_counter_without_text_or_cursor_overlap() { + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + + let theme = Theme::current(); + let width = 20u16; + let counter_width = "12/34".width() as u16; + let layout = search_bar_layout(width, counter_width); + assert_eq!(layout.input_width(), 5); + assert_eq!(layout.trailing_width(), counter_width); + + let mut editor = LineEditor::default(); + editor.set_text("123456789中e\u{301}👩🏽\u{200d}💻z"); + let viewport = editor.viewport(layout.input_width()); + let mut buffer = Buffer::empty(Rect::new(0, 0, width, 1)); + buffer.set_string(0, 0, "#".repeat(width as usize), Style::default()); + render_search_bar_with_viewport( + &mut buffer, + 0, + 0, + layout, + &theme, + editor.text(), + true, + false, + None, + viewport, + ); + + for x in layout.render_width..width { + assert_eq!( + buffer[(x, 0)].symbol(), + "#", + "reserved counter cell {x} was overwritten", + ); + } + let cursor_x = (0..layout.render_width) + .find(|x| buffer[(*x, 0)].bg == theme.text_primary) + .expect("cursor inside search render width"); + assert!(cursor_x < layout.render_width); + } + + #[test] + fn narrow_search_bar_omits_real_counters_to_preserve_caret_cell() { + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + + let theme = Theme::current(); + for counter in ["no matches", "bad pattern", "12/34"] { + let counter_width = counter.width() as u16; + let width = SEARCH_BAR_LABEL.len() as u16 + counter_width + SEARCH_BAR_TRAILING_GAP; + let layout = search_bar_layout(width, counter_width); + assert_eq!( + layout.trailing_width(), + 0, + "{counter:?} must be omitted without one editor cell", + ); + assert!(layout.input_width() >= 1); + + let mut editor = LineEditor::default(); + editor.set_text("long-query-中e\u{301}👩🏽\u{200d}💻"); + let viewport = editor.viewport(layout.input_width()); + let mut buffer = Buffer::empty(Rect::new(0, 0, width, 1)); + render_search_bar_with_viewport( + &mut buffer, + 0, + 0, + layout, + &theme, + editor.text(), + true, + false, + None, + viewport, + ); + let cursor_x = (0..width) + .find(|x| buffer[(*x, 0)].bg == theme.text_primary) + .expect("active query keeps a visible caret"); + assert!(cursor_x < width); + + let fit_layout = search_bar_layout(width + 1, counter_width); + assert_eq!(fit_layout.trailing_width(), counter_width); + assert_eq!(fit_layout.input_width(), 1); + let viewport = editor.viewport(fit_layout.input_width()); + let mut fit_buffer = Buffer::empty(Rect::new(0, 0, width + 1, 1)); + render_search_bar_with_viewport( + &mut fit_buffer, + 0, + 0, + fit_layout, + &theme, + editor.text(), + true, + false, + None, + viewport, + ); + let cursor_x = (0..fit_layout.render_width) + .find(|x| fit_buffer[(*x, 0)].bg == theme.text_primary) + .expect("just-fit counter preserves one caret cell"); + assert!(cursor_x < fit_layout.render_width); + } + } + #[test] fn underline_last_desc_underlines_only_the_link_line() { use ratatui::buffer::Buffer; @@ -3337,7 +3552,7 @@ mod tests { let config = cfg(hint, true); let mut state = PickerState::default(); let outcome = handle_picker_input(&press('a'), &mut state, 3, &config); - assert!(state.query.is_empty(), "hint={hint}"); + assert!(state.query().is_empty(), "hint={hint}"); assert!(!state.search_active, "hint={hint}"); assert!(matches!(outcome, PickerOutcome::Unchanged), "hint={hint}"); } @@ -3351,7 +3566,7 @@ mod tests { let outcome = handle_picker_input(&press('i'), &mut state, 3, &config); assert!(matches!(outcome, PickerOutcome::Changed), "hint={hint}"); assert!(state.search_active, "hint={hint}"); - assert!(state.query.is_empty(), "hint={hint}"); + assert!(state.query().is_empty(), "hint={hint}"); } } @@ -3364,6 +3579,69 @@ mod tests { assert!(!PickerState::default().search_active); } + #[test] + fn expandable_picker_routes_arrows_by_focus() { + let mut config = cfg(true, false); + config.expandable = true; + + let mut search = PickerState::input_active(); + assert!(matches!( + handle_picker_input( + &Event::Key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)), + &mut search, + 3, + &config, + ), + PickerOutcome::Expand(0) + )); + + search.set_query("match"); + let outcome = handle_picker_input(&press_esc(), &mut search, 3, &config); + assert!(matches!(outcome, PickerOutcome::Changed)); + assert!(!search.search_active); + assert!(matches!( + handle_picker_input( + &Event::Key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)), + &mut search, + 3, + &config, + ), + PickerOutcome::Collapse(0) + )); + } + + #[test] + fn first_hint_search_edit_leaves_expansion_to_the_host() { + let mut config = cfg(true, false); + config.expandable = true; + let mut state = PickerState::default(); + + let outcome = handle_picker_input(&press('a'), &mut state, 3, &config); + assert!(matches!(outcome, PickerOutcome::QueryChanged)); + assert!(state.expanded.is_empty()); + } + + #[test] + fn set_query_sanitizes_before_filtering_and_places_cursor_at_end() { + let mut state = PickerState::default(); + state.set_query("alpha\r\nbeta\n"); + assert_eq!(state.query(), "alphabeta"); + assert_eq!(state.query_cursor(), state.query().len()); + + let visible = ["alpha", "alphabeta", "beta"] + .into_iter() + .filter(|candidate| candidate.contains(state.query())) + .collect::>(); + assert_eq!(visible, vec!["alphabeta"]); + } + + #[test] + fn clamp_selection_skips_group_headers() { + let mut state = PickerState::default(); + clamp_picker_selection(&mut state, 3, &[true, false, false]); + assert_eq!(state.selected, 1); + } + #[test] fn vim_char_types_after_entering_search() { for hint in [true, false] { @@ -3371,8 +3649,11 @@ mod tests { let mut state = PickerState::default(); handle_picker_input(&press('i'), &mut state, 3, &config); let outcome = handle_picker_input(&press('a'), &mut state, 3, &config); - assert!(matches!(outcome, PickerOutcome::Changed), "hint={hint}"); - assert_eq!(state.query, "a", "hint={hint}"); + assert!( + matches!(outcome, PickerOutcome::QueryChanged), + "hint={hint}" + ); + assert_eq!(state.query(), "a", "hint={hint}"); } } @@ -3383,11 +3664,14 @@ mod tests { let mut state = PickerState::default(); handle_picker_input(&press('i'), &mut state, 3, &config); handle_picker_input(&press('a'), &mut state, 3, &config); - assert_eq!(state.query, "a", "hint={hint}"); + assert_eq!(state.query(), "a", "hint={hint}"); let outcome = handle_picker_input(&press_esc(), &mut state, 3, &config); - assert!(!matches!(outcome, PickerOutcome::Closed), "hint={hint}"); + assert!( + matches!(outcome, PickerOutcome::QueryChanged), + "hint={hint}" + ); assert!(!state.search_active, "hint={hint}"); - assert!(state.query.is_empty(), "hint={hint}"); + assert!(state.query().is_empty(), "hint={hint}"); } } @@ -3452,7 +3736,7 @@ mod tests { let outcome = handle_picker_input(&press('/'), &mut state, 3, &config); assert!(matches!(outcome, PickerOutcome::Changed), "hint={hint}"); assert!(state.search_active, "hint={hint}"); - assert!(state.query.is_empty(), "hint={hint}"); + assert!(state.query().is_empty(), "hint={hint}"); } } @@ -3468,6 +3752,18 @@ mod tests { assert!(!state.search_active); } + #[test] + fn paste_search_leaves_expansion_to_the_host() { + let mut config = cfg(true, false); + config.expandable = true; + let mut state = PickerState::default(); + + let outcome = + handle_picker_input(&Event::Paste("needle".to_string()), &mut state, 3, &config); + assert!(matches!(outcome, PickerOutcome::QueryChanged)); + assert!(state.expanded.is_empty()); + } + #[test] fn vim_paste_suppressed_when_not_searching() { for hint in [true, false] { @@ -3475,7 +3771,7 @@ mod tests { let mut state = PickerState::default(); let outcome = handle_picker_input(&Event::Paste("hello".to_string()), &mut state, 3, &config); - assert!(state.query.is_empty(), "hint={hint}"); + assert!(state.query().is_empty(), "hint={hint}"); assert!(matches!(outcome, PickerOutcome::Unchanged), "hint={hint}"); } } @@ -3487,8 +3783,8 @@ mod tests { let mut state = PickerState::default(); handle_picker_input(&press('i'), &mut state, 3, &config); let outcome = handle_picker_input(&Event::Paste("hi".to_string()), &mut state, 3, &config); - assert!(matches!(outcome, PickerOutcome::Changed)); - assert_eq!(state.query, "hi"); + assert!(matches!(outcome, PickerOutcome::QueryChanged)); + assert_eq!(state.query(), "hi"); } #[test] @@ -3501,7 +3797,7 @@ mod tests { let mut state = PickerState::default(); let outcome = handle_picker_input(&press(c), &mut state, 3, &config); assert!(!state.search_active, "c={c}"); - assert!(state.query.is_empty(), "c={c}"); + assert!(state.query().is_empty(), "c={c}"); assert!(matches!(outcome, PickerOutcome::Unchanged), "c={c}"); } } @@ -3518,7 +3814,7 @@ mod tests { assert!(!state.tabs_focused); // A bare printable char does not type while in nav mode. let outcome = handle_picker_input(&press('a'), &mut state, 3, &config); - assert!(state.query.is_empty()); + assert!(state.query().is_empty()); assert!(matches!(outcome, PickerOutcome::Unchanged)); } @@ -3538,4 +3834,165 @@ mod tests { assert!(!state.search_active); assert!(!state.tabs_focused); } + + #[test] + fn query_cursor_edits_do_not_reset_list_state() { + let config = cfg(false, false); + let mut state = PickerState::default(); + state.set_query("alpha-beta"); + state.selected = 2; + state.expanded.insert(1); + state.scroll_offset = Some(4); + + let left_word = Event::Key(KeyEvent::new(KeyCode::Left, KeyModifiers::ALT)); + let outcome = handle_picker_input(&left_word, &mut state, 3, &config); + assert!(matches!(outcome, PickerOutcome::Changed)); + assert_eq!(state.query(), "alpha-beta"); + assert_eq!(state.query_cursor(), "alpha-".len()); + assert_eq!(state.selected, 2); + assert_eq!(state.expanded, HashSet::from([1])); + assert_eq!(state.scroll_offset, Some(4)); + + let mut empty = PickerState { + selected: 2, + ..PickerState::default() + }; + let outcome = handle_picker_input( + &Event::Key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)), + &mut empty, + 3, + &config, + ); + assert!(matches!(outcome, PickerOutcome::Changed)); + assert_eq!(empty.selected, 2); + } + + #[test] + fn query_text_edits_reset_list_state_once() { + let config = cfg(false, false); + let mut state = PickerState::default(); + state.set_query("alpha-beta"); + state.selected = 2; + state.expanded.insert(1); + state.scroll_offset = Some(4); + + let outcome = handle_picker_input( + &Event::Key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT)), + &mut state, + 3, + &config, + ); + assert!(matches!(outcome, PickerOutcome::QueryChanged)); + assert_eq!(state.query(), "alpha-"); + assert_eq!(state.selected, 0); + assert!(state.expanded.is_empty()); + assert_eq!(state.scroll_offset, None); + } + + #[test] + fn canonical_alt_words_work_in_normal_and_vim_input() { + let mut state = PickerState::default(); + state.set_query("alpha-beta"); + let config = cfg(false, false); + for (character, cursor) in [('b', "alpha-".len()), ('f', "alpha-beta".len())] { + let outcome = handle_picker_input( + &Event::Key(KeyEvent::new(KeyCode::Char(character), KeyModifiers::ALT)), + &mut state, + 3, + &config, + ); + assert!(matches!(outcome, PickerOutcome::Changed)); + assert_eq!(state.query_cursor(), cursor); + } + + let config = cfg(true, true); + let mut vim_state = PickerState::default(); + handle_picker_input(&press('i'), &mut vim_state, 3, &config); + vim_state.set_query("alpha-beta"); + let outcome = handle_picker_input( + &Event::Key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT)), + &mut vim_state, + 3, + &config, + ); + assert!(matches!(outcome, PickerOutcome::QueryChanged)); + assert_eq!(vim_state.query(), "alpha-"); + + let mut hinted = PickerState::default(); + let hinted_config = cfg(true, false); + let outcome = handle_picker_input( + &Event::Key(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::ALT)), + &mut hinted, + 3, + &hinted_config, + ); + assert!(matches!(outcome, PickerOutcome::Changed)); + assert!(hinted.query().is_empty()); + assert!(!hinted.search_active); + + let ctrl_w = Event::Key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL)); + let mut navigation = PickerState::default(); + assert!(matches!( + handle_picker_input(&ctrl_w, &mut navigation, 3, &hinted_config), + PickerOutcome::Unchanged + )); + navigation.search_active = true; + assert!(matches!( + handle_picker_input(&ctrl_w, &mut navigation, 3, &hinted_config), + PickerOutcome::Changed + )); + } + + #[test] + fn picker_graphemes_paste_and_viewport_use_line_editor() { + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + + let config = cfg(false, false); + let grapheme = "👩🏽\u{200d}💻"; + let mut state = PickerState::default(); + state.set_query(format!("a{grapheme}b")); + handle_picker_input( + &Event::Key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)), + &mut state, + 3, + &config, + ); + let viewport = state.query.viewport(3); + assert_eq!( + &state.query()[viewport.visible_byte_range.clone()], + format!("{grapheme}b") + ); + assert_eq!(viewport.cursor_display_column, 2); + let area = Rect::new(0, 0, 12, 1); + let mut buffer = Buffer::empty(area); + let theme = Theme::current(); + render_picker_search_bar( + &mut buffer, + area.x, + area.y, + area.width, + &theme, + &state, + true, + false, + None, + ); + assert_eq!( + buffer.cell((11, 0)).expect("cursor cell").bg, + theme.text_primary + ); + + handle_picker_input( + &Event::Key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)), + &mut state, + 3, + &config, + ); + assert_eq!(state.query(), "ab"); + let outcome = + handle_picker_input(&Event::Paste("x\r\ny".to_owned()), &mut state, 3, &config); + assert!(matches!(outcome, PickerOutcome::QueryChanged)); + assert_eq!(state.query(), "axyb"); + } } diff --git a/crates/codegen/xai-grok-pager/src/views/queue_pane.rs b/crates/codegen/xai-grok-pager/src/views/queue_pane.rs index 94038c3..51be427 100644 --- a/crates/codegen/xai-grok-pager/src/views/queue_pane.rs +++ b/crates/codegen/xai-grok-pager/src/views/queue_pane.rs @@ -616,6 +616,10 @@ impl QueuePane { self.list_state.handle_key_event(key, &self.entries) } + pub fn handle_paste(&mut self, text: &str) -> bool { + self.list_state.handle_paste(text, &self.entries) + } + /// Get the stable ID of the currently selected entry, if any. pub fn selected_id(&self) -> Option { self.list_state.selected_id() @@ -1028,6 +1032,18 @@ mod tests { QueuedPrompt::plain(id, text, QueueEntryKind::Prompt) } + #[test] + fn paste_routes_to_active_list_input() { + let mut pane = QueuePane::new(); + let mut local = std::collections::VecDeque::new(); + local.push_back(local_prompt(1, "first")); + pane.sync_from_merged(&local, &[], None, None, &Default::default()); + pane.list_state.open_comment_input(""); + + assert!(pane.handle_paste("queued text")); + assert_eq!(pane.list_state.input_text(), "queued text"); + } + #[test] fn reset_auto_show_edge_allows_requeue_after_external_hide() { let mut pane = QueuePane::new(); diff --git a/crates/codegen/xai-grok-pager/src/views/session_picker.rs b/crates/codegen/xai-grok-pager/src/views/session_picker.rs index c7fa6f9..1550405 100644 --- a/crates/codegen/xai-grok-pager/src/views/session_picker.rs +++ b/crates/codegen/xai-grok-pager/src/views/session_picker.rs @@ -389,12 +389,25 @@ pub(crate) fn build_virtual_list( items } -/// Build a position-indexed entry map for the session picker. -/// -/// Each element is `Some(item)` for selectable rows or `None` for -/// non-selectable headers. When `grouped` is true, repo-group headers -/// are interleaved so indices match what the renderer stores in hit areas. -/// `current_repo` pins the matching repo group to the top of the list. +/// Rebuild expansion keys in the backing-data index space used by session rendering. +pub(crate) fn expand_all_mapped_session_items( + state: &mut PickerState, + entry_map: &[Option], +) { + state.expanded.clear(); + if state.query().is_empty() { + return; + } + for item in entry_map.iter().flatten() { + let key = match item { + PickerItem::Fuzzy { original_index } => *original_index, + PickerItem::Content { hit_index } => CONTENT_EXPAND_OFFSET + hit_index, + }; + state.expanded.insert(key); + } +} + +/// Build the position-indexed session map, including non-selectable headers. pub(crate) fn build_entry_map( entries: Option<&[SessionPickerEntry]>, content_results: Option<&[xai_grok_shell::extensions::session_search::SearchSessionHit]>, @@ -489,6 +502,77 @@ pub(crate) fn build_entry_map( } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum SessionPickerWorktreeSelection { + Fuzzy(usize), + Content { session_id: String, cwd: String }, + Unavailable, +} + +/// Resolve Ctrl+W before generic editing because the line editor binds it to delete-word. +pub(crate) fn session_picker_worktree_selection( + key: &crossterm::event::KeyEvent, + state: &mut PickerState, + entry_map: &[Option], + non_selectable: &[bool], + entries: Option<&[SessionPickerEntry]>, + content_results: Option<&[xai_grok_shell::extensions::session_search::SearchSessionHit]>, +) -> Option { + if key.kind != crossterm::event::KeyEventKind::Press || !crate::key!('w', CONTROL).matches(key) + { + return None; + } + if entry_map.is_empty() { + return Some(SessionPickerWorktreeSelection::Unavailable); + } + crate::views::picker::clamp_picker_selection(state, entry_map.len(), non_selectable); + Some( + match entry_map + .get(state.selected) + .and_then(|entry| entry.as_ref()) + { + Some(PickerItem::Fuzzy { original_index }) => entries + .and_then(|entries| entries.get(*original_index)) + .filter(|entry| !crate::app::is_foreign_picker_source(&entry.source)) + .map_or(SessionPickerWorktreeSelection::Unavailable, |_| { + SessionPickerWorktreeSelection::Fuzzy(*original_index) + }), + Some(PickerItem::Content { hit_index }) => content_results + .and_then(|results| results.get(*hit_index)) + .map_or(SessionPickerWorktreeSelection::Unavailable, |hit| { + SessionPickerWorktreeSelection::Content { + session_id: hit.session_id.clone(), + cwd: hit.cwd.clone(), + } + }), + None => SessionPickerWorktreeSelection::Unavailable, + }, + ) +} + +/// Rebuild backing-index expansion after a session query changes. +pub(crate) fn sync_session_picker_query_expansion( + entries: Option<&[SessionPickerEntry]>, + content_results: Option<&[xai_grok_shell::extensions::session_search::SearchSessionHit]>, + entries_query: Option<&str>, + state: &mut PickerState, + grouped: bool, + content_loading: bool, + source_filter: SourceFilter, + current_repo: Option<&str>, +) { + let entry_map = build_entry_map( + entries, + content_results, + effective_filter_query(state.query(), entries_query), + grouped, + content_loading, + source_filter, + current_repo, + ); + expand_all_mapped_session_items(state, &entry_map); +} + // --------------------------------------------------------------------------- // Session entry data building // --------------------------------------------------------------------------- @@ -1096,6 +1180,28 @@ mod tests { assert!(matches!(map[3], Some(PickerItem::Content { hit_index: 0 }))); } + #[test] + fn expand_all_mapped_session_items_uses_backing_indices() { + let entries = vec![make_entry("zero", "repo-a"), make_entry("needle", "repo-b")]; + let hits = vec![make_content_hit("content")]; + let map = build_entry_map( + Some(&entries), + Some(&hits), + "needle", + true, + false, + SourceFilter::All, + None, + ); + let mut state = PickerState::default(); + state.set_query("needle"); + + expand_all_mapped_session_items(&mut state, &map); + + assert_eq!(state.expanded, HashSet::from([1, CONTENT_EXPAND_OFFSET]),); + assert!(!state.expanded.contains(&0), "group header is not an item"); + } + #[test] fn foreign_id_does_not_suppress_native_content_result() { let mut foreign = make_entry("shared", "repo"); diff --git a/crates/codegen/xai-grok-pager/src/views/settings_modal/input.rs b/crates/codegen/xai-grok-pager/src/views/settings_modal/input.rs index 16f063a..373ddb7 100644 --- a/crates/codegen/xai-grok-pager/src/views/settings_modal/input.rs +++ b/crates/codegen/xai-grok-pager/src/views/settings_modal/input.rs @@ -5,12 +5,15 @@ use ratatui::layout::Rect; use super::render::int_step_sizes; use super::state::{ - RowEntry, SettingsKeyOutcome, SettingsModalMode, SettingsModalState, action_for_bool, - action_for_enum, action_for_enum_commit, action_for_int, action_for_string, - effective_enum_choices, group_children, validate_int, validate_string, + RowEntry, SettingsKeyOutcome, SettingsModalState, SettingsMode, SettingsModeKind, + action_for_bool, action_for_enum, action_for_enum_commit, action_for_int, action_for_string, + effective_enum_choices, group_children, validate_string, }; use crate::app::actions::Action; -use crate::settings::{SettingKey, SettingKind, SettingValue, dynamic_enum_choices}; +use crate::input::line_editor::LineEditOutcome; +use crate::settings::{ + SettingKey, SettingKind, SettingValue, StringValidator, dynamic_enum_choices, +}; // --------------------------------------------------------------------------- // Key handling @@ -38,22 +41,50 @@ pub fn handle_settings_key(state: &mut SettingsModalState, key: &KeyEvent) -> Se } // Exhaustive per-mode dispatch. - match state.mode { - SettingsModalMode::Browse => handle_browse(state, key), - SettingsModalMode::FilterFocused => handle_filter_focused(state, key), - SettingsModalMode::PickingEnum { .. } => handle_picking_enum(state, key), - SettingsModalMode::PickingGroup { .. } => handle_picking_group(state, key), - SettingsModalMode::EditingValue { .. } => handle_editing_value(state, key), + match state.state.mode_kind() { + SettingsModeKind::Browse => handle_browse(state, key), + SettingsModeKind::FilterFocused => handle_filter_focused(state, key), + SettingsModeKind::PickingEnum => handle_picking_enum(state, key), + SettingsModeKind::PickingGroup => handle_picking_group(state, key), + SettingsModeKind::EditingString | SettingsModeKind::EditingInt => { + handle_editing_value(state, key) + } + } +} + +pub fn handle_settings_paste(state: &mut SettingsModalState, text: &str) -> SettingsKeyOutcome { + match state.state.mode_kind() { + SettingsModeKind::FilterFocused => { + let outcome = state.state.filter.insert_paste(text); + apply_filter_edit(state, outcome) + } + SettingsModeKind::EditingString => { + let (validator, outcome) = { + let SettingsMode::EditingString { + editor, validator, .. + } = &mut state.state.mode + else { + unreachable!("mode kind changed before paste") + }; + ( + *validator, + editor.insert_paste_with_policy(text, safe_settings_char, usize::MAX), + ) + }; + apply_string_edit(state, validator, outcome) + } + SettingsModeKind::Browse + | SettingsModeKind::PickingEnum + | SettingsModeKind::PickingGroup + | SettingsModeKind::EditingInt => SettingsKeyOutcome::Unchanged, } } /// Enum chooser key routing. Up/Down dispatches preview actions, /// Enter commits current choice, Esc reverts to original value. fn handle_picking_enum(state: &mut SettingsModalState, key: &KeyEvent) -> SettingsKeyOutcome { - // Snapshot the current picker state under an immutable borrow so - // the subsequent `state.mode = ...` writes are unambiguous. - let (setting_key, choices_idx, original_value, supports_preview) = match &state.mode { - SettingsModalMode::PickingEnum { + let (setting_key, choices_idx, original_value, supports_preview) = match &state.state.mode { + SettingsMode::PickingEnum { key, choices_idx, original_value, @@ -64,7 +95,7 @@ fn handle_picking_enum(state: &mut SettingsModalState, key: &KeyEvent) -> Settin original_value.clone(), *supports_preview, ), - _ => return SettingsKeyOutcome::Unchanged, + _ => unreachable!("picker handler requires PickingEnum state"), }; match key.code { @@ -170,9 +201,9 @@ fn handle_picking_enum(state: &mut SettingsModalState, key: &KeyEvent) -> Settin /// Space/Enter toggles the focused child in place (the sheet stays open); /// Esc returns to Browse. fn handle_picking_group(state: &mut SettingsModalState, key: &KeyEvent) -> SettingsKeyOutcome { - let (group_key, child_idx) = match &state.mode { - SettingsModalMode::PickingGroup { key, child_idx } => (*key, *child_idx), - _ => return SettingsKeyOutcome::Unchanged, + let (group_key, child_idx) = match &state.state.mode { + SettingsMode::PickingGroup { key, child_idx } => (*key, *child_idx), + _ => unreachable!("group handler requires PickingGroup state"), }; let children = group_children(state, group_key); if children.is_empty() { @@ -186,20 +217,14 @@ fn handle_picking_group(state: &mut SettingsModalState, key: &KeyEvent) -> Setti if child_idx + 1 >= children.len() { return SettingsKeyOutcome::Unchanged; } - state.mode = SettingsModalMode::PickingGroup { - key: group_key, - child_idx: child_idx + 1, - }; + state.transition_to_picking_group(group_key, child_idx + 1); SettingsKeyOutcome::Changed } KeyCode::Up | KeyCode::Char('k') => { if child_idx == 0 { return SettingsKeyOutcome::Unchanged; } - state.mode = SettingsModalMode::PickingGroup { - key: group_key, - child_idx: child_idx - 1, - }; + state.transition_to_picking_group(group_key, child_idx - 1); SettingsKeyOutcome::Changed } // Space/Enter toggle the focused child Bool and stay in the sheet so the @@ -245,12 +270,7 @@ pub(super) fn set_picker_idx( // for refactor safety. return SettingsKeyOutcome::Unchanged; } - state.mode = SettingsModalMode::PickingEnum { - key: setting_key, - choices_idx: new_idx, - supports_preview, - original_value, - }; + state.transition_to_picking_enum(setting_key, new_idx, original_value, supports_preview); // Preview dispatch for static Enums with preview support. if supports_preview && let Some(new_canonical) = picker_choice_at(state, setting_key, new_idx) @@ -265,157 +285,112 @@ pub(super) fn set_picker_idx( /// String mode: free-form text with cursor. Int mode: range-aware stepper /// (Up/Down small, Left/Right large; see [`int_step_sizes`]), clamped to [min,max]. fn handle_editing_value(state: &mut SettingsModalState, key: &KeyEvent) -> SettingsKeyOutcome { - // Snapshot mode payload under an immutable borrow. - let (setting_key, buffer, cursor_byte, validation_error) = match &state.mode { - SettingsModalMode::EditingValue { - key, - buffer, - cursor_byte, - validation_error, - } => (*key, buffer.clone(), *cursor_byte, validation_error.clone()), - _ => return SettingsKeyOutcome::Unchanged, - }; - - // Look up the registered kind so we know how to handle this - // edit. The lookup is `&self`-only. - let Some(meta) = state.registry.find(setting_key) else { - // Registry skew — log and exit. The CI guards catch this. - tracing::error!( - target: "settings", - key = setting_key, - "EditingValue mode references an unregistered key — exiting to Browse", - ); - state.transition_to_browse(); - return SettingsKeyOutcome::Changed; - }; - let kind_snapshot = meta.kind.clone(); - // Int settings dispatch through a stepper-only // handler. All char-input / cursor-pan / Backspace / Delete / // Home / End keys are rejected; only Up/Down/Left/Right (and // j/k/h/l aliases), Enter, and Esc do anything. - if matches!(kind_snapshot, SettingKind::Int { .. }) { - return handle_int_stepper(state, key, setting_key, &buffer, &kind_snapshot); + if let SettingsMode::EditingInt { + key: setting_key, + buffer, + min, + max, + } = &state.state.mode + { + let setting_key = *setting_key; + let buffer = buffer.clone(); + return handle_int_stepper(state, key, setting_key, &buffer, *min, *max); } - match key.code { - KeyCode::Esc => { - state.transition_to_browse(); - SettingsKeyOutcome::Changed - } - KeyCode::Enter => { - // Commit gate: re-validate against the current buffer. - // On failure, refresh the inline error and stay in - // EditingValue. - let error = match &kind_snapshot { - SettingKind::String { validator, .. } => { - validate_string(*validator, &buffer, &state.pager_snapshot.available_models) - } - _ => return SettingsKeyOutcome::Unchanged, + let (setting_key, validator) = match &state.state.mode { + SettingsMode::EditingString { key, validator, .. } => (*key, *validator), + _ => unreachable!("editing handler requires String or Int state"), + }; + + if key.code == KeyCode::Enter { + let SettingsMode::EditingString { editor, .. } = &state.state.mode else { + unreachable!("String editor state changed during commit"); + }; + let text = editor.text().to_owned(); + let error = validate_string(validator, &text, &state.pager_snapshot.available_models); + if error.is_some() { + let SettingsMode::EditingString { + validation_error, .. + } = &mut state.state.mode + else { + unreachable!("String editor state changed during validation"); }; - if error.is_some() { - update_editing_value_buffer(state, buffer, cursor_byte, error); - return SettingsKeyOutcome::Unchanged; + *validation_error = error; + return SettingsKeyOutcome::Unchanged; + } + let action = action_for_string(setting_key, text, &state.pager_snapshot); + state.transition_to_browse(); + return match action { + Some(action) => SettingsKeyOutcome::Action(action), + None => { + tracing::error!( + target: "settings", + key = setting_key, + "EditingValue commit has no action_for_string arm — registry skew", + ); + SettingsKeyOutcome::Changed } - // Dispatch the typed Action and transition to Browse. - let action_opt = match &kind_snapshot { - SettingKind::String { .. } => { - action_for_string(setting_key, buffer.clone(), &state.pager_snapshot) - } - _ => None, + }; + } + + if key.code == KeyCode::Esc { + state.transition_to_browse(); + return SettingsKeyOutcome::Changed; + } + + if matches!( + key.code, + KeyCode::Up + | KeyCode::Down + | KeyCode::PageUp + | KeyCode::PageDown + | KeyCode::Tab + | KeyCode::BackTab + ) { + return SettingsKeyOutcome::Unchanged; + } + + let outcome = { + let SettingsMode::EditingString { editor, .. } = &mut state.state.mode else { + unreachable!("String editor state changed before key handling"); + }; + editor.handle_key_with_insert_policy(key, safe_settings_char) + }; + apply_string_edit(state, validator, outcome) +} + +fn apply_string_edit( + state: &mut SettingsModalState, + validator: StringValidator, + outcome: LineEditOutcome, +) -> SettingsKeyOutcome { + match outcome { + LineEditOutcome::TextChanged => { + let SettingsMode::EditingString { editor, .. } = &state.state.mode else { + unreachable!("String editor state changed after text mutation"); }; - state.transition_to_browse(); - match action_opt { - Some(action) => SettingsKeyOutcome::Action(action), - None => { - tracing::error!( - target: "settings", - key = setting_key, - "EditingValue commit has no action_for_string arm — registry skew", - ); - SettingsKeyOutcome::Changed - } - } - } - KeyCode::Backspace => { - if cursor_byte == 0 { - return SettingsKeyOutcome::Unchanged; - } - let mut new_buf = buffer.clone(); - // Find the prev char boundary. - let prev = (0..cursor_byte) - .rev() - .find(|&i| new_buf.is_char_boundary(i)) - .unwrap_or(0); - new_buf.replace_range(prev..cursor_byte, ""); - let new_cursor = prev; - let new_error = recompute_validation(&kind_snapshot, &new_buf, state); - update_editing_value_buffer(state, new_buf, new_cursor, new_error); - SettingsKeyOutcome::Changed - } - KeyCode::Delete => { - if cursor_byte >= buffer.len() { - return SettingsKeyOutcome::Unchanged; - } - let mut new_buf = buffer.clone(); - // Find next char boundary. - let next = (cursor_byte + 1..=new_buf.len()) - .find(|&i| new_buf.is_char_boundary(i)) - .unwrap_or(new_buf.len()); - new_buf.replace_range(cursor_byte..next, ""); - let new_error = recompute_validation(&kind_snapshot, &new_buf, state); - update_editing_value_buffer(state, new_buf, cursor_byte, new_error); - SettingsKeyOutcome::Changed - } - KeyCode::Left => { - if cursor_byte == 0 { - return SettingsKeyOutcome::Unchanged; - } - let prev = (0..cursor_byte) - .rev() - .find(|&i| buffer.is_char_boundary(i)) - .unwrap_or(0); - update_editing_value_buffer(state, buffer, prev, validation_error); - SettingsKeyOutcome::Changed - } - KeyCode::Right => { - if cursor_byte >= buffer.len() { - return SettingsKeyOutcome::Unchanged; - } - let next = (cursor_byte + 1..=buffer.len()) - .find(|&i| buffer.is_char_boundary(i)) - .unwrap_or(buffer.len()); - update_editing_value_buffer(state, buffer, next, validation_error); - SettingsKeyOutcome::Changed - } - KeyCode::Home => { - update_editing_value_buffer(state, buffer, 0, validation_error); - SettingsKeyOutcome::Changed - } - KeyCode::End => { - let end = buffer.len(); - update_editing_value_buffer(state, buffer, end, validation_error); - SettingsKeyOutcome::Changed - } - KeyCode::Char(c) if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT => { - // Defense-in-depth for the (currently unused) String editor path: - // reject control + bidi/format chars so a future `String` setting - // can't reintroduce the Trojan-Source surface. - let accept = match &kind_snapshot { - SettingKind::String { .. } => !crate::render::line_utils::is_unsafe_display_char(c), - _ => false, + let error = validate_string( + validator, + editor.text(), + &state.pager_snapshot.available_models, + ); + let SettingsMode::EditingString { + validation_error, .. + } = &mut state.state.mode + else { + unreachable!("String editor state changed during validation"); }; - if !accept { - return SettingsKeyOutcome::Unchanged; - } - let mut new_buf = buffer.clone(); - new_buf.insert(cursor_byte, c); - let new_cursor = cursor_byte + c.len_utf8(); - let new_error = recompute_validation(&kind_snapshot, &new_buf, state); - update_editing_value_buffer(state, new_buf, new_cursor, new_error); + *validation_error = error; SettingsKeyOutcome::Changed } - _ => SettingsKeyOutcome::Unchanged, + LineEditOutcome::HandledNoChange | LineEditOutcome::CursorChanged => { + SettingsKeyOutcome::Changed + } + LineEditOutcome::Unhandled => SettingsKeyOutcome::Unchanged, } } @@ -427,22 +402,18 @@ fn handle_int_stepper( key: &KeyEvent, setting_key: SettingKey, buffer: &str, - kind: &SettingKind, + min: i64, + max: i64, ) -> SettingsKeyOutcome { - let SettingKind::Int { min, max, .. } = kind else { - // Caller pre-checked the kind; defensive bail. - return SettingsKeyOutcome::Unchanged; - }; - - let (small_step, large_step) = int_step_sizes(*min, *max); + let (small_step, large_step) = int_step_sizes(min, max); let step_delta = |dir: i64, large: bool| -> i64 { let magnitude = if large { large_step } else { small_step }; dir * magnitude }; let apply_step = |state: &mut SettingsModalState, delta: i64| -> SettingsKeyOutcome { - let cur = buffer.parse::().unwrap_or(*min); - let new = cur.saturating_add(delta).clamp(*min, *max); + let cur = buffer.parse::().unwrap_or(min); + let new = cur.saturating_add(delta).clamp(min, max); if new == cur { // Already clamped — no visible change. Report // Unchanged so the test for `clamps_to_min/max` can @@ -450,8 +421,7 @@ fn handle_int_stepper( return SettingsKeyOutcome::Unchanged; } let new_buf = new.to_string(); - let new_cursor = new_buf.len(); - update_editing_value_buffer(state, new_buf, new_cursor, None); + update_int_buffer(state, new_buf); SettingsKeyOutcome::Changed }; @@ -514,43 +484,11 @@ fn handle_int_stepper( } } -/// Helper: rewrite the EditingValue mode payload with new buffer + -/// cursor + validation. Centralised so future variants don't need -/// to repeat the pattern-construction boilerplate. -fn update_editing_value_buffer( - state: &mut SettingsModalState, - buffer: String, - cursor_byte: usize, - validation_error: Option, -) { - let SettingsModalMode::EditingValue { key, .. } = state.mode else { - // Caller-provided key was lost on mode shift; this is the - // belt-and-suspenders fallback for a future refactor. - return; +fn update_int_buffer(state: &mut SettingsModalState, new_buffer: String) { + let SettingsMode::EditingInt { buffer, .. } = &mut state.state.mode else { + unreachable!("Int update requires EditingInt state"); }; - state.mode = SettingsModalMode::EditingValue { - key, - buffer, - cursor_byte, - validation_error, - }; -} - -/// Helper: recompute the validation error for the current buffer -/// against the registered validator. Called on every buffer mutation -/// so the inline error indicator stays in sync. -fn recompute_validation( - kind: &SettingKind, - buffer: &str, - state: &SettingsModalState, -) -> Option { - match kind { - SettingKind::String { validator, .. } => { - validate_string(*validator, buffer, &state.pager_snapshot.available_models) - } - SettingKind::Int { min, max, .. } => validate_int(buffer, *min, *max), - _ => None, - } + *buffer = new_buffer; } /// Number of choices for the picker. Handles both @@ -740,16 +678,30 @@ fn handle_browse(state: &mut SettingsModalState, key: &KeyEvent) -> SettingsKeyO } SettingsKeyOutcome::Unchanged } - KeyCode::Char(' ') | KeyCode::Enter => { + KeyCode::Char(' ') => { + if let Some(action) = state.toggle_focused_bool() { + SettingsKeyOutcome::Action(action) + } else { + SettingsKeyOutcome::Unchanged + } + } + KeyCode::Enter => { + // Group row → open its sub-sheet of child toggles. if state.try_enter_picking_group() { return SettingsKeyOutcome::Changed; } + // For Bool, Enter behaves like Space (the keyboard + // map gives both keys the toggle semantics). if let Some(action) = state.toggle_focused_bool() { return SettingsKeyOutcome::Action(action); } + // Enum row → enter PickingEnum mode. The picker's chooser + // sub-pane takes over rendering and key routing from here. if state.try_enter_picking_enum() { return SettingsKeyOutcome::Changed; } + // String / Int row → enter EditingValue mode. The + // inline editor takes over rendering and key routing. if state.try_enter_editing_value() { return SettingsKeyOutcome::Changed; } @@ -757,7 +709,7 @@ fn handle_browse(state: &mut SettingsModalState, key: &KeyEvent) -> SettingsKeyO } // `i` aliases `/` (vim-nav "press i to search"). KeyCode::Char('/') | KeyCode::Char('i') if key.modifiers.is_empty() => { - state.mode = SettingsModalMode::FilterFocused; + state.focus_filter(); SettingsKeyOutcome::Changed } KeyCode::Char('d') if key.modifiers.is_empty() => { @@ -788,19 +740,12 @@ fn handle_browse(state: &mut SettingsModalState, key: &KeyEvent) -> SettingsKeyO } } KeyCode::Backspace => { - // Continue editing the query from Browse mode (the commit - // path via Enter preserves the query, so Browse can be - // entered with a non-empty query). Pop one char and - // re-broaden the filter without switching modes. Mirrors - // `memory_modal::handle_browse`'s Backspace arm. - if state.query.pop().is_some() { - state.query_cursor = state.query.len(); - state.invalidate_filter(); - state.clamp_selected_to_visible(); - SettingsKeyOutcome::Changed - } else { - SettingsKeyOutcome::Unchanged + // Continue editing a committed query without refocusing the filter. + if state.query().is_empty() { + return SettingsKeyOutcome::Unchanged; } + let outcome = state.state.filter.delete_last_grapheme(); + apply_filter_edit(state, outcome) } _ => SettingsKeyOutcome::Unchanged, } @@ -809,10 +754,11 @@ fn handle_browse(state: &mut SettingsModalState, key: &KeyEvent) -> SettingsKeyO fn handle_filter_focused(state: &mut SettingsModalState, key: &KeyEvent) -> SettingsKeyOutcome { match key.code { KeyCode::Esc => { - state.query.clear(); - state.query_cursor = 0; - state.invalidate_filter(); - state.clamp_selected_to_visible(); + if !state.query().is_empty() { + state.state.filter.reset(); + state.invalidate_filter(); + state.clamp_selected_to_visible(); + } state.transition_to_browse(); SettingsKeyOutcome::Changed } @@ -843,59 +789,48 @@ fn handle_filter_focused(state: &mut SettingsModalState, key: &KeyEvent) -> Sett } changed_if(moved) } + KeyCode::Tab => SettingsKeyOutcome::Unchanged, KeyCode::Char('u') if key.modifiers == KeyModifiers::CONTROL => { - // Clears entire query (not cursor-to-start) to match picker behavior. - if state.query.is_empty() { - return SettingsKeyOutcome::Unchanged; + if !state.query().is_empty() { + state.state.filter.reset(); + state.invalidate_filter(); + state.clamp_selected_to_visible(); } - state.query.clear(); - state.query_cursor = 0; + SettingsKeyOutcome::Changed + } + _ => { + let outcome = state + .state + .filter + .handle_key_with_insert_policy(key, safe_settings_char); + apply_filter_edit(state, outcome) + } + } +} + +fn safe_settings_char(character: char) -> bool { + !crate::render::line_utils::is_unsafe_display_char(character) +} + +#[cfg(test)] +pub(super) fn set_filter_cursor(state: &mut SettingsModalState, cursor_byte: usize) { + let _ = state.state.filter.set_cursor_byte(cursor_byte); +} + +fn apply_filter_edit( + state: &mut SettingsModalState, + outcome: LineEditOutcome, +) -> SettingsKeyOutcome { + match outcome { + LineEditOutcome::TextChanged => { state.invalidate_filter(); state.clamp_selected_to_visible(); SettingsKeyOutcome::Changed } - KeyCode::Char(c) if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT => { - state.query.insert(state.query_cursor, c); - state.query_cursor += c.len_utf8(); - state.invalidate_filter(); - state.clamp_selected_to_visible(); + LineEditOutcome::HandledNoChange | LineEditOutcome::CursorChanged => { SettingsKeyOutcome::Changed } - KeyCode::Backspace => { - if state.query_cursor == 0 { - return SettingsKeyOutcome::Unchanged; - } - let prev = state.query[..state.query_cursor] - .char_indices() - .next_back() - .map_or(0, |(i, _)| i); - state.query.drain(prev..state.query_cursor); - state.query_cursor = prev; - state.invalidate_filter(); - state.clamp_selected_to_visible(); - SettingsKeyOutcome::Changed - } - KeyCode::Left => { - if state.query_cursor == 0 { - return SettingsKeyOutcome::Unchanged; - } - state.query_cursor = state.query[..state.query_cursor] - .char_indices() - .next_back() - .map_or(0, |(i, _)| i); - SettingsKeyOutcome::Changed - } - KeyCode::Right => { - if state.query_cursor >= state.query.len() { - return SettingsKeyOutcome::Unchanged; - } - state.query_cursor = state.query[state.query_cursor..] - .char_indices() - .nth(1) - .map_or(state.query.len(), |(i, _)| state.query_cursor + i); - SettingsKeyOutcome::Changed - } - _ => SettingsKeyOutcome::Unchanged, + LineEditOutcome::Unhandled => SettingsKeyOutcome::Unchanged, } } @@ -937,14 +872,14 @@ pub fn handle_settings_mouse( && rect_contains(rect, column, row) { let synthetic = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE); - match state.mode { - SettingsModalMode::PickingEnum { .. } => { + match state.state.mode_kind() { + SettingsModeKind::PickingEnum => { return handle_picking_enum(state, &synthetic); } - SettingsModalMode::PickingGroup { .. } => { + SettingsModeKind::PickingGroup => { return handle_picking_group(state, &synthetic); } - SettingsModalMode::EditingValue { .. } => { + SettingsModeKind::EditingString | SettingsModeKind::EditingInt => { return handle_editing_value(state, &synthetic); } _ => {} @@ -981,21 +916,24 @@ pub fn handle_settings_mouse( // when in EditingValue mode AND the row is an Int. All other // events in EditingValue (scrolls, off-adornment clicks) are // no-ops. - if matches!(state.mode, SettingsModalMode::EditingValue { .. }) { + if matches!( + state.state.mode_kind(), + SettingsModeKind::EditingString | SettingsModeKind::EditingInt + ) { let outcome = handle_editor_mouse(state, kind, column, row); return upgrade_if_breadcrumb_flipped(outcome, breadcrumb_hover_flipped); } // PickingEnum: click-to-pick on choice rects, scroll wheel is a // no-op (the picker is bounded; scroll there could surprise). - if matches!(state.mode, SettingsModalMode::PickingEnum { .. }) { + if state.state.mode_kind() == SettingsModeKind::PickingEnum { let outcome = handle_picker_mouse(state, kind, column, row); return upgrade_if_breadcrumb_flipped(outcome, breadcrumb_hover_flipped); } // PickingGroup: hover tracks the child rects; a click toggles the clicked // child in place (same bounded-viewport, scroll-is-a-no-op contract). - if matches!(state.mode, SettingsModalMode::PickingGroup { .. }) { + if state.state.mode_kind() == SettingsModeKind::PickingGroup { let outcome = handle_group_mouse(state, kind, column, row); return upgrade_if_breadcrumb_flipped(outcome, breadcrumb_hover_flipped); } @@ -1147,8 +1085,7 @@ fn handle_picker_mouse( ) -> SettingsKeyOutcome { // Hover highlight for picker choices. Tracks the // choice index under the cursor in `state.hover_row` (same - // field as the row-list path; the field is mode-aware via the - // active `state.mode`). + // field as the row-list path; the field is mode-aware). if matches!(kind, MouseEventKind::Moved) { let new_hover = state .picker_choice_rects @@ -1164,9 +1101,9 @@ fn handle_picker_mouse( let MouseEventKind::Down(crossterm::event::MouseButton::Left) = kind else { return SettingsKeyOutcome::Unchanged; }; - // Snapshot the picker payload under the immutable borrow. - let (setting_key, current_idx, original_value, supports_preview) = match &state.mode { - SettingsModalMode::PickingEnum { + // Snapshot the picker payload before mutating the state. + let (setting_key, current_idx, original_value, supports_preview) = match &state.state.mode { + SettingsMode::PickingEnum { key, choices_idx, original_value, @@ -1177,7 +1114,7 @@ fn handle_picker_mouse( original_value.clone(), *supports_preview, ), - _ => return SettingsKeyOutcome::Unchanged, + _ => unreachable!("picker mouse handler requires PickingEnum state"), }; let clicked_idx = state .picker_choice_rects @@ -1229,9 +1166,9 @@ fn handle_group_mouse( let MouseEventKind::Down(crossterm::event::MouseButton::Left) = kind else { return SettingsKeyOutcome::Unchanged; }; - let group_key = match &state.mode { - SettingsModalMode::PickingGroup { key, .. } => *key, - _ => return SettingsKeyOutcome::Unchanged, + let group_key = match &state.state.mode { + SettingsMode::PickingGroup { key, .. } => *key, + _ => unreachable!("group mouse handler requires PickingGroup state"), }; let children = group_children(state, group_key); let clicked_idx = state @@ -1241,10 +1178,7 @@ fn handle_group_mouse( let Some(idx) = clicked_idx else { return SettingsKeyOutcome::Unchanged; }; - state.mode = SettingsModalMode::PickingGroup { - key: group_key, - child_idx: idx, - }; + state.transition_to_picking_group(group_key, idx); let Some(child_key) = children.get(idx).copied() else { return SettingsKeyOutcome::Changed; }; @@ -1305,5 +1239,3 @@ fn rect_contains(r: Rect, column: u16, row: u16) -> bool { && row >= r.y && row < r.y.saturating_add(r.height) } - -// --------------------------------------------------------------------------- diff --git a/crates/codegen/xai-grok-pager/src/views/settings_modal/mod.rs b/crates/codegen/xai-grok-pager/src/views/settings_modal/mod.rs index e345cf8..5d1b177 100644 --- a/crates/codegen/xai-grok-pager/src/views/settings_modal/mod.rs +++ b/crates/codegen/xai-grok-pager/src/views/settings_modal/mod.rs @@ -30,7 +30,7 @@ mod state; #[cfg(test)] mod tests; -pub use input::{handle_settings_key, handle_settings_mouse}; +pub use input::{handle_settings_key, handle_settings_mouse, handle_settings_paste}; pub use render::{ResetConfirmOverlay, render_settings_modal}; #[allow(unused_imports)] // re-export for crate path; used by settings/registry tests pub(crate) use state::MAX_PICKER_CHOICES; diff --git a/crates/codegen/xai-grok-pager/src/views/settings_modal/render.rs b/crates/codegen/xai-grok-pager/src/views/settings_modal/render.rs index de89fc3..0a34337 100644 --- a/crates/codegen/xai-grok-pager/src/views/settings_modal/render.rs +++ b/crates/codegen/xai-grok-pager/src/views/settings_modal/render.rs @@ -8,8 +8,8 @@ use unicode_width::UnicodeWidthStr; use super::state::{ CONTENT_MIN_WIDTH, MAX_THOUGHTS_WIDTH_WIDENED_MARGIN, MODAL_TITLE, RowEntry, - STANDARD_MAX_WIDTH, SettingsModalMode, SettingsModalState, TITLE_LEADING_DECORATION_W, - effective_enum_choices, group_children, + STANDARD_MAX_WIDTH, SettingsModalState, SettingsMode, SettingsModeKind, + TITLE_LEADING_DECORATION_W, effective_enum_choices, group_children, }; use crate::render::line_utils::truncate_str; use crate::settings::{ @@ -61,8 +61,8 @@ pub fn render_settings_modal( ); &breadcrumb_owned } else { - match &state.mode { - SettingsModalMode::PickingEnum { key, .. } => { + match &state.state.mode { + SettingsMode::PickingEnum { key, .. } => { if let Some(meta) = state.registry.find(key) { breadcrumb_owned = format!("{MODAL_TITLE} {} {}", crate::glyphs::chevron(), meta.label); @@ -72,7 +72,7 @@ pub fn render_settings_modal( } } - SettingsModalMode::EditingValue { key, .. } => { + SettingsMode::EditingString { key, .. } | SettingsMode::EditingInt { key, .. } => { if let Some(meta) = state.registry.find(key) { breadcrumb_owned = format!("{MODAL_TITLE} {} {}", crate::glyphs::chevron(), meta.label); @@ -81,7 +81,7 @@ pub fn render_settings_modal( MODAL_TITLE } } - SettingsModalMode::PickingGroup { key, .. } => { + SettingsMode::PickingGroup { key, .. } => { if let Some(meta) = state.registry.find(key) { breadcrumb_owned = format!("{MODAL_TITLE} {} {}", crate::glyphs::chevron(), meta.label); @@ -99,8 +99,8 @@ pub fn render_settings_modal( // docs footer. Widen the modal when editing `max_thoughts_width` // so the wrap preview is useful at widths above STANDARD_MAX_WIDTH. let widen_for_max_thoughts_width = matches!( - &state.mode, - SettingsModalMode::EditingValue { key, .. } + &state.state.mode, + SettingsMode::EditingInt { key, .. } if *key == crate::settings::defs::MAX_THOUGHTS_WIDTH_KEY ); let widened_candidate = full_area @@ -122,7 +122,10 @@ pub fn render_settings_modal( footer_lines: 2, } .with_compact(compact); - let has_tip_footer = !matches!(state.mode, SettingsModalMode::EditingValue { .. }); + let has_tip_footer = !matches!( + state.state.mode_kind(), + SettingsModeKind::EditingString | SettingsModeKind::EditingInt + ); let footer_lines = if has_tip_footer { modal_window::footer_lines_with_tip_gap(full_area, &sizing, shortcuts) } else { @@ -165,34 +168,35 @@ pub fn render_settings_modal( return true; } - let (inner_area, docs_footer_area) = match state.mode { - SettingsModalMode::EditingValue { .. } => (content_area, None), + let (inner_area, docs_footer_area) = match state.state.mode_kind() { + SettingsModeKind::EditingString | SettingsModeKind::EditingInt => (content_area, None), _ => modal_window::split_content_for_tip_footer(content_area), }; // Per-mode render dispatch (exhaustive to catch new variants). let mode_is_sub_pane = matches!( - state.mode, - SettingsModalMode::PickingEnum { .. } - | SettingsModalMode::PickingGroup { .. } - | SettingsModalMode::EditingValue { .. } + state.state.mode_kind(), + SettingsModeKind::PickingEnum + | SettingsModeKind::PickingGroup + | SettingsModeKind::EditingString + | SettingsModeKind::EditingInt ); - match state.mode { - SettingsModalMode::PickingEnum { .. } => { + match state.state.mode_kind() { + SettingsModeKind::PickingEnum => { state.reset_hit_rects(); render_picking_enum(buf, inner_area, state, &theme); state.picker_choice_rects = take_picker_choice_rects(); } - SettingsModalMode::PickingGroup { .. } => { + SettingsModeKind::PickingGroup => { state.reset_hit_rects(); let rects = render_picking_group(buf, inner_area, state, &theme); state.picker_choice_rects = rects; } - SettingsModalMode::EditingValue { .. } => { + SettingsModeKind::EditingString | SettingsModeKind::EditingInt => { state.reset_hit_rects(); render_editing_value(buf, inner_area, state, &theme); } - SettingsModalMode::Browse | SettingsModalMode::FilterFocused => { + SettingsModeKind::Browse | SettingsModeKind::FilterFocused => { // Clear sub-pane hit-rects from prior frames. state.picker_choice_rects.clear(); state.editor_adornment_rects = (Rect::default(), Rect::default()); @@ -351,13 +355,13 @@ fn build_reset_confirm_shortcuts() -> Vec> { } /// Render the row list with a search bar at the top (Browse/FilterFocused). -fn render_row_list_with_search_bar( +pub(super) fn render_row_list_with_search_bar( buf: &mut Buffer, content_area: Rect, state: &mut SettingsModalState, theme: &Theme, ) { - let filter_focused = matches!(state.mode, SettingsModalMode::FilterFocused); + let filter_focused = state.state.mode_kind() == SettingsModeKind::FilterFocused; if content_area.height >= 3 { // row 0: search bar, row 1: divider, row 2+: list. let search_area = Rect { @@ -366,16 +370,15 @@ fn render_row_list_with_search_bar( width: content_area.width, height: 1, }; - crate::views::picker::render_search_bar( + crate::views::picker::render_line_editor_search_bar( buf, search_area.x, search_area.y, search_area.width, theme, - &state.query, + &state.state.filter, filter_focused, true, - state.query_cursor, Some(theme.bg_base), ); crate::views::picker::render_divider( @@ -403,16 +406,15 @@ fn render_row_list_with_search_bar( width: content_area.width, height: 1, }; - crate::views::picker::render_search_bar( + crate::views::picker::render_line_editor_search_bar( buf, search_area.x, search_area.y, search_area.width, theme, - &state.query, + &state.state.filter, filter_focused, true, - state.query_cursor, Some(theme.bg_base), ); let list_area = Rect { @@ -465,16 +467,16 @@ pub(super) fn render_rows( // Empty filter — show "No matches for ". if total_visible == 0 { - if !state.query.is_empty() { + if !state.query().is_empty() { let prefix = "No matches for "; let suffix_quote_w = 2u16; // surrounding "" chars let available_for_query = (area.width as usize) .saturating_sub(prefix.width()) .saturating_sub(suffix_quote_w as usize); - let q_disp = if state.query.width() <= available_for_query { - state.query.clone() + let q_disp = if state.query().width() <= available_for_query { + state.query().to_owned() } else { - truncate_str(&state.query, available_for_query) + truncate_str(state.query(), available_for_query) }; let msg = format!("{prefix}\"{q_disp}\""); let style = Style::default().fg(theme.gray_dim).bg(theme.bg_base); @@ -970,11 +972,11 @@ pub(super) fn render_picking_enum( "PICKER_SEPARATOR_W drifted from PICKER_SEPARATOR width", ); - let (setting_key, choices_idx) = match &state.mode { - SettingsModalMode::PickingEnum { + let (setting_key, choices_idx) = match &state.state.mode { + SettingsMode::PickingEnum { key, choices_idx, .. } => (*key, *choices_idx), - _ => return, + _ => unreachable!("picker renderer requires PickingEnum state"), }; let Some(meta) = state.registry.find(setting_key) else { return; @@ -1274,9 +1276,9 @@ fn render_picking_group( state: &SettingsModalState, theme: &Theme, ) -> Vec { - let (group_key, child_idx) = match &state.mode { - SettingsModalMode::PickingGroup { key, child_idx } => (*key, *child_idx), - _ => return Vec::new(), + let (group_key, child_idx) = match &state.state.mode { + SettingsMode::PickingGroup { key, child_idx } => (*key, *child_idx), + _ => unreachable!("group renderer requires PickingGroup state"), }; let Some(group_meta) = state.registry.find(group_key) else { return Vec::new(); @@ -1570,32 +1572,14 @@ pub(super) fn render_editing_value( state.editor_adornment_rects = (Rect::default(), Rect::default()); - // Snapshot mode payload to avoid borrow conflicts with mut state. - let (setting_key, buffer_owned, cursor_byte, validation_error_owned, kind_is_int) = { - let (setting_key, buffer, cursor_byte, validation_error) = match &state.mode { - SettingsModalMode::EditingValue { - key, - buffer, - cursor_byte, - validation_error, - } => (*key, buffer.clone(), *cursor_byte, validation_error.clone()), - _ => return, - }; - let kind_is_int = state - .registry - .find(setting_key) - .map(|m| matches!(m.kind, SettingKind::Int { .. })) - .unwrap_or(false); - ( - setting_key, - buffer, - cursor_byte, - validation_error, - kind_is_int, - ) - }; - - if kind_is_int { + if let SettingsMode::EditingInt { + key: setting_key, + buffer, + .. + } = &state.state.mode + { + let setting_key = *setting_key; + let buffer = buffer.clone(); let Some(meta) = state.registry.find(setting_key) else { return; }; @@ -1609,14 +1593,24 @@ pub(super) fn render_editing_value( setting_key, label, description, - &buffer_owned, + &buffer, theme, ); return; } - let buffer = buffer_owned.as_str(); - let validation_error = validation_error_owned.as_deref(); + let SettingsMode::EditingString { + key: setting_key, + editor, + validation_error, + .. + } = &state.state.mode + else { + unreachable!("editor renderer requires String or Int state"); + }; + let setting_key = *setting_key; + let buffer = editor.text(); + let validation_error = validation_error.as_deref(); let Some(meta) = state.registry.find(setting_key) else { return; }; @@ -1700,56 +1694,18 @@ pub(super) fn render_editing_value( 1, ); } else { - // Cursor-following pan. - let cursor_col = buffer[..cursor_byte.min(buffer.len())].width(); - let buffer_w = buffer.width(); - let view_offset = if buffer_w <= visible_buffer_w { - 0 - } else if cursor_col >= visible_buffer_w { - cursor_col + 1 - visible_buffer_w - } else { - // Cursor fits within the first window; no scroll. - 0 - }; - - let start_byte = if view_offset == 0 { - 0 - } else { - let mut acc = 0usize; - buffer - .char_indices() - .find_map(|(idx, ch)| { - if acc >= view_offset { - Some(idx) - } else { - acc += unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0); - None - } - }) - .unwrap_or(buffer.len()) - }; - - // Render the visible tail. - let tail = &buffer[start_byte..]; - // The visible portion may still be wider than the room when - // a wide grapheme straddles the right boundary; cap with - // `truncate_str` defensively. - let tail_text: std::borrow::Cow<'_, str> = if tail.width() <= visible_buffer_w { - std::borrow::Cow::Borrowed(tail) - } else { - std::borrow::Cow::Owned(truncate_str(tail, visible_buffer_w)) - }; - let tail_w = (tail_text.width() as u16).min(visible_buffer_w as u16); + let viewport = editor.viewport(buffer_room); + let visible = &buffer[viewport.visible_byte_range]; + let visible_width = (visible.width() as u16).min(buffer_room as u16); buf.set_span( input_x, input_y, - &Span::styled(tail_text.as_ref(), input_style), - tail_w, + &Span::styled(visible, input_style), + visible_width, ); - // Cursor lands at the logical column relative to the view. - let cursor_visual_col = cursor_col.saturating_sub(view_offset); - let cursor_x = input_x + (cursor_visual_col as u16).min(buffer_room as u16 - 1); + let cursor_x = + input_x + (viewport.cursor_display_column as u16).min(buffer_room as u16 - 1); buf.set_span( cursor_x, input_y, @@ -2355,12 +2311,14 @@ pub(super) fn render_setting_row( is_hovered: bool, ) -> Rect { let bg = settings_list_row_bg(theme, is_selected, is_hovered); + // Paint the row bg across the full area (1 or 2 lines). buf.set_style(area, Style::default().bg(bg)); let mut label_style = Style::default().fg(theme.text_primary).bg(bg); if is_selected { label_style = label_style.add_modifier(Modifier::BOLD); } + // Bool(false) renders muted; all other values use accent. let value_style = Style::default().fg(theme.accent_user).bg(bg); let chevron_style = Style::default().fg(theme.gray).bg(bg); let restart_style = Style::default() @@ -2755,10 +2713,14 @@ fn render_setting_group_row( } } -/// Browse footer is fixed (same wrap height on every focused row kind). +/// Build the footer shortcut row. Enter label varies by focused row kind. pub(super) fn build_shortcuts(state: &SettingsModalState) -> Vec> { - match state.mode { - SettingsModalMode::Browse => { + match &state.state.mode { + SettingsMode::Browse => { + let enter_label = match state.focused_setting() { + Some((_, meta)) if matches!(meta.kind, SettingKind::Bool { .. }) => "Enter toggle", + _ => "Enter edit", + }; let mut shortcuts = vec![ Shortcut { label: "\u{2191}/\u{2193}/j/k nav", @@ -2771,7 +2733,12 @@ pub(super) fn build_shortcuts(state: &SettingsModalState) -> Vec Vec vec![ + SettingsMode::FilterFocused => vec![ Shortcut { label: "type to filter", clickable: false, @@ -2828,17 +2795,17 @@ pub(super) fn build_shortcuts(state: &SettingsModalState) -> Vec { // Labels depend on whether the Enum supports live preview. - let nav_label = if sp { + let nav_label = if *sp { "\u{2191}/\u{2193} try" } else { "\u{2191}/\u{2193} nav" }; - let esc_label = if sp { "Esc revert" } else { "Esc cancel" }; + let esc_label = if *sp { "Esc revert" } else { "Esc cancel" }; vec![ Shortcut { label: nav_label, @@ -2863,48 +2830,16 @@ pub(super) fn build_shortcuts(state: &SettingsModalState) -> Vec { - // Int stepper: step-only hints with range-aware deltas. - if let Some(SettingKind::Int { min, max, .. }) = - state.registry.find(key).map(|m| &m.kind) - { - let (small_label, large_label) = int_step_footer_labels(*min, *max); - return vec![ - Shortcut { - label: small_label, - clickable: false, - id: 0, - }, - Shortcut { - label: large_label, - clickable: false, - id: 0, - }, - Shortcut { - label: "Enter commit", - clickable: false, - id: 0, - }, - Shortcut { - label: "Esc cancel", - clickable: false, - id: 0, - }, - Shortcut { - label: "d reset", - clickable: false, - id: 0, - }, - ]; - } + SettingsMode::EditingInt { min, max, .. } => { + let (small_label, large_label) = int_step_footer_labels(*min, *max); vec![ Shortcut { - label: "type to edit", + label: small_label, clickable: false, id: 0, }, Shortcut { - label: "\u{2190}/\u{2192} cursor", + label: large_label, clickable: false, id: 0, }, @@ -2918,9 +2853,36 @@ pub(super) fn build_shortcuts(state: &SettingsModalState) -> Vec vec![ + SettingsMode::EditingString { .. } => vec![ + Shortcut { + label: "type to edit", + clickable: false, + id: 0, + }, + Shortcut { + label: "\u{2190}/\u{2192} cursor", + clickable: false, + id: 0, + }, + Shortcut { + label: "Enter commit", + clickable: false, + id: 0, + }, + Shortcut { + label: "Esc cancel", + clickable: false, + id: 0, + }, + ], + SettingsMode::PickingGroup { .. } => vec![ Shortcut { label: "\u{2191}/\u{2193}/j/k nav", clickable: false, @@ -2939,5 +2901,3 @@ pub(super) fn build_shortcuts(state: &SettingsModalState) -> Vec SettingsModeKind { + match &self.mode { + SettingsMode::Browse => SettingsModeKind::Browse, + SettingsMode::FilterFocused => SettingsModeKind::FilterFocused, + SettingsMode::PickingEnum { .. } => SettingsModeKind::PickingEnum, + SettingsMode::PickingGroup { .. } => SettingsModeKind::PickingGroup, + SettingsMode::EditingString { .. } => SettingsModeKind::EditingString, + SettingsMode::EditingInt { .. } => SettingsModeKind::EditingInt, + } + } +} + +#[derive(Debug)] +pub(super) enum SettingsMode { + Browse, + FilterFocused, + PickingEnum { + key: SettingKey, + choices_idx: usize, + original_value: SettingValue, + supports_preview: bool, + }, + PickingGroup { + key: SettingKey, + child_idx: usize, + }, + EditingString { + key: SettingKey, + editor: LineEditor, + validator: StringValidator, validation_error: Option, }, + EditingInt { + key: SettingKey, + buffer: String, + min: i64, + max: i64, + }, } /// Settings modal state. Boxed inside `ActiveModal::Settings` to @@ -119,11 +171,7 @@ pub struct SettingsModalState { pub selected: usize, /// Vertical scroll offset (line-granular). pub scroll_offset: usize, - pub mode: SettingsModalMode, - /// Filter query. Persists across FilterFocused→Browse on Enter; cleared by Esc. - pub query: String, - /// Byte offset of the editing cursor within `query`. - pub query_cursor: usize, + pub(super) state: SettingsState, /// Row indices matching `query`, recomputed per mutation (not per frame). pub(super) filtered_cache: Vec, @@ -178,9 +226,10 @@ impl SettingsModalState { rows, selected, scroll_offset: 0, - mode: SettingsModalMode::Browse, - query: String::new(), - query_cursor: 0, + state: SettingsState { + filter: LineEditor::default(), + mode: SettingsMode::Browse, + }, filtered_cache, list_area: Rect::default(), row_rects: Vec::new(), @@ -214,11 +263,12 @@ impl SettingsModalState { /// Keeps focus on the same key when possible; exits sub-panes if the key vanished. pub fn rebuild_rows(&mut self) { let prev_key = self.focused_setting().map(|(k, _)| k); - let subpane_key = match &self.mode { - SettingsModalMode::PickingEnum { key, .. } - | SettingsModalMode::PickingGroup { key, .. } - | SettingsModalMode::EditingValue { key, .. } => Some(*key), - SettingsModalMode::Browse | SettingsModalMode::FilterFocused => None, + let subpane_key = match &self.state.mode { + SettingsMode::PickingEnum { key, .. } + | SettingsMode::PickingGroup { key, .. } + | SettingsMode::EditingString { key, .. } + | SettingsMode::EditingInt { key, .. } => Some(*key), + SettingsMode::Browse | SettingsMode::FilterFocused => None, }; self.rows = build_rows(&self.registry); @@ -230,9 +280,7 @@ impl SettingsModalState { .iter() .any(|r| matches!(r, RowEntry::Setting { key: k, .. } if *k == key)); if !still_visible { - self.mode = SettingsModalMode::Browse; - self.settings_breadcrumb_rect = None; - self.picker_choice_rects.clear(); + self.transition_to_browse(); } } @@ -255,9 +303,73 @@ impl SettingsModalState { } } + pub fn mode(&self) -> SettingsModalMode { + match &self.state.mode { + SettingsMode::Browse => SettingsModalMode::Browse, + SettingsMode::FilterFocused => SettingsModalMode::FilterFocused, + SettingsMode::PickingEnum { + key, + choices_idx, + original_value, + supports_preview, + } => SettingsModalMode::PickingEnum { + key, + choices_idx: *choices_idx, + original_value: original_value.clone(), + supports_preview: *supports_preview, + }, + SettingsMode::PickingGroup { key, child_idx } => SettingsModalMode::PickingGroup { + key, + child_idx: *child_idx, + }, + SettingsMode::EditingString { key, .. } | SettingsMode::EditingInt { key, .. } => { + SettingsModalMode::EditingValue { key } + } + } + } + + pub fn query(&self) -> &str { + self.state.filter.text() + } + + pub fn query_cursor(&self) -> usize { + self.state.filter.cursor_byte() + } + + pub fn set_query(&mut self, query: impl Into) { + self.state.filter.set_text(query); + self.invalidate_filter(); + self.clamp_selected_to_visible(); + } + + pub fn editing_buffer(&self) -> Option<&str> { + match &self.state.mode { + SettingsMode::EditingString { editor, .. } => Some(editor.text()), + SettingsMode::EditingInt { buffer, .. } => Some(buffer), + _ => None, + } + } + + pub fn editing_cursor_byte(&self) -> Option { + match &self.state.mode { + SettingsMode::EditingString { editor, .. } => Some(editor.cursor_byte()), + _ => None, + } + } + + pub fn editing_validation_error(&self) -> Option<&str> { + match &self.state.mode { + SettingsMode::EditingString { + validation_error, .. + } => validation_error.as_deref(), + _ => None, + } + } + /// Recompute `filtered_cache` from the current `query`. pub(super) fn invalidate_filter(&mut self) { - self.filtered_cache = compute_filtered(&self.rows, &self.registry, &self.query); + self.filtered_cache = + compute_filtered(&self.rows, &self.registry, self.state.filter.text()); } /// Snap `selected` to the first visible setting if filtered out. @@ -358,12 +470,65 @@ impl SettingsModalState { /// Transition to Browse, clearing sub-pane hover/breadcrumb state /// to prevent stale hit-rects across mode changes. pub(crate) fn transition_to_browse(&mut self) { - self.mode = SettingsModalMode::Browse; + self.state.mode = SettingsMode::Browse; self.hover_row = None; self.settings_breadcrumb_rect = None; self.breadcrumb_hovered = false; } + pub fn focus_filter(&mut self) { + self.state.mode = SettingsMode::FilterFocused; + } + + pub(super) fn transition_to_picking_enum( + &mut self, + key: SettingKey, + choices_idx: usize, + original_value: SettingValue, + supports_preview: bool, + ) { + self.state.mode = SettingsMode::PickingEnum { + key, + choices_idx, + original_value, + supports_preview, + }; + } + + pub(super) fn transition_to_picking_group(&mut self, key: SettingKey, child_idx: usize) { + self.state.mode = SettingsMode::PickingGroup { key, child_idx }; + } + + pub(super) fn transition_to_editing_string( + &mut self, + key: SettingKey, + editor: LineEditor, + validator: StringValidator, + validation_error: Option, + ) { + self.state.mode = SettingsMode::EditingString { + key, + editor, + validator, + validation_error, + }; + } + + pub(super) fn transition_to_editing_int( + &mut self, + key: SettingKey, + buffer: String, + min: i64, + max: i64, + ) { + self.state.mode = SettingsMode::EditingInt { + key, + buffer, + min, + max, + }; + } + /// Transition to `PickingEnum` if the focused row is Enum/DynamicEnum. /// Returns `false` if the focused row is another kind. pub fn try_enter_picking_enum(&mut self) -> bool { @@ -469,12 +634,7 @@ impl SettingsModalState { _ => SettingValue::Enum(""), } }); - self.mode = SettingsModalMode::PickingEnum { - key, - choices_idx, - supports_preview, - original_value, - }; + self.transition_to_picking_enum(key, choices_idx, original_value, supports_preview); self.hover_row = None; true } @@ -489,7 +649,7 @@ impl SettingsModalState { if !matches!(meta.kind, SettingKind::Group { .. }) { return false; } - self.mode = SettingsModalMode::PickingGroup { key, child_idx: 0 }; + self.transition_to_picking_group(key, 0); self.hover_row = None; true } @@ -499,31 +659,36 @@ impl SettingsModalState { let Some((key, meta)) = self.focused_setting() else { return false; }; - let buffer = match (&meta.kind, self.value_for(key)) { - (SettingKind::String { .. }, Some(SettingValue::String(s))) => s, - (SettingKind::Int { .. }, Some(SettingValue::Int(i))) => i.to_string(), - // Fallback for registry skew — seed from default. - (SettingKind::String { default, .. }, _) => default.to_string(), - (SettingKind::Int { default, .. }, _) => default.to_string(), - _ => return false, - }; - let cursor_byte = buffer.len(); - - // Validate the seed value upfront. - let validation_error = match &meta.kind { - SettingKind::String { validator, .. } => { - validate_string(*validator, &buffer, &self.pager_snapshot.available_models) + let kind = meta.kind.clone(); + let value = self.value_for(key); + match kind { + SettingKind::String { + default, validator, .. + } => { + let text = match value { + Some(SettingValue::String(text)) => text, + _ => default.to_string(), + }; + let mut editor = LineEditor::default(); + editor.set_text(text); + let validation_error = validate_string( + validator, + editor.text(), + &self.pager_snapshot.available_models, + ); + self.transition_to_editing_string(key, editor, validator, validation_error); } - SettingKind::Int { min, max, .. } => validate_int(&buffer, *min, *max), - _ => None, - }; - - self.mode = SettingsModalMode::EditingValue { - key, - buffer, - cursor_byte, - validation_error, - }; + SettingKind::Int { + default, min, max, .. + } => { + let buffer = match value { + Some(SettingValue::Int(value)) => value.to_string(), + _ => default.to_string(), + }; + self.transition_to_editing_int(key, buffer, min, max); + } + _ => return false, + } self.hover_row = None; true } @@ -687,6 +852,7 @@ pub(super) fn action_for_bool(key: SettingKey, new: bool) -> Option { "collapsed_edit_blocks" => Some(Action::SetCollapsedEditBlocks(new)), "prompt_suggestions" => Some(Action::SetPromptSuggestions(new)), "respect_manual_folds" => Some(Action::SetRespectManualFolds(new)), + "page_flip_on_send" => Some(Action::SetPageFlipOnSend(new)), "invert_scroll" => Some(Action::SetInvertScroll(new)), "show_tips" => Some(Action::SetShowTips(new)), "auto_update" => Some(Action::SetAutoUpdate(new)), @@ -856,18 +1022,6 @@ pub(super) fn validate_string( } } -/// Validate an Int buffer against `(min, max)` bounds. -pub(super) fn validate_int(buffer: &str, min: i64, max: i64) -> Option { - if buffer.is_empty() { - return Some("Value cannot be empty".to_string()); - } - match buffer.parse::() { - Ok(v) if v >= min && v <= max => None, - Ok(v) => Some(format!("Value out of range ({min}\u{2013}{max}): {v}")), - Err(_) => Some(format!("Not a valid integer: \"{buffer}\"")), - } -} - /// Soft product cap on static Enum choices (settings unit tests enforce it). /// /// The chooser already scrolls within the viewport when the focused choice diff --git a/crates/codegen/xai-grok-pager/src/views/settings_modal/tests.rs b/crates/codegen/xai-grok-pager/src/views/settings_modal/tests.rs index 753ad5e..9d1cb3f 100644 --- a/crates/codegen/xai-grok-pager/src/views/settings_modal/tests.rs +++ b/crates/codegen/xai-grok-pager/src/views/settings_modal/tests.rs @@ -10,6 +10,7 @@ use super::input::*; use super::render::*; use super::state::*; use crate::app::actions::Action; +use crate::input::line_editor::LineEditor; use crate::settings::{ EnumChoice, PagerLocalSnapshot, SettingCategory, SettingKey, SettingKind, SettingMeta, SettingOwner, SettingValue, SettingsRegistry, StringValidator, @@ -50,7 +51,7 @@ fn contextual_hints_group_sub_sheet_flow() { let out = handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!(matches!(out, SettingsKeyOutcome::Changed)); assert!(matches!( - s.mode, + s.mode(), SettingsModalMode::PickingGroup { child_idx: 0, .. } )); @@ -70,7 +71,7 @@ fn contextual_hints_group_sub_sheet_flow() { // Esc returns to Browse. let out = handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert!(matches!(out, SettingsKeyOutcome::Changed)); - assert!(matches!(s.mode, SettingsModalMode::Browse)); + assert!(matches!(s.mode(), SettingsModalMode::Browse)); } /// The permission_mode picker hides the "Auto" choice when the auto feature @@ -610,11 +611,11 @@ fn rows_contain_categories_and_settings_through_pr_14() { vec![ // Booleans. "compact_mode", - // SHELL-owned default screen mode (Appearance; after compact). "screen_mode", "show_timestamps", - // Timeline sidebar (Appearance, declared after timestamps). "show_timeline", + // PAGER-owned page_flip_on_send (Appearance). + "page_flip_on_send", "simple_mode", // PAGER-owned vim_mode (Appearance, // paired with simple_mode). @@ -753,50 +754,6 @@ fn space_on_compact_mode_dispatches_set_compact_mode_true() { } } -#[test] -fn space_on_enum_row_opens_picker() { - let mut s = make_state(); - let idx = s - .rows - .iter() - .position(|r| matches!(r, RowEntry::Setting { key, .. } if *key == "screen_mode")) - .expect("screen_mode setting row"); - s.selected = idx; - let space = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE); - let outcome = handle_settings_key(&mut s, &space); - assert!( - matches!(outcome, SettingsKeyOutcome::Changed), - "Space on enum must open picker, got {outcome:?}" - ); - assert!( - matches!(s.mode, SettingsModalMode::PickingEnum { .. }), - "expected PickingEnum after Space on screen_mode, got {:?}", - s.mode - ); -} - -#[test] -fn browse_footer_shortcuts_stable_across_bool_and_enum_focus() { - let mut s = make_state(); - let bool_labels: Vec<&str> = build_shortcuts(&s).iter().map(|sc| sc.label).collect(); - assert!( - bool_labels.contains(&"Space/Enter"), - "Browse footer must advertise Space/Enter, got {bool_labels:?}" - ); - - let idx = s - .rows - .iter() - .position(|r| matches!(r, RowEntry::Setting { key, .. } if *key == "screen_mode")) - .expect("screen_mode setting row"); - s.selected = idx; - let enum_labels: Vec<&str> = build_shortcuts(&s).iter().map(|sc| sc.label).collect(); - assert_eq!( - bool_labels, enum_labels, - "Browse footer must not change when focus moves Bool → Enum" - ); -} - #[test] fn enter_on_compact_mode_also_toggles() { let mut s = make_state(); @@ -859,12 +816,12 @@ fn filter_mode_swallows_chars_into_query() { handle_settings_key(&mut s, &slash), SettingsKeyOutcome::Changed )); - assert!(matches!(s.mode, SettingsModalMode::FilterFocused)); + assert!(matches!(s.mode(), SettingsModalMode::FilterFocused)); for c in "compact".chars() { let k = KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE); let _ = handle_settings_key(&mut s, &k); } - assert_eq!(s.query, "compact"); + assert_eq!(s.query(), "compact"); // Esc exits filter, doesn't close modal. let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE); @@ -872,7 +829,7 @@ fn filter_mode_swallows_chars_into_query() { handle_settings_key(&mut s, &esc), SettingsKeyOutcome::Changed )); - assert!(matches!(s.mode, SettingsModalMode::Browse)); + assert!(matches!(s.mode(), SettingsModalMode::Browse)); } /// `i` aliases `/` without modifiers: from Browse it enters FilterFocused @@ -885,7 +842,7 @@ fn i_key_enters_filter_like_slash() { handle_settings_key(&mut s, &i), SettingsKeyOutcome::Changed )); - assert!(matches!(s.mode, SettingsModalMode::FilterFocused)); + assert!(matches!(s.mode(), SettingsModalMode::FilterFocused)); } /// The `modifiers.is_empty()` guard: Ctrl+i / Alt+i must NOT enter filter. @@ -898,7 +855,7 @@ fn modified_i_does_not_enter_filter() { handle_settings_key(&mut s, &k), SettingsKeyOutcome::Unchanged )); - assert!(matches!(s.mode, SettingsModalMode::Browse)); + assert!(matches!(s.mode(), SettingsModalMode::Browse)); } } @@ -912,7 +869,7 @@ fn modified_i_does_not_enter_filter() { fn browse_footer_advertises_i_search_under_vim() { crate::appearance::cache::set_vim_mode(true); let s = make_state(); - assert!(matches!(s.mode, SettingsModalMode::Browse)); + assert!(matches!(s.mode(), SettingsModalMode::Browse)); assert!( build_shortcuts(&s).iter().any(|sc| sc.label == "i search"), "vim-mode Browse footer must advertise `i search`" @@ -988,6 +945,38 @@ fn mouse_click_on_header_is_no_op() { // ---------- mouse hover highlight ---------- +#[test] +fn selected_browse_row_label_is_bold() { + let state = make_state(); + let meta = state + .registry + .find("compact_mode") + .expect("compact mode registered"); + let area = Rect::new(0, 0, 80, 1); + let mut buf = Buffer::empty(area); + let theme = Theme::current(); + + render_setting_row( + &mut buf, + area, + meta, + &SettingValue::Bool(false), + 40, + true, + &theme, + false, + false, + ); + + assert!( + buf.cell((2, 0)) + .expect("label cell") + .style() + .add_modifier + .contains(Modifier::BOLD), + ); +} + #[test] fn settings_list_row_bg_terminal_native_elevates_selection() { let theme = Theme::terminal_default(); @@ -998,17 +987,6 @@ fn settings_list_row_bg_terminal_native_elevates_selection() { assert_eq!(settings_list_row_bg(&theme, true, true), Color::DarkGray); } -#[test] -fn settings_list_row_bg_rgb_theme_uses_theme_tokens() { - let theme = Theme::current(); - if matches!(theme.bg_visual, Color::Reset) { - return; - } - assert_eq!(settings_list_row_bg(&theme, true, false), theme.bg_visual); - assert_eq!(settings_list_row_bg(&theme, false, true), theme.bg_hover); - assert_eq!(settings_list_row_bg(&theme, false, false), theme.bg_base); -} - /// `MouseEventKind::Moved` over a setting row's hit-rect sets /// `state.hover_row` to that row's index and reports `Changed` /// so the next render paints the highlight. Mirrors the @@ -1175,7 +1153,7 @@ fn hover_row_renders_with_hover_style() { assert_eq!( cell.style().bg, Some(settings_list_row_bg(&theme, false, true)), - "hover row must paint with settings list hover bg, got {:?}", + "hover row must paint with the list hover background, got {:?}", cell.style().bg, ); } @@ -1225,13 +1203,7 @@ fn picker_choice_mouse_hover_highlights_choice() { "Moved over choice 1 must set hover_row = Some(1)", ); - // Re-render and observe the hover bg on choice 1's row. - // (`bg_hover` may quantize to `Color::Reset` in tests with - // `NO_COLOR` set — same caveat as - // `picker_highlights_current_choice`. We assert tautologically - // against `theme.bg_hover`; the wiring assertion is the - // hover_row state mutation above plus the focused-choice - // bg_visual contrast.) + // Re-render and observe the shared list hover background on choice 1. let mut buf2 = Buffer::empty(area); render_picking_enum(&mut buf2, area, &s, &theme); let new_rects = take_picker_choice_rects(); @@ -1242,10 +1214,10 @@ fn picker_choice_mouse_hover_highlights_choice() { assert_eq!( cell1.style().bg, Some(settings_list_row_bg(&theme, false, true)), - "hovered choice must paint list hover bg, got {:?}", + "hovered choice must paint the list hover background, got {:?}", cell1.style().bg, ); - // Focused choice (index 0) keeps selection bg — selection wins + // Focused choice (index 0) keeps bg_visual — selection wins // over hover. Verifies the `is_focused` branch precedence. let rect0 = new_rects[0]; let cell0 = buf2 @@ -1254,7 +1226,7 @@ fn picker_choice_mouse_hover_highlights_choice() { assert_eq!( cell0.style().bg, Some(settings_list_row_bg(&theme, true, false)), - "focused choice must keep selection bg even when hover is elsewhere", + "focused choice must keep the list selection background when hover is elsewhere", ); } @@ -1269,7 +1241,7 @@ fn mode_transition_browse_to_picking_enum_clears_hover_row() { s.hover_row = Some(s.selected); let _ = handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!( - matches!(s.mode, SettingsModalMode::PickingEnum { .. }), + matches!(s.mode(), SettingsModalMode::PickingEnum { .. }), "Enter on enum row must transition to PickingEnum", ); assert_eq!( @@ -1291,9 +1263,9 @@ fn mode_transition_browse_to_editing_value_clears_hover_row() { s.hover_row = Some(s.selected); let _ = handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!( - matches!(s.mode, SettingsModalMode::EditingValue { .. }), + matches!(s.mode(), SettingsModalMode::EditingValue { .. }), "Enter on Int row must transition to EditingValue, got {:?}", - s.mode, + s.mode(), ); assert_eq!( s.hover_row, None, @@ -1308,13 +1280,13 @@ fn mode_transition_picking_enum_to_browse_clears_hover_row() { let mut s = make_state(); navigate_to_enum_row(&mut s); let _ = handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert!(matches!(s.mode, SettingsModalMode::PickingEnum { .. })); + assert!(matches!(s.mode(), SettingsModalMode::PickingEnum { .. })); // Seed picker-mode hover (e.g. mouse moved over a non-focused // choice while in the picker). s.hover_row = Some(2); let _ = handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert!( - matches!(s.mode, SettingsModalMode::Browse), + matches!(s.mode(), SettingsModalMode::Browse), "Esc in PickingEnum must transition to Browse", ); assert_eq!( @@ -1582,17 +1554,20 @@ fn editor_render_fixture(buffer: &str, cursor_byte: usize) -> SettingsModalState ..PagerLocalSnapshot::default() }; let mut s = SettingsModalState::new(Arc::new(registry), UiConfig::default(), snapshot); + let mut editor = LineEditor::default(); + editor.set_text(buffer); + let _ = editor.set_cursor_byte(cursor_byte); let validation_error = validate_string( StringValidator::KnownModel, - buffer, + editor.text(), &s.pager_snapshot.available_models, ); - s.mode = SettingsModalMode::EditingValue { - key: "default_model", - buffer: buffer.to_string(), - cursor_byte, + s.transition_to_editing_string( + "default_model", + editor, + StringValidator::KnownModel, validation_error, - }; + ); s } @@ -1718,6 +1693,43 @@ fn render_editing_value_cursor_pans_to_right_on_overflow_at_end() { ); } +#[test] +fn render_string_editor_keeps_narrow_graphemes_and_cursor_aligned() { + let grapheme = "👩🏽\u{200d}💻"; + let combining = "e\u{301}"; + let text = format!("a{grapheme}{combining}"); + let mut state = editor_render_fixture(&text, text.len()); + let area = Rect { + x: 0, + y: 0, + width: 4, + height: 12, + }; + let mut buffer = Buffer::empty(area); + render_editing_value(&mut buffer, area, &mut state, &Theme::current()); + + let mut rendered = String::new(); + let mut cursor = None; + for y in 0..area.height { + for x in 0..area.width { + let cell = buffer.cell((x, y)).expect("rendered cell"); + rendered.push_str(cell.symbol()); + if cell.symbol() == crate::glyphs::selection_bar() { + cursor = Some((x, y)); + } + } + } + assert!( + rendered.contains(grapheme), + "ZWJ grapheme split: {rendered:?}" + ); + assert!( + rendered.contains(combining), + "combining grapheme split: {rendered:?}", + ); + assert_eq!(cursor.map(|(x, _)| x), Some(3)); +} + /// When the validator returns a /// non-None error, the buffer foreground turns red /// (`accent_error`), AND the validation-error row at y = @@ -1808,12 +1820,7 @@ fn render_editing_value_int_populates_adornment_hit_rects() { UiConfig::default(), PagerLocalSnapshot::default(), ); - s.mode = SettingsModalMode::EditingValue { - key: "max_thoughts_width", - buffer: "120".to_string(), - cursor_byte: 3, - validation_error: None, - }; + s.transition_to_editing_int("max_thoughts_width", "120".to_string(), 40, 500); let area = Rect { x: 0, y: 0, @@ -1868,14 +1875,11 @@ fn int_stepper_fixture_for(key: &'static str, value: i64) -> SettingsModalState UiConfig::default(), PagerLocalSnapshot::default(), ); - let buffer = value.to_string(); - let cursor_byte = buffer.len(); - s.mode = SettingsModalMode::EditingValue { - key, - buffer, - cursor_byte, - validation_error: None, + let (min, max) = match s.registry.find(key).map(|meta| &meta.kind) { + Some(SettingKind::Int { min, max, .. }) => (*min, *max), + _ => panic!("expected registered Int setting"), }; + s.transition_to_editing_int(key, value.to_string(), min, max); s } @@ -1885,10 +1889,9 @@ fn int_stepper_fixture(value: i64) -> SettingsModalState { } fn int_stepper_buffer(s: &SettingsModalState) -> String { - match &s.mode { - SettingsModalMode::EditingValue { buffer, .. } => buffer.clone(), - other => panic!("expected EditingValue, got {other:?}"), - } + s.editing_buffer() + .map(str::to_owned) + .unwrap_or_else(|| panic!("expected EditingValue, got {:?}", s.mode())) } #[test] @@ -2144,7 +2147,7 @@ fn int_editing_value_enter_commits() { other => panic!("expected SetMaxThoughtsWidth(75) on Enter, got {other:?}"), } assert!( - matches!(s.mode, SettingsModalMode::Browse), + matches!(s.mode(), SettingsModalMode::Browse), "Enter must return to Browse" ); } @@ -2172,7 +2175,7 @@ fn int_editing_value_esc_reverts() { never live-previewed", ); assert!( - matches!(s.mode, SettingsModalMode::Browse), + matches!(s.mode(), SettingsModalMode::Browse), "Esc must return to Browse" ); } @@ -2279,12 +2282,7 @@ fn picking_enum_esc_dispatches_preview_revert_for_each_key() { ]; for &(key, original) in cases { let mut s = make_state(); - s.mode = SettingsModalMode::PickingEnum { - key, - choices_idx: 0, - original_value: SettingValue::Enum(original), - supports_preview: true, - }; + s.transition_to_picking_enum(key, 0, SettingValue::Enum(original), true); let outcome = handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); match (key, outcome) { ("theme", SettingsKeyOutcome::Action(Action::PreviewTheme(name))) => { @@ -2304,7 +2302,7 @@ fn picking_enum_esc_dispatches_preview_revert_for_each_key() { } } assert!( - matches!(s.mode, SettingsModalMode::Browse), + matches!(s.mode(), SettingsModalMode::Browse), "Esc must transition back to Browse for key `{key}`", ); } @@ -2317,12 +2315,7 @@ fn picking_enum_esc_dispatches_preview_revert_for_each_key() { #[test] fn picking_enum_esc_returns_to_browse() { let mut s = make_state(); - s.mode = SettingsModalMode::PickingEnum { - key: "theme", - choices_idx: 0, - original_value: SettingValue::Enum("groknight"), - supports_preview: true, - }; + s.transition_to_picking_enum("theme", 0, SettingValue::Enum("groknight"), true); let outcome = handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); match outcome { SettingsKeyOutcome::Action(Action::PreviewTheme(name)) => { @@ -2333,7 +2326,7 @@ fn picking_enum_esc_returns_to_browse() { } other => panic!("expected Action::PreviewTheme(\"groknight\") on Esc, got {other:?}"), } - assert!(matches!(s.mode, SettingsModalMode::Browse)); + assert!(matches!(s.mode(), SettingsModalMode::Browse)); } // -- picker machinery tests -- @@ -2399,12 +2392,7 @@ fn picker_test_state() -> SettingsModalState { UiConfig::default(), PagerLocalSnapshot::default(), ); - s.mode = SettingsModalMode::PickingEnum { - key: "test_enum", - choices_idx: 0, - original_value: SettingValue::Enum("first"), - supports_preview: true, - }; + s.transition_to_picking_enum("test_enum", 0, SettingValue::Enum("first"), true); s } @@ -2442,7 +2430,7 @@ fn picker_arrow_keys_advance_choices_idx() { matches!(outcome, SettingsKeyOutcome::Changed), "Down should produce Changed (state mutation), got {outcome:?}" ); - match s.mode { + match s.mode() { SettingsModalMode::PickingEnum { choices_idx, .. } => assert_eq!(choices_idx, 1), ref other => panic!("expected PickingEnum mode after Down, got {other:?}"), } @@ -2453,7 +2441,7 @@ fn picker_arrow_keys_advance_choices_idx() { &KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), ); assert!(matches!(outcome, SettingsKeyOutcome::Changed)); - match s.mode { + match s.mode() { SettingsModalMode::PickingEnum { choices_idx, .. } => assert_eq!(choices_idx, 2), _ => panic!("expected PickingEnum mode after j"), } @@ -2468,7 +2456,7 @@ fn picker_arrow_keys_advance_choices_idx() { // Up: 2 → 1. let outcome = handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); assert!(matches!(outcome, SettingsKeyOutcome::Changed)); - match s.mode { + match s.mode() { SettingsModalMode::PickingEnum { choices_idx, .. } => assert_eq!(choices_idx, 1), _ => panic!("expected PickingEnum mode after Up"), } @@ -2479,7 +2467,7 @@ fn picker_arrow_keys_advance_choices_idx() { &KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE), ); assert!(matches!(outcome, SettingsKeyOutcome::Changed)); - match s.mode { + match s.mode() { SettingsModalMode::PickingEnum { choices_idx, .. } => assert_eq!(choices_idx, 0), _ => panic!("expected PickingEnum mode after k"), } @@ -2516,7 +2504,7 @@ fn picker_enter_returns_to_browse() { "Enter for synthetic-key must produce Changed (no commit arm), got {outcome:?}" ); assert!( - matches!(s.mode, SettingsModalMode::Browse), + matches!(s.mode(), SettingsModalMode::Browse), "Enter must return to Browse" ); } @@ -2540,7 +2528,7 @@ fn picker_esc_returns_to_browse_after_preview_nav() { // path's "original vs current" distinction is meaningful. let _ = handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); let _ = handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); - match s.mode { + match s.mode() { SettingsModalMode::PickingEnum { choices_idx, .. } => assert_eq!(choices_idx, 2), _ => panic!("expected PickingEnum mode after 2x Down"), } @@ -2553,7 +2541,7 @@ fn picker_esc_returns_to_browse_after_preview_nav() { "Esc revert outcome should be Changed (or Action when arms exist), got {outcome:?}" ); assert!( - matches!(s.mode, SettingsModalMode::Browse), + matches!(s.mode(), SettingsModalMode::Browse), "Esc must return to Browse" ); } @@ -2632,13 +2620,7 @@ fn picker_renders_choices_in_order() { fn picker_highlights_current_choice() { let mut s = picker_test_state(); // Focus the second choice (index 1). - if let SettingsModalMode::PickingEnum { - ref mut choices_idx, - .. - } = s.mode - { - *choices_idx = 1; - } + s.transition_to_picking_enum("test_enum", 1, SettingValue::Enum("first"), true); let area = Rect { x: 0, y: 0, @@ -2670,13 +2652,13 @@ fn picker_highlights_current_choice() { }; assert_eq!( bg_at(4), - Some(settings_list_row_bg(&theme, true, false)), - "focused row must have selection background" + Some(theme.bg_visual), + "focused row must have bg_visual background" ); assert_eq!( bg_at(3), - Some(settings_list_row_bg(&theme, false, false)), - "unfocused row must have idle background" + Some(theme.bg_base), + "unfocused row must have bg_base background" ); // Display text on focused row carries BOLD modifier @@ -2716,7 +2698,7 @@ fn picker_highlights_current_choice() { fn browse_enter_on_enum_row_transitions_to_picking_enum() { let mut s = picker_test_state_in_browse(); // Sanity: initial state. - assert!(matches!(s.mode, SettingsModalMode::Browse)); + assert!(matches!(s.mode(), SettingsModalMode::Browse)); match &s.rows[s.selected] { RowEntry::Setting { key, .. } => assert_eq!(*key, "test_enum"), _ => panic!("expected synthetic Enum row at initial selection"), @@ -2727,7 +2709,7 @@ fn browse_enter_on_enum_row_transitions_to_picking_enum() { matches!(outcome, SettingsKeyOutcome::Changed), "Enter on Enum row should produce Changed, got {outcome:?}" ); - match s.mode { + match s.mode() { SettingsModalMode::PickingEnum { key, choices_idx, @@ -2758,9 +2740,9 @@ fn browse_enter_on_bool_row_does_not_enter_picking_enum() { } // Mode must NOT have changed to PickingEnum. assert!( - matches!(s.mode, SettingsModalMode::Browse), + matches!(s.mode(), SettingsModalMode::Browse), "Bool toggle must stay in Browse mode, got {:?}", - s.mode, + s.mode(), ); } @@ -2788,7 +2770,7 @@ fn try_enter_picking_enum_seeds_choices_idx_from_current_value() { // idx > 0 seeding. let mut s = picker_test_state_in_browse(); assert!(s.try_enter_picking_enum()); - match s.mode { + match s.mode() { SettingsModalMode::PickingEnum { key, choices_idx, @@ -2813,13 +2795,13 @@ fn try_enter_picking_enum_seeds_choices_idx_from_current_value() { #[test] fn try_enter_picking_enum_returns_false_for_non_enum_row() { let mut s = make_state(); - assert!(matches!(s.mode, SettingsModalMode::Browse)); + assert!(matches!(s.mode(), SettingsModalMode::Browse)); assert!( !s.try_enter_picking_enum(), "non-Enum focused row should return false" ); assert!( - matches!(s.mode, SettingsModalMode::Browse), + matches!(s.mode(), SettingsModalMode::Browse), "mode must not change on non-Enum row" ); } @@ -3002,12 +2984,7 @@ fn render_picker_long_description_wraps_no_ellipsis() { UiConfig::default(), PagerLocalSnapshot::default(), ); - s.mode = SettingsModalMode::PickingEnum { - key: "long_enum", - choices_idx: 0, - original_value: SettingValue::Enum("wide"), - supports_preview: true, - }; + s.transition_to_picking_enum("long_enum", 0, SettingValue::Enum("wide"), true); let area = Rect { x: 0, y: 0, @@ -3099,12 +3076,7 @@ fn picker_visual_smoke_debug() { UiConfig::default(), PagerLocalSnapshot::default(), ); - s.mode = SettingsModalMode::PickingEnum { - key: "wrap_enum", - choices_idx: 1, - original_value: SettingValue::Enum("opt-out"), - supports_preview: false, - }; + s.transition_to_picking_enum("wrap_enum", 1, SettingValue::Enum("opt-out"), false); let area = Rect { x: 0, y: 0, @@ -3161,12 +3133,7 @@ fn picker_long_description_wraps_to_multiple_lines() { UiConfig::default(), PagerLocalSnapshot::default(), ); - s.mode = SettingsModalMode::PickingEnum { - key: "wrap_enum", - choices_idx: 1, - original_value: SettingValue::Enum("opt-out"), - supports_preview: false, - }; + s.transition_to_picking_enum("wrap_enum", 1, SettingValue::Enum("opt-out"), false); let area = Rect { x: 0, y: 0, @@ -3297,12 +3264,7 @@ fn picker_short_description_stays_one_line() { UiConfig::default(), PagerLocalSnapshot::default(), ); - s.mode = SettingsModalMode::PickingEnum { - key: "short_enum", - choices_idx: 0, - original_value: SettingValue::Enum("a"), - supports_preview: true, - }; + s.transition_to_picking_enum("short_enum", 0, SettingValue::Enum("a"), true); let area = Rect { x: 0, y: 0, @@ -3371,12 +3333,7 @@ fn picker_no_description_renders_symbol_and_display_only() { UiConfig::default(), PagerLocalSnapshot::default(), ); - s.mode = SettingsModalMode::PickingEnum { - key: "nodesc_enum", - choices_idx: 0, - original_value: SettingValue::Enum("a"), - supports_preview: true, - }; + s.transition_to_picking_enum("nodesc_enum", 0, SettingValue::Enum("a"), true); let area = Rect { x: 0, y: 0, @@ -3446,12 +3403,7 @@ fn picker_multi_line_choice_hit_rect_spans_all_lines() { UiConfig::default(), PagerLocalSnapshot::default(), ); - s.mode = SettingsModalMode::PickingEnum { - key: "wrap_enum", - choices_idx: 0, - original_value: SettingValue::Enum("opt-in"), - supports_preview: false, - }; + s.transition_to_picking_enum("wrap_enum", 0, SettingValue::Enum("opt-in"), false); let area = Rect { x: 0, y: 0, @@ -3499,7 +3451,7 @@ fn picker_multi_line_choice_hit_rect_spans_all_lines() { 10, click_y, ); - match (outcome, &s.mode) { + match (outcome, &s.mode()) { ( SettingsKeyOutcome::Changed | SettingsKeyOutcome::Action(_), SettingsModalMode::PickingEnum { choices_idx, .. }, @@ -3572,12 +3524,7 @@ fn picker_scroll_offset_accounts_for_variable_height() { UiConfig::default(), PagerLocalSnapshot::default(), ); - s.mode = SettingsModalMode::PickingEnum { - key: "many_wrap", - choices_idx: 4, - original_value: SettingValue::Enum("c4"), - supports_preview: true, - }; + s.transition_to_picking_enum("many_wrap", 4, SettingValue::Enum("c4"), true); // Viewport: title + desc + gap = 3 rows of chrome + 8 rows // of choices = 11 total. With 5 choices × 3 lines = 15 total // wrap-rows of content, only ~2 choices can fit per page. @@ -3644,12 +3591,7 @@ fn render_picker_truncates_long_display_with_ellipsis() { UiConfig::default(), PagerLocalSnapshot::default(), ); - s.mode = SettingsModalMode::PickingEnum { - key: "long_enum", - choices_idx: 0, - original_value: SettingValue::Enum("wide"), - supports_preview: true, - }; + s.transition_to_picking_enum("long_enum", 0, SettingValue::Enum("wide"), true); let area = Rect { x: 0, y: 0, @@ -3699,12 +3641,7 @@ fn render_picker_truncates_long_title_with_ellipsis() { UiConfig::default(), PagerLocalSnapshot::default(), ); - s.mode = SettingsModalMode::PickingEnum { - key: "long_enum", - choices_idx: 0, - original_value: SettingValue::Enum("a"), - supports_preview: true, - }; + s.transition_to_picking_enum("long_enum", 0, SettingValue::Enum("a"), true); let area = Rect { x: 0, y: 0, @@ -3784,12 +3721,7 @@ fn render_picker_shows_more_indicator_when_choices_overflow() { UiConfig::default(), PagerLocalSnapshot::default(), ); - s.mode = SettingsModalMode::PickingEnum { - key: "long_enum", - choices_idx: 0, - original_value: SettingValue::Enum("c0"), - supports_preview: true, - }; + s.transition_to_picking_enum("long_enum", 0, SettingValue::Enum("c0"), true); // Total height 7 → header_rows=3 (title+desc+gap) + 4 choices // rows. With 6 choices, 4 fit in viewport - 1 (reserved for // overflow). So 3 visible, 3 hidden → "… 3 more". @@ -3934,7 +3866,7 @@ fn picker_ignores_random_keypress() { assert!(matches!(outcome, SettingsKeyOutcome::Unchanged)); assert!( matches!( - s.mode, + s.mode(), SettingsModalMode::PickingEnum { choices_idx: 0, .. } ), "mode must remain PickingEnum after random keypress" @@ -3974,21 +3906,12 @@ fn editing_value_chars_mutate_buffer_and_invalid_enter_is_noop() { matches!(outcome, SettingsKeyOutcome::Changed), "char insert in EditingValue must be Changed, got {outcome:?}" ); - match &s.mode { - SettingsModalMode::EditingValue { - buffer, - validation_error, - .. - } => { - assert_eq!(buffer, "a"); - assert!( - validation_error.is_some(), - "validation_error must be Some for unknown model 'a' \ - (catalog has 'Grok 4 Fast' only)", - ); - } - _ => panic!("mode must remain EditingValue after char input"), - } + assert_eq!(s.editing_buffer(), Some("a")); + assert!( + s.editing_validation_error().is_some(), + "validation_error must be Some for unknown model 'a' \ + (catalog has 'Grok 4 Fast' only)", + ); // Enter on a buffer that fails the KnownModel validator // (catalog has 'Grok 4 Fast'; "a" doesn't match) is @@ -3999,11 +3922,59 @@ fn editing_value_chars_mutate_buffer_and_invalid_enter_is_noop() { "Enter on invalid buffer must be Unchanged, got {outcome:?}" ); assert!( - matches!(s.mode, SettingsModalMode::EditingValue { .. }), + matches!(s.mode(), SettingsModalMode::EditingValue { .. }), "Enter on invalid buffer must keep EditingValue mode (no commit)" ); } +#[test] +fn string_editor_uses_canonical_edits_policy_and_live_validation() { + let mut state = editor_render_fixture("alpha-beta", "alpha-beta".len()); + let outcome = handle_settings_key( + &mut state, + &KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT), + ); + assert!(matches!(outcome, SettingsKeyOutcome::Changed)); + assert_eq!(state.editing_buffer(), Some("alpha-")); + + let mut state = editor_render_fixture("Grok Tes", "Grok Tes".len()); + assert!(state.editing_validation_error().is_some()); + let _ = handle_settings_key( + &mut state, + &KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), + ); + assert!( + state.editing_validation_error().is_some(), + "cursor motion must preserve validation state", + ); + let _ = handle_settings_key(&mut state, &KeyEvent::new(KeyCode::End, KeyModifiers::NONE)); + let _ = handle_settings_key( + &mut state, + &KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE), + ); + assert_eq!(state.editing_buffer(), Some("Grok Test")); + assert!(state.editing_validation_error().is_none()); + + let cursor = state.editing_cursor_byte(); + let outcome = handle_settings_key( + &mut state, + &KeyEvent::new(KeyCode::Char('\u{202e}'), KeyModifiers::NONE), + ); + assert!(matches!(outcome, SettingsKeyOutcome::Changed)); + assert_eq!(state.editing_buffer(), Some("Grok Test")); + assert_eq!(state.editing_cursor_byte(), cursor); + + let outcome = handle_settings_key( + &mut state, + &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert!(matches!( + outcome, + SettingsKeyOutcome::Action(Action::SetDefaultModel(_)) + )); + assert!(matches!(state.mode(), SettingsModalMode::Browse)); +} + // -- helper-function coverage -- /// `picker_choices_len` returns 0 for an unknown key, a non-Enum @@ -4033,16 +4004,10 @@ fn picker_choice_at_returns_none_for_oob_and_missing() { #[test] fn editing_value_esc_returns_to_browse() { - let mut s = make_state(); - s.mode = SettingsModalMode::EditingValue { - key: "default_model", - buffer: "grok-4".to_string(), - cursor_byte: "grok-4".len(), - validation_error: None, - }; + let mut s = int_stepper_fixture(120); let outcome = handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert!(matches!(outcome, SettingsKeyOutcome::Changed)); - assert!(matches!(s.mode, SettingsModalMode::Browse)); + assert!(matches!(s.mode(), SettingsModalMode::Browse)); } // -- Direct unit tests for compute_filtered -- @@ -4146,9 +4111,7 @@ fn compute_filtered_multi_word_and_match_narrows_further() { fn advance_next_recovers_when_selection_is_hidden() { let mut s = make_state(); // Apply a filter that hides compact_mode. - s.query = "stamp".to_string(); - s.query_cursor = s.query.len(); - s.invalidate_filter(); + s.set_query("stamp"); // Manually corrupt selected to a HIDDEN row (compact_mode is // row 1, hidden by "stamp"). This bypasses // clamp_selected_to_visible and exercises the defensive arm. @@ -4183,9 +4146,7 @@ fn advance_prev_recovers_when_selection_is_hidden() { // only. Let's use that and corrupt selected to compact_mode // (hidden). Up should land on the LAST visible setting which // is simple_mode. - s.query = "simple".to_string(); - s.query_cursor = s.query.len(); - s.invalidate_filter(); + s.set_query("simple"); let compact_idx = s .rows .iter() @@ -4688,10 +4649,10 @@ fn two_line_row_hit_rect_spans_both_lines() { match outcome { SettingsKeyOutcome::Changed => { assert!( - matches!(s.mode, SettingsModalMode::PickingEnum { .. }), + matches!(s.mode(), SettingsModalMode::PickingEnum { .. }), "click on line 2 of a two-line Enum row must open the picker, \ got mode {:?}", - s.mode + s.mode() ); } other => panic!( @@ -4973,7 +4934,7 @@ fn footer_has_blank_line_between_tip_and_hints_when_hints_dont_wrap() { // FilterFocused mode has 5 shortcuts totalling ~76 cells — // fits on one row at any modal width supported by // `render_settings_modal` (max_width=110). - s.mode = SettingsModalMode::FilterFocused; + s.focus_filter(); let area = Rect { x: 0, y: 0, @@ -5036,7 +4997,7 @@ fn footer_total_height_grows_when_hints_wrap() { height: 30, }; let mut s_wide = make_state(); - s_wide.mode = SettingsModalMode::FilterFocused; + s_wide.focus_filter(); let mut buf_wide = Buffer::empty(wide_area); render_settings_modal(&mut buf_wide, wide_area, &mut s_wide, false, None); let wide_list_height = s_wide.list_area.height; @@ -5050,7 +5011,7 @@ fn footer_total_height_grows_when_hints_wrap() { height: 30, }; let mut s_narrow = make_state(); - s_narrow.mode = SettingsModalMode::FilterFocused; + s_narrow.focus_filter(); let mut buf_narrow = Buffer::empty(narrow_area); render_settings_modal(&mut buf_narrow, narrow_area, &mut s_narrow, false, None); let narrow_list_height = s_narrow.list_area.height; @@ -5180,16 +5141,16 @@ fn search_bar_focused_style_matches_palette() { }; let mut buf = Buffer::empty(area); let theme = Theme::current(); - crate::views::picker::render_search_bar( + let editor = LineEditor::default(); + crate::views::picker::render_line_editor_search_bar( &mut buf, area.x, area.y, area.width, &theme, - "", + &editor, true, true, - 0, Some(theme.bg_base), ); @@ -5232,16 +5193,16 @@ fn search_bar_placeholder_matches_palette() { }; let mut buf = Buffer::empty(area); let theme = Theme::current(); - crate::views::picker::render_search_bar( + let editor = LineEditor::default(); + crate::views::picker::render_line_editor_search_bar( &mut buf, area.x, area.y, area.width, &theme, - "", + &editor, false, true, - 0, Some(theme.bg_base), ); @@ -5271,6 +5232,65 @@ fn search_bar_placeholder_matches_palette() { ); } +#[test] +fn ctrl_u_clears_the_entire_filter_from_mid_query() { + let mut state = make_state(); + state.focus_filter(); + state.set_query("alpha beta"); + set_filter_cursor(&mut state, "alpha".len()); + + let outcome = handle_settings_key( + &mut state, + &KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL), + ); + assert!(matches!(outcome, SettingsKeyOutcome::Changed)); + assert!(state.query().is_empty()); + assert_eq!(state.query_cursor(), 0); +} + +#[test] +fn string_editor_paste_sanitizes_validates_and_consumes_rejected_text() { + let mut state = editor_render_fixture("Grok Tst", "Grok T".len()); + let outcome = handle_settings_paste(&mut state, "e\r\n"); + assert!(matches!(outcome, SettingsKeyOutcome::Changed)); + assert_eq!(state.editing_buffer(), Some("Grok Test")); + assert!(state.editing_validation_error().is_none()); + + let outcome = handle_settings_paste(&mut state, "\u{202e}\r\n"); + assert!(matches!(outcome, SettingsKeyOutcome::Changed)); + assert_eq!(state.editing_buffer(), Some("Grok Test")); + assert!(state.editing_validation_error().is_none()); +} + +#[test] +fn filter_search_bar_keeps_narrow_graphemes_and_cursor_aligned() { + let grapheme = "👩🏽\u{200d}💻"; + let combining = "e\u{301}"; + let mut state = make_state(); + state.focus_filter(); + state.set_query(format!("a{grapheme}{combining}")); + let area = Rect { + x: 0, + y: 0, + width: 13, + height: 3, + }; + let mut buffer = Buffer::empty(area); + let theme = Theme::current(); + render_row_list_with_search_bar(&mut buffer, area, &mut state, &theme); + + let mut row = String::new(); + for x in 0..area.width { + row.push_str(buffer.cell((x, 0)).expect("search cell").symbol()); + } + assert!(row.contains(grapheme), "ZWJ grapheme split: {row:?}"); + assert!(row.contains(combining), "combining grapheme split: {row:?}",); + assert_eq!( + buffer.cell((12, 0)).expect("cursor cell").bg, + theme.text_primary, + ); +} + // -- value color + chevron column + docs footer -- /// Bool `off` values render in the muted `gray` color while @@ -6036,9 +6056,9 @@ fn click_settings_breadcrumb_collapses_picker_to_browse() { ), } assert!( - matches!(s.mode, SettingsModalMode::Browse), + matches!(s.mode(), SettingsModalMode::Browse), "after the breadcrumb click the mode must be Browse, got {:?}", - s.mode, + s.mode(), ); } @@ -6061,7 +6081,7 @@ fn click_settings_breadcrumb_after_nav_reverts_to_original() { // The picker exposes `choices_idx`; the registry's theme // choices include at least 2 entries so we can safely // advance. - let (orig_canonical_owned, advanced_idx) = match &s.mode { + let (orig_canonical_owned, advanced_idx) = match &s.mode() { SettingsModalMode::PickingEnum { choices_idx, original_value, @@ -6079,7 +6099,7 @@ fn click_settings_breadcrumb_after_nav_reverts_to_original() { // (index 1 per the registry); advance to index 0 to ensure // we're navigating to a different value. let target_idx = if advanced_idx == 0 { 1 } else { 0 }; - match s.mode { + match s.mode() { SettingsModalMode::PickingEnum { ref mut choices_idx, .. @@ -6111,7 +6131,7 @@ fn click_settings_breadcrumb_after_nav_reverts_to_original() { } other => panic!("expected Action(PreviewTheme()), got {other:?}"), } - assert!(matches!(s.mode, SettingsModalMode::Browse)); + assert!(matches!(s.mode(), SettingsModalMode::Browse)); } /// `d` in PickingEnum for a preview-supporting Enum dispatches @@ -6148,7 +6168,7 @@ fn d_key_in_picking_enum_dispatches_open_reset_confirm() { } } assert!( - matches!(s.mode, SettingsModalMode::Browse), + matches!(s.mode(), SettingsModalMode::Browse), "picker must collapse to Browse before dispatching reset \ (dispatch arm panics in debug if it sees a sub-pane mode)", ); @@ -6161,12 +6181,12 @@ fn d_key_in_picking_enum_dispatches_open_reset_confirm() { /// buffer is discarded — a user who stepped to a new value /// then pressed `d` should not have the in-flight value leak /// past the mode transition. Asserts the mode is structurally -/// `Browse` (not lingering `EditingValue { buffer: "80", … }`). +/// `Browse` with no lingering pending buffer. #[test] fn d_key_in_int_stepper_dispatches_open_reset_confirm() { let mut s = int_stepper_fixture(75); assert!( - matches!(s.mode, SettingsModalMode::EditingValue { .. }), + matches!(s.mode(), SettingsModalMode::EditingValue { .. }), "fixture must start in EditingValue", ); // Step Up so the pending buffer diverges from the default @@ -6192,7 +6212,7 @@ fn d_key_in_int_stepper_dispatches_open_reset_confirm() { other => panic!("expected OpenResetConfirm action, got {other:?}"), } assert!( - matches!(s.mode, SettingsModalMode::Browse), + matches!(s.mode(), SettingsModalMode::Browse), "stepper must collapse to Browse before dispatching reset", ); // Pending buffer must be discarded — no lingering @@ -6201,10 +6221,10 @@ fn d_key_in_int_stepper_dispatches_open_reset_confirm() { // assertion that we did NOT carry the in-flight buffer // through the mode change. assert!( - !matches!(&s.mode, SettingsModalMode::EditingValue { .. }), + !matches!(&s.mode(), SettingsModalMode::EditingValue { .. }), "stepper's pending edit must NOT survive the d-reset \ transition, got {:?}", - s.mode, + s.mode(), ); } @@ -6235,24 +6255,16 @@ fn d_key_in_string_editor_inserts_into_buffer() { "`d` in String editor MUST NOT dispatch a reset (no Action)", ); assert!( - matches!(s.mode, SettingsModalMode::EditingValue { .. }), + matches!(s.mode(), SettingsModalMode::EditingValue { .. }), "mode must STILL be EditingValue (no transition); got {:?}", - s.mode, + s.mode(), + ); + assert_eq!(s.editing_buffer(), Some("Grod")); + assert_eq!( + s.editing_cursor_byte(), + Some(4), + "cursor must advance past the inserted `d`", ); - match &s.mode { - SettingsModalMode::EditingValue { - buffer, - cursor_byte, - .. - } => { - assert_eq!( - buffer, "Grod", - "`d` must be inserted after `Gro`; got {buffer:?}", - ); - assert_eq!(*cursor_byte, 4, "cursor must advance past the inserted `d`",); - } - _ => unreachable!(), - } } /// Clicks OUTSIDE the @@ -6291,10 +6303,10 @@ fn click_outside_settings_breadcrumb_is_noop() { got {outcome:?}", ); assert!( - matches!(s.mode, SettingsModalMode::PickingEnum { .. }), + matches!(s.mode(), SettingsModalMode::PickingEnum { .. }), "mode must STILL be PickingEnum (no transition fired); \ got {:?}", - s.mode, + s.mode(), ); // Click 1 cell BEFORE the rect's left edge — on the // leading `─ ` decoration. @@ -7065,12 +7077,7 @@ fn max_thoughts_width_preview_only_renders_for_max_thoughts_width_key() { UiConfig::default(), PagerLocalSnapshot::default(), ); - s.mode = SettingsModalMode::EditingValue { - key: "synthetic_int", - buffer: "50".to_string(), - cursor_byte: 2, - validation_error: None, - }; + s.transition_to_editing_int("synthetic_int", "50".to_string(), 0, 200); let area = Rect { x: 0, y: 0, @@ -7281,7 +7288,7 @@ fn modal_widens_when_editing_max_thoughts_width() { /// Transitioning from `EditingValue { max_thoughts_width }` back /// to `Browse` snaps the modal back to its standard width on the /// next render frame — the widening lives in the render-time -/// `state.mode` match, not in any persistent state. +/// active-state match, not in any persistent layout state. #[test] fn modal_returns_to_default_width_when_leaving_edit_mode() { let area = Rect { diff --git a/crates/codegen/xai-grok-pager/src/views/shortcuts_help.rs b/crates/codegen/xai-grok-pager/src/views/shortcuts_help.rs index 8acb3aa..1332193 100644 --- a/crates/codegen/xai-grok-pager/src/views/shortcuts_help.rs +++ b/crates/codegen/xai-grok-pager/src/views/shortcuts_help.rs @@ -301,16 +301,14 @@ pub fn build_entries( /// key + label columns. pub fn build_initial_picker_state(entries: &[ShortcutsHelpEntry]) -> PickerState { use crate::views::picker::{PickerMode, PopupConfig}; - PickerState { - selected: entries.iter().position(|e| e.is_hint()).unwrap_or(0), - mode: PickerMode::Popup(PopupConfig { - width_pct: 0.6, - height_pct: 0.7, - min_width: 60, - min_height: 16, - }), - ..PickerState::default() - } + let mut state = PickerState::with_mode(PickerMode::Popup(PopupConfig { + width_pct: 0.6, + height_pct: 0.7, + min_width: 60, + min_height: 16, + })); + state.selected = entries.iter().position(|e| e.is_hint()).unwrap_or(0); + state } // --------------------------------------------------------------------------- @@ -577,8 +575,7 @@ pub fn detail_from_entry(entry: &ShortcutsHelpEntry) -> Option Option { let detail = detail_from_entry(entry)?; - state.query.clear(); - state.query_cursor = 0; + state.set_query(""); state.search_active = false; Some(detail) } @@ -813,7 +810,7 @@ pub fn handle_input( return ShortcutsHelpOutcome::Unchanged; } - let searching = state.search_active || !state.query.is_empty(); + let searching = state.search_active || !state.query().is_empty(); let vim_mode = crate::appearance::cache::load_vim_mode(); if !searching { @@ -827,7 +824,7 @@ pub fn handle_input( if key.code == KeyCode::Char('f') { return ShortcutsHelpOutcome::ToggleFilter; } - let filtered = filter_entries(entries, &state.query, hide_dimmed, collapsed); + let filtered = filter_entries(entries, state.query(), hide_dimmed, collapsed); if let Some(ShortcutsHelpEntry::SectionHeader { category_idx, .. }) = selected_original_entry(&filtered, entries, state.selected) { @@ -891,6 +888,9 @@ pub fn handle_input( return match handle_picker_input(&ev, state, filtered.len(), &config) { PickerOutcome::Selected(_) | PickerOutcome::Closed => ShortcutsHelpOutcome::Close, PickerOutcome::Unchanged => ShortcutsHelpOutcome::Unchanged, + PickerOutcome::Changed | PickerOutcome::QueryChanged => { + ShortcutsHelpOutcome::Changed + } _ => ShortcutsHelpOutcome::Changed, }; } @@ -898,14 +898,13 @@ pub fn handle_input( } if key.code == KeyCode::Esc { - state.query.clear(); - state.query_cursor = 0; + state.set_query(""); state.search_active = false; state.selected = 0; return ShortcutsHelpOutcome::Changed; } - let filtered = filter_entries(entries, &state.query, hide_dimmed, collapsed); + let filtered = filter_entries(entries, state.query(), hide_dimmed, collapsed); let non_sel: Vec = non_selectable_mask(&filtered, entries); let config = picker_config(&non_sel); @@ -930,6 +929,7 @@ pub fn handle_input( } PickerOutcome::Closed => ShortcutsHelpOutcome::Close, PickerOutcome::Unchanged => ShortcutsHelpOutcome::Unchanged, + PickerOutcome::Changed | PickerOutcome::QueryChanged => ShortcutsHelpOutcome::Changed, _ => ShortcutsHelpOutcome::Changed, } } @@ -960,7 +960,7 @@ pub fn handle_mouse( } } - let filtered = filter_entries(entries, &state.query, hide_dimmed, collapsed); + let filtered = filter_entries(entries, state.query(), hide_dimmed, collapsed); let non_sel: Vec = non_selectable_mask(&filtered, entries); let config = picker_config(&non_sel); @@ -986,6 +986,7 @@ pub fn handle_mouse( } PickerOutcome::Closed => ShortcutsHelpOutcome::Close, PickerOutcome::Unchanged => ShortcutsHelpOutcome::Unchanged, + PickerOutcome::Changed | PickerOutcome::QueryChanged => ShortcutsHelpOutcome::Changed, _ => ShortcutsHelpOutcome::Changed, } } @@ -1267,7 +1268,7 @@ pub fn render_modal( return; } - let rows = CheatsheetRows::build(entries, &state.query, filter_active, collapsed_sections); + let rows = CheatsheetRows::build(entries, state.query(), filter_active, collapsed_sections); let help_refs = rows.help_refs(); let picker_entries = rows.picker_entries(state, expanded_ids, &help_refs); let non_sel: Vec = vec![false; picker_entries.len()]; @@ -1285,19 +1286,18 @@ pub fn render_modal( let content_area = mca.content; let inner_x = mca.inner_x; let inner_width = mca.inner_width; - let searching = state.search_active || !state.query.is_empty(); + let searching = state.search_active || !state.query().is_empty(); let show_search_hint = !searching; - picker::render_search_bar( + picker::render_picker_search_bar( buf, content_area.x, content_area.y, content_area.width, theme, - &state.query, + state, searching, show_search_hint, - state.query_cursor, Some(theme.bg_base), ); let sep_y = content_area.y + 1; @@ -1380,10 +1380,9 @@ pub fn handle_modal_key( use crate::views::modal_window as mw; use crossterm::event::KeyCode; - let searching = state.search_active || !state.query.is_empty(); + let searching = state.search_active || !state.query().is_empty(); if mode.is_browse() && searching && key.code == KeyCode::Esc { - state.query.clear(); - state.query_cursor = 0; + state.set_query(""); state.search_active = false; state.selected = 0; return ModalKeyOutcome::Changed; @@ -1426,6 +1425,29 @@ pub fn handle_modal_key( } } +pub fn handle_paste( + text: &str, + state: &mut PickerState, + mode: &ShortcutsHelpMode, +) -> ShortcutsHelpOutcome { + if mode.is_detail() || !state.search_active { + return ShortcutsHelpOutcome::Unchanged; + } + match state.paste_query(text) { + crate::input::line_editor::LineEditOutcome::TextChanged => { + state.selected = 0; + state.selection_hidden = false; + state.scroll_offset = None; + ShortcutsHelpOutcome::Changed + } + crate::input::line_editor::LineEditOutcome::HandledNoChange + | crate::input::line_editor::LineEditOutcome::CursorChanged => { + ShortcutsHelpOutcome::Changed + } + crate::input::line_editor::LineEditOutcome::Unhandled => ShortcutsHelpOutcome::Unchanged, + } +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -2220,8 +2242,7 @@ mod tests { ]; let mut state = build_initial_picker_state(&entries); // Active search matching the hint, selection on the matching row. - state.query = "send".to_string(); - state.query_cursor = state.query.len(); + state.set_query("send"); state.search_active = true; state.selected = 1; let mut mode = browse_mode(); @@ -2237,7 +2258,7 @@ mod tests { assert_eq!(result, ShortcutsHelpOutcome::Changed); assert!(mode.is_detail(), "Enter from search opens the detail page"); assert!( - state.query.is_empty(), + state.query().is_empty(), "opening detail clears the search query" ); assert!(!state.search_active, "opening detail clears search_active"); @@ -2257,11 +2278,10 @@ mod tests { ]; let mut state = build_initial_picker_state(&entries); // Active search that still matches the hint row. - state.query = "send".to_string(); - state.query_cursor = state.query.len(); + state.set_query("send"); state.search_active = true; // Map a click at row 2 to the hint's position in the filtered view. - let filtered = filter_entries(&entries, &state.query, false, &no_collapsed()); + let filtered = filter_entries(&entries, state.query(), false, &no_collapsed()); let hint_pos = filtered .iter() .position(|&i| matches!(entries[i], ShortcutsHelpEntry::Hint { .. })) @@ -2292,7 +2312,7 @@ mod tests { assert_eq!(result, ShortcutsHelpOutcome::Changed); assert!(mode.is_detail(), "clicking a hint from search opens detail"); assert!( - state.query.is_empty(), + state.query().is_empty(), "click-open detail clears the search query" ); assert!( @@ -2875,7 +2895,7 @@ mod tests { ); assert_eq!(enter_search, ShortcutsHelpOutcome::Changed); assert!(state.search_active, "`i` must activate cheatsheet search"); - assert!(state.query.is_empty(), "`i` must not enter search text"); + assert!(state.query().is_empty(), "`i` must not enter search text"); let type_j = handle_input( &make_key(crossterm::event::KeyCode::Char('j')), @@ -2887,7 +2907,7 @@ mod tests { &mut mode, ); assert_eq!(type_j, ShortcutsHelpOutcome::Changed); - assert_eq!(state.query, "j", "printables must type in active search"); + assert_eq!(state.query(), "j", "printables must type in active search"); } // ── vim_mode tests ─────────────────────────────────────────── @@ -2915,7 +2935,7 @@ mod tests { ); assert_eq!(down, ShortcutsHelpOutcome::Changed); assert_eq!(state.selected, 2, "`j` must select the next row"); - assert!(state.query.is_empty(), "`j` must not enter search text"); + assert!(state.query().is_empty(), "`j` must not enter search text"); assert!(!state.search_active, "`j` must leave search inactive"); let up = handle_input( @@ -2929,7 +2949,7 @@ mod tests { ); assert_eq!(up, ShortcutsHelpOutcome::Changed); assert_eq!(state.selected, 1, "`k` must select the previous row"); - assert!(state.query.is_empty(), "`k` must not enter search text"); + assert!(state.query().is_empty(), "`k` must not enter search text"); assert!(!state.search_active, "`k` must leave search inactive"); } @@ -2960,7 +2980,7 @@ mod tests { ShortcutsHelpOutcome::Changed, "non-vim `{ch}` must start search" ); - assert_eq!(state.query, ch.to_string(), "non-vim `{ch}` must type"); + assert_eq!(state.query(), ch.to_string(), "non-vim `{ch}` must type"); } } @@ -3227,7 +3247,7 @@ mod tests { ShortcutsHelpOutcome::Unchanged, "vim h on a collapsed action hint must be inert" ); - assert!(state.query.is_empty(), "vim h must not enter search text"); + assert!(state.query().is_empty(), "vim h must not enter search text"); let key_id = ExpandKey::Action(ActionId::SendPrompt); let expanded = std::collections::HashSet::from([key_id]); @@ -3245,7 +3265,7 @@ mod tests { ShortcutsHelpOutcome::ToggleExpand(key_id), "vim h must collapse an expanded action hint" ); - assert!(state.query.is_empty(), "vim h must not enter search text"); + assert!(state.query().is_empty(), "vim h must not enter search text"); } #[test] @@ -3324,7 +3344,7 @@ mod tests { ShortcutsHelpOutcome::ToggleExpand(key_id), "vim l must expand the paste pseudo-row" ); - assert!(state.query.is_empty(), "vim l must not enter search text"); + assert!(state.query().is_empty(), "vim l must not enter search text"); let expanded = std::collections::HashSet::from([key_id]); let collapse = handle_input( @@ -3341,7 +3361,7 @@ mod tests { ShortcutsHelpOutcome::ToggleExpand(key_id), "vim h must collapse the expanded paste pseudo-row" ); - assert!(state.query.is_empty(), "vim h must not enter search text"); + assert!(state.query().is_empty(), "vim h must not enter search text"); } /// `handle_modal_key` (chrome + picker pipeline) maps the hint-row expand to diff --git a/crates/codegen/xai-grok-pager/src/views/subagent_catalog_pane.rs b/crates/codegen/xai-grok-pager/src/views/subagent_catalog_pane.rs index de98866..fe19b2b 100644 --- a/crates/codegen/xai-grok-pager/src/views/subagent_catalog_pane.rs +++ b/crates/codegen/xai-grok-pager/src/views/subagent_catalog_pane.rs @@ -221,6 +221,10 @@ impl SubagentCatalogPane { self.list_state.handle_key_event(key, &self.entries) } + pub fn handle_paste(&mut self, text: &str) -> bool { + self.list_state.handle_paste(text, &self.entries) + } + pub fn handle_scroll(&mut self, lines: i32, col: u16, row: u16) { let max = match self.list_state.viewport_height() { 0..=5 => 1, diff --git a/crates/codegen/xai-grok-pager/src/views/tasks_pane.rs b/crates/codegen/xai-grok-pager/src/views/tasks_pane.rs index 7edf31f..f726134 100644 --- a/crates/codegen/xai-grok-pager/src/views/tasks_pane.rs +++ b/crates/codegen/xai-grok-pager/src/views/tasks_pane.rs @@ -1085,6 +1085,10 @@ impl TasksPane { self.list_state.handle_key_event(key, &self.entries) } + pub fn handle_paste(&mut self, text: &str) -> bool { + self.list_state.handle_paste(text, &self.entries) + } + pub fn handle_scroll(&mut self, lines: i32, col: u16, row: u16) { let max = match self.list_state.viewport_height() { 0..=5 => 1, diff --git a/crates/codegen/xai-grok-pager/src/views/todo_pane.rs b/crates/codegen/xai-grok-pager/src/views/todo_pane.rs index 78b7559..c56aeca 100644 --- a/crates/codegen/xai-grok-pager/src/views/todo_pane.rs +++ b/crates/codegen/xai-grok-pager/src/views/todo_pane.rs @@ -433,6 +433,10 @@ impl TodoPane { self.list_state.handle_key_event(key, &self.entries) } + pub fn handle_paste(&mut self, text: &str) -> bool { + self.list_state.handle_paste(text, &self.entries) + } + /// Handle a mouse scroll event over the todo pane area. /// /// Caps scroll speed for small viewports — the app-level scroll diff --git a/crates/codegen/xai-grok-pager/src/views/turn_status.rs b/crates/codegen/xai-grok-pager/src/views/turn_status.rs index cb88250..8a9c924 100644 --- a/crates/codegen/xai-grok-pager/src/views/turn_status.rs +++ b/crates/codegen/xai-grok-pager/src/views/turn_status.rs @@ -91,48 +91,54 @@ pub struct MouseButtons { } /// Counts of idle-surviving "watcher" work — background jobs that can wake -/// the agent for a new turn while it sits idle. Running `monitor` tasks emit -/// events, scheduled `/loop` tasks fire prompts on a timer, and running -/// background subagents inject a `subagent-completed-…` turn when they finish -/// — each can start a new turn, so they share one persistent "watching" cue -/// above the prompt. Broader than the tasks-pane `Watchers` group (which is -/// monitors + loops only); subagents are included here because they too -/// auto-wake the agent. +/// the agent for a new turn while it sits idle (commands and monitors on +/// completion/events, `/loop` tasks on a timer, background subagents on +/// finish). They share one persistent "watching" cue above the prompt. +/// Broader than the tasks-pane `Watchers` group (monitors + loops only). #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct Watchers { + /// Running background commands (non-monitor `background: true` tasks). + pub commands: usize, /// Running `monitor` background tasks. pub monitors: usize, /// Active scheduled `/loop` tasks. pub loops: usize, - /// Running background subagents (they auto-wake the parent on completion). - /// While the agent is idle, any running subagent is a background one — a - /// foreground subagent would keep the parent in `TurnRunning`. + /// Running background subagents. While the agent is idle, any running + /// subagent is a background one — a foreground subagent would keep the + /// parent in `TurnRunning`. pub subagents: usize, } impl Watchers { /// Total watcher count across all kinds. pub fn total(self) -> usize { - self.monitors + self.loops + self.subagents + self.commands + self.monitors + self.loops + self.subagents + } + + /// Awaitable in-flight work — the kinds a blocking `wait_tasks` / + /// `get_task_output` wait can resolve on (commands, monitors, subagents; + /// scheduled `/loop` tasks are timers, not awaitable work). + pub fn awaitable_work(self) -> usize { + self.commands + self.monitors + self.subagents } } /// Build the "watching · …" label for the idle watcher cue, listing only the /// non-zero kinds with correct singular/plural nouns — e.g. -/// `"watching · 2 monitors · 1 loop · 1 subagent"`. Assumes +/// `"watching · 1 command · 2 monitors · 1 loop · 1 subagent"`. Assumes /// `watchers.total() > 0`. -/// -/// Every scheduled task can wake the agent, so all of them are counted as -/// `loops` (today every scheduled task is `/loop`-tagged; see -/// `ScheduledTaskInfo`). The label is built in a single `String` — no -/// intermediate `Vec`/`join` — because the turn-status line re-renders every -/// frame, keeping the idle cue's churn to one allocation (cf. the -/// static-`&str` right-side arms below). fn watching_label(watchers: Watchers) -> String { use std::fmt::Write as _; - // "watching" stem, then " · N noun" appended for each non-zero kind. let mut label = String::with_capacity(32); label.push_str("watching"); + if watchers.commands > 0 { + let noun = if watchers.commands == 1 { + "command" + } else { + "commands" + }; + let _ = write!(label, " \u{00b7} {} {noun}", watchers.commands); + } if watchers.monitors > 0 { let noun = if watchers.monitors == 1 { "monitor" @@ -191,6 +197,10 @@ pub fn is_sendable_wait(activity: &Option) -> bool { /// `[stop]` / `[↓]` buttons with their hover state; `None` for a keyboard-only /// host (minimal mode — no mouse capture), which suppresses both buttons. /// - `total_tokens`: Total tokens used (context window usage), shown as `⇣Nk`. +/// - `parked`: the turn is parked on a sendable wait and renders the stopped +/// look (`AgentView::renders_parked`). The running-turn chrome is suppressed; +/// only the "watching · …" cue renders (the parked turn is by definition +/// waiting on background work, so the cue explains the idle-looking chrome). /// - `flat_background`: when `true`, right-side timer/buttons use a transparent /// (`Color::Reset`) background instead of `theme.bg_base`, so the row blends /// with the terminal's own background (minimal mode). @@ -215,6 +225,7 @@ pub fn render_turn_status( is_pending_user_input: bool, goal_verifying: bool, watchers: Watchers, + parked: bool, flat_background: bool, held_queue: usize, held_queue_top_sendable: bool, @@ -263,14 +274,10 @@ pub fn render_turn_status( return TurnStatusOutput::default(); } - // Special case: agent idle but background watchers (monitors, scheduled - // `/loop` tasks, and/or running background subagents) are still alive. - // Monitors emit events, loops fire prompts on a timer, and subagents - // inject a completion turn on finish — any can wake the agent for another - // turn — so keep a persistent cue above the prompt (unlike a scrollback - // line, it never scrolls away). Lower priority than the starting-session - // and drain-blocked cues handled above. - if state.is_idle() && watchers.total() > 0 { + // Idle or parked with watchers: persistent watching cue (not scrollback + // — it must never scroll away). Lower priority than the starting-session + // and drain-blocked cues above. + if (state.is_idle() || parked) && watchers.total() > 0 { // Pulsing concentric circle (○ ◎ ◉ ◎) on a calm ambient cadence: // the agent is idle, so this "watching" breath runs slower than the // active turn spinner (see MONITOR_PULSE_DIVISOR). @@ -287,6 +294,13 @@ pub fn render_turn_status( return TurnStatusOutput::default(); } + // Parked with no watchers left: render nothing. The stopped look must + // never fall through to the running-turn chrome (spinner/timers/[stop]) + // — the wait aborts the moment the user types, so that chrome would lie. + if parked { + return TurnStatusOutput::default(); + } + // Determine if cancel button should be shown. // Show when: TurnRunning or CommandRunning. // Hide when: Idle, Cancelling (already cancelling), or a keyboard-only host @@ -738,9 +752,13 @@ fn render_starting_session( /// is blocked (agent idle, waiting on user edit), while the MCP startup seed /// is showing "Starting session…" (a fresh `total == 0` seed), or when the /// agent is idle but background watchers are still running -/// (`watchers.total() > 0`) — running monitors emit events, scheduled `/loop` -/// tasks fire prompts, and background subagents inject a completion turn, any -/// of which can start a new turn. +/// (`watchers.total() > 0`) — running commands and monitors wake the agent on +/// completion/events, scheduled `/loop` tasks fire prompts, and background +/// subagents inject a completion turn, any of which can start a new turn. +/// +/// A parked turn (`parked` — the stopped look while blocked on a sendable +/// wait) suppresses the running-turn chrome entirely: the row shows only when +/// watchers exist, rendering the "watching · …" cue. /// /// Real MCP progress (`total > 0`) renders as a compact chip in the top status /// bar instead, so it does not affect this row. @@ -749,7 +767,11 @@ pub fn should_show( drain_blocked: bool, mcp_init_progress: Option<&McpInitProgress>, watchers: Watchers, + parked: bool, ) -> bool { + if parked { + return watchers.total() > 0; + } !state.is_idle() || drain_blocked || starting_session_visible(mcp_init_progress) @@ -949,19 +971,22 @@ mod tests { &AgentState::TurnRunning, false, None, - Watchers::default() + Watchers::default(), + false )); assert!(should_show( &AgentState::TurnCancelling, false, None, - Watchers::default() + Watchers::default(), + false )); assert!(!should_show( &AgentState::Idle, false, None, - Watchers::default() + Watchers::default(), + false )); } @@ -971,47 +996,66 @@ mod tests { &AgentState::Idle, true, None, - Watchers::default() + Watchers::default(), + false )); } #[test] fn should_show_when_watchers_running() { - // Idle but a watcher (monitor, loop, or subagent) is still running → - // row stays visible so the persistent "watching · …" cue can show. - assert!(should_show( - &AgentState::Idle, - false, - None, + // Idle but a watcher (command, monitor, loop, or subagent) is still + // running → row stays visible so the persistent "watching · …" cue + // can show. + for watchers in [ + Watchers { + commands: 1, + ..Watchers::default() + }, Watchers { monitors: 1, ..Watchers::default() - } - )); - assert!(should_show( - &AgentState::Idle, - false, - None, + }, Watchers { loops: 1, ..Watchers::default() - } - )); - assert!(should_show( - &AgentState::Idle, - false, - None, + }, Watchers { subagents: 1, ..Watchers::default() - } - )); + }, + ] { + assert!(should_show(&AgentState::Idle, false, None, watchers, false)); + } // Idle with no watchers and nothing else pending → hidden. assert!(!should_show( &AgentState::Idle, false, None, - Watchers::default() + Watchers::default(), + false + )); + } + + #[test] + fn should_show_parked_only_with_watchers() { + // Parked (turn running but rendering the stopped look): the row shows + // only to carry the "watching · …" cue — never the running chrome. + assert!(should_show( + &AgentState::TurnRunning, + false, + None, + Watchers { + commands: 1, + ..Watchers::default() + }, + true + )); + assert!(!should_show( + &AgentState::TurnRunning, + false, + None, + Watchers::default(), + true )); } @@ -1027,7 +1071,8 @@ mod tests { &AgentState::Idle, false, Some(&seed), - Watchers::default() + Watchers::default(), + false )); // Real progress (total > 0) is the top-bar chip — it must NOT drive @@ -1041,7 +1086,8 @@ mod tests { &AgentState::Idle, false, Some(&connecting), - Watchers::default() + Watchers::default(), + false )); // An expired seed must not drive the row either. @@ -1054,7 +1100,8 @@ mod tests { &AgentState::Idle, false, Some(&expired), - Watchers::default() + Watchers::default(), + false )); } @@ -1092,6 +1139,7 @@ mod tests { false, Watchers::default(), false, + false, 0, false, ); @@ -1121,6 +1169,37 @@ mod tests { false, watchers, false, + false, + 0, + false, + ); + buffer_text(&buf, area) + } + + /// Invoke `render_turn_status` for a PARKED running turn (the stopped + /// look) with the given watcher counts. + fn render_parked_with_watchers(watchers: Watchers) -> String { + let area = Rect::new(0, 0, 60, 1); + let mut buf = Buffer::empty(area); + render_turn_status( + &mut buf, + area, + &AgentState::TurnRunning, + &Some(TurnActivity::Waiting(WaitingReason::TasksComplete)), + Some(Duration::from_secs(5)), + None, + 0, + false, + Some(MouseButtons::default()), + false, + None, + None, + false, + false, + false, + watchers, + true, + false, 0, false, ); @@ -1243,19 +1322,72 @@ mod tests { #[test] fn idle_with_all_watcher_kinds_lists_all() { - // Monitors, loops, and subagents present → one cue lists all three in - // order (monitors → loops → subagents), middle-dot separated. + // Commands, monitors, loops, and subagents present → one cue lists + // all four in order, middle-dot separated. let text = render_idle_with_watchers(Watchers { + commands: 1, monitors: 2, loops: 1, subagents: 3, }); assert!( - text.contains("watching \u{00b7} 2 monitors \u{00b7} 1 loop \u{00b7} 3 subagents"), + text.contains( + "watching \u{00b7} 1 command \u{00b7} 2 monitors \u{00b7} 1 loop \u{00b7} 3 subagents" + ), "all kinds must be listed in one cue, got: {text:?}" ); } + #[test] + fn idle_with_commands_renders_watching_line() { + // Plain background commands (non-monitor bg tasks) count as watchers: + // they wake the agent with a task-completed turn, so the cue must show. + let text = render_idle_with_watchers(Watchers { + commands: 2, + ..Watchers::default() + }); + assert!( + text.contains("watching \u{00b7} 2 commands"), + "idle with bg commands must render the watching cue, got: {text:?}" + ); + let text = render_idle_with_watchers(Watchers { + commands: 1, + ..Watchers::default() + }); + assert!( + text.contains("watching \u{00b7} 1 command") && !text.contains("commands"), + "single command must use the singular noun, got: {text:?}" + ); + } + + #[test] + fn parked_with_watchers_renders_watching_not_running_chrome() { + // A parked running turn renders the watching cue — never the busy + // spinner/timers/[stop] chrome (the wait aborts as soon as the user + // types, so that chrome would lie). + let text = render_parked_with_watchers(Watchers { + commands: 2, + ..Watchers::default() + }); + assert!( + text.contains("watching \u{00b7} 2 commands"), + "parked with bg work must render the watching cue, got: {text:?}" + ); + assert!( + !text.contains("Waiting") && !text.contains("[stop]"), + "parked must not render the running-turn chrome, got: {text:?}" + ); + } + + #[test] + fn parked_without_watchers_renders_nothing() { + let text = render_parked_with_watchers(Watchers::default()); + assert!( + text.trim().is_empty(), + "parked with no watchers must render nothing, got: {text:?}" + ); + } + #[test] fn idle_with_no_watchers_renders_nothing() { let text = render_idle_with_watchers(Watchers::default()); @@ -1287,6 +1419,7 @@ mod tests { false, Watchers::default(), false, + false, 1, true, ); @@ -1299,6 +1432,13 @@ mod tests { #[test] fn watching_label_lists_only_nonzero_kinds() { + assert_eq!( + watching_label(Watchers { + commands: 2, + ..Watchers::default() + }), + "watching \u{00b7} 2 commands" + ); assert_eq!( watching_label(Watchers { monitors: 2, @@ -1330,11 +1470,12 @@ mod tests { ); assert_eq!( watching_label(Watchers { + commands: 1, monitors: 1, loops: 1, subagents: 2, }), - "watching \u{00b7} 1 monitor \u{00b7} 1 loop \u{00b7} 2 subagents" + "watching \u{00b7} 1 command \u{00b7} 1 monitor \u{00b7} 1 loop \u{00b7} 2 subagents" ); } diff --git a/crates/codegen/xai-grok-pager/src/views/welcome/mod.rs b/crates/codegen/xai-grok-pager/src/views/welcome/mod.rs index a28db2b..8e9577e 100644 --- a/crates/codegen/xai-grok-pager/src/views/welcome/mod.rs +++ b/crates/codegen/xai-grok-pager/src/views/welcome/mod.rs @@ -11,6 +11,8 @@ use ratatui::layout::{Alignment, Constraint, Flex, Layout, Position, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Padding, Paragraph, Widget, Wrap}; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; use crate::app::app_view::{AuthMode, AuthState, SessionPickerEntry, TrustState}; use crate::startup::StartupWarning; @@ -589,7 +591,8 @@ pub struct WelcomeRenderParams<'a> { pub trust_state: &'a TrustState, pub login_label: Option<&'a str>, pub auth_code_input: &'a str, - pub clipboard_copied: bool, + pub auth_code_cursor_byte: usize, + pub clipboard_delivery: Option, pub show_raw_url: bool, pub announcement: Option<&'a xai_grok_announcements::RemoteAnnouncement>, pub tip: Option<&'a str>, @@ -733,7 +736,8 @@ pub fn render_welcome( auth_url.as_deref(), *mode, params.auth_code_input, - params.clipboard_copied, + params.auth_code_cursor_byte, + params.clipboard_delivery, params.show_raw_url, ); WelcomeRenderResult { @@ -1062,18 +1066,30 @@ fn auth_fallback_line(theme: &Theme) -> Line<'static> { .alignment(Alignment::Center) } -/// Push the shared copy-prompt block: the "click here to copy" line, a "copied!" -/// slot (kept blank when not copied so the height is stable), and the -/// show-full-URL fallback link. -fn push_auth_copy_block(lines: &mut Vec>, theme: &Theme, clipboard_copied: bool) { +/// Push the shared copy-prompt block, stable feedback slot, and raw-URL fallback. +fn push_auth_copy_block( + lines: &mut Vec>, + theme: &Theme, + clipboard_delivery: Option, +) { lines.push(Line::default()); lines.push(auth_copy_line(theme)); lines.push(Line::default()); - lines.push(if clipboard_copied { - Line::from(Span::styled("copied!", Style::default().fg(theme.gray))) - .alignment(Alignment::Center) - } else { - Line::default() + lines.push(match clipboard_delivery { + Some(crate::clipboard::ClipboardDelivery::Confirmed) => { + Line::from(Span::styled("copied!", Style::default().fg(theme.gray))) + .alignment(Alignment::Center) + } + Some(crate::clipboard::ClipboardDelivery::Unverified) => Line::from(Span::styled( + "copy sent—verify paste", + Style::default().fg(theme.gray), + )) + .alignment(Alignment::Center), + Some(crate::clipboard::ClipboardDelivery::Failed) => { + Line::from(Span::styled("copy failed", Style::default().fg(theme.gray))) + .alignment(Alignment::Center) + } + None => Line::default(), }); lines.push(Line::default()); lines.push(auth_fallback_line(theme)); @@ -1231,7 +1247,7 @@ fn render_browser_status_arm( logo_line_count: u16, auth_url: Option<&str>, show_raw_url: bool, - clipboard_copied: bool, + clipboard_delivery: Option, kind: BrowserStatusKind, ) -> (Option, Option) { let h_pad: u16 = content_area.width / 6; @@ -1303,7 +1319,7 @@ fn render_browser_status_arm( ); } if auth_url.is_some() { - push_auth_copy_block(&mut lines, theme, clipboard_copied); + push_auth_copy_block(&mut lines, theme, clipboard_delivery); } lines.push(Line::default()); lines.push( @@ -1337,7 +1353,8 @@ fn render_welcome_authenticating( auth_url: Option<&str>, mode: AuthMode, auth_code_input: &str, - clipboard_copied: bool, + auth_code_cursor_byte: usize, + clipboard_delivery: Option, show_raw_url: bool, ) -> (Option, Option) { let top_pad = content_area.height.saturating_sub(logo_line_count) / 10; @@ -1390,7 +1407,7 @@ fn render_welcome_authenticating( )) .alignment(Alignment::Center), ); - push_auth_copy_block(&mut lines, theme, clipboard_copied); + push_auth_copy_block(&mut lines, theme, clipboard_delivery); } else { lines.push( Line::from(Span::styled( @@ -1420,7 +1437,13 @@ fn render_welcome_authenticating( ]) .flex(Flex::Center) .areas(prompt_area); - render_auth_input_box(prompt_centered, buf, theme, auth_code_input); + render_auth_input_box( + prompt_centered, + buf, + theme, + auth_code_input, + auth_code_cursor_byte, + ); // Hints let mut hint_spans = vec![ @@ -1447,7 +1470,7 @@ fn render_welcome_authenticating( logo_line_count, auth_url, show_raw_url, - clipboard_copied, + clipboard_delivery, BrowserStatusKind::Command, ), @@ -1459,7 +1482,7 @@ fn render_welcome_authenticating( logo_line_count, auth_url, show_raw_url, - clipboard_copied, + clipboard_delivery, BrowserStatusKind::Device, ), @@ -2188,7 +2211,7 @@ pub(crate) fn render_session_picker( // this render disagrees with `handle_welcome_input`'s `build_entry_map` // (which receives the effective query) on row indices. let filter_query = - crate::views::session_picker::effective_filter_query(&ctx.state.query, ctx.entries_query); + crate::views::session_picker::effective_filter_query(ctx.state.query(), ctx.entries_query); let filtered_indices = crate::app::app_view::filter_session_entries(ctx.sessions, filter_query, ctx.source_filter); @@ -2392,7 +2415,13 @@ pub(crate) fn render_session_picker( } /// Render the auth token input box (loopback mode). -fn render_auth_input_box(area: Rect, buf: &mut Buffer, theme: &Theme, input: &str) { +fn render_auth_input_box( + area: Rect, + buf: &mut Buffer, + theme: &Theme, + input: &str, + cursor_byte: usize, +) { let prompt_block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(theme.accent_user)) @@ -2406,7 +2435,11 @@ fn render_auth_input_box(area: Rect, buf: &mut Buffer, theme: &Theme, input: &st prompt_block.render(area, buf); if inner.height > 0 && inner.width > 2 { - let display = mask_auth_token_for_display(input); + let prompt = crate::glyphs::prompt_arrow(); + let prompt_width = prompt.width() as u16; + let input_width = inner.width.saturating_sub(prompt_width); + let (display, cursor_column) = + masked_auth_token_view(input, cursor_byte, input_width as usize); let style = if input.is_empty() { Style::default().fg(theme.gray_dim) @@ -2415,13 +2448,16 @@ fn render_auth_input_box(area: Rect, buf: &mut Buffer, theme: &Theme, input: &st }; let line = Line::from(vec![ - Span::styled( - crate::glyphs::prompt_arrow(), - Style::default().fg(theme.accent_user), - ), + Span::styled(prompt, Style::default().fg(theme.accent_user)), Span::styled(display, style), ]); buf.set_line(inner.x, inner.y, &line, inner.width); + if input_width > 0 { + let cursor_x = inner.x + prompt_width + cursor_column as u16; + if let Some(cell) = buf.cell_mut((cursor_x, inner.y)) { + cell.set_style(Style::default().fg(theme.bg_base).bg(theme.text_primary)); + } + } } } @@ -2471,20 +2507,48 @@ fn render_startup_warnings( None } -fn mask_auth_token_for_display(input: &str) -> String { - use crate::render::line_utils::floor_char_boundary; +fn auth_token_grapheme_visible(index: usize, total: usize) -> bool { + total <= 8 || index + 4 >= total +} +struct MaskedAuthToken { + display: String, + cursor_byte: usize, +} + +fn build_masked_auth_token(input: &str, cursor_byte: usize) -> MaskedAuthToken { + let graphemes: Vec<(usize, &str)> = input.grapheme_indices(true).collect(); + let total = graphemes.len(); + let mut display = String::new(); + let mut mapped_cursor = None; + for (index, (byte, grapheme)) in graphemes.into_iter().enumerate() { + if byte == cursor_byte { + mapped_cursor = Some(display.len()); + } + if auth_token_grapheme_visible(index, total) { + display.push_str(grapheme); + } else { + display.push('\u{2022}'); + } + } + MaskedAuthToken { + cursor_byte: mapped_cursor.unwrap_or(display.len()), + display, + } +} + +fn masked_auth_token_view(input: &str, cursor_byte: usize, width: usize) -> (String, usize) { if input.is_empty() { - return "Paste your token here...".to_string(); + return ("Paste your token here...".to_string(), 0); } - let len = input.len(); - if len <= 8 { - return input.to_string(); - } - let boundary = floor_char_boundary(input, len - 4); - let visible = &input[boundary..]; - let masked_count = input[..boundary].chars().count(); - format!("{}{}", "\u{2022}".repeat(masked_count), visible) + let masked = build_masked_auth_token(input, cursor_byte); + let buffer = + xai_ratatui_textarea::EditBuffer::from_parts(masked.display.as_str(), masked.cursor_byte); + let viewport = buffer.single_line_viewport(width); + ( + masked.display[viewport.visible_byte_range].to_owned(), + viewport.cursor_display_column, + ) } #[cfg(test)] @@ -2495,17 +2559,97 @@ mod tests { use crate::views::session_picker::{build_grouped_picker_entries, build_session_entry_data}; #[test] - fn mask_auth_token_cases() { - assert_eq!(mask_auth_token_for_display(""), "Paste your token here..."); - assert_eq!(mask_auth_token_for_display("12345678"), "12345678"); + fn auth_copy_feedback_covers_delivery_states() { + let theme = Theme::current(); + for (delivery, expected) in [ + (crate::clipboard::ClipboardDelivery::Confirmed, "copied!"), + ( + crate::clipboard::ClipboardDelivery::Unverified, + "copy sent—verify paste", + ), + (crate::clipboard::ClipboardDelivery::Failed, "copy failed"), + ] { + let mut lines = Vec::new(); + push_auth_copy_block(&mut lines, &theme, Some(delivery)); + let feedback = lines[3] + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + assert_eq!(feedback, expected); + } + } - let masked = mask_auth_token_for_display("abcdefghij"); - assert!(masked.ends_with("ghij")); - assert!(masked.starts_with("\u{2022}")); + #[test] + fn masked_auth_token_preserves_reveal_policy() { + assert_eq!( + masked_auth_token_view("", 0, 24), + ("Paste your token here...".to_string(), 0) + ); + assert_eq!(build_masked_auth_token("12345678", 8).display, "12345678"); + assert_eq!(build_masked_auth_token("123456789", 9).display, "•••••6789"); - // Regression: multi-byte input panicked on byte-index slicing - let masked = mask_auth_token_for_display("测试令牌一二三四五六"); - assert!(masked.starts_with("\u{2022}")); + let input = "abcdefghMIDDLEwxyz"; + let masked = build_masked_auth_token(input, input.len()).display; + assert!(masked.starts_with("••••")); + assert!(masked.ends_with("wxyz")); + assert!(!masked.contains("MIDDLE")); + assert!(masked.contains("\u{2022}")); + + let input = "测试令牌一二三四五六七八九十"; + let masked = build_masked_auth_token(input, input.len()).display; + assert!(masked.starts_with("••••")); + assert!(masked.contains("\u{2022}")); + } + + #[test] + fn masked_auth_mapping_handles_zero_width_combining_and_zwj_middle() { + let prefix = "abcdefgh"; + let hidden = "\u{200b}e\u{301}👩🏽\u{200d}💻MID"; + let suffix = "wxyz"; + let token = format!("{prefix}{hidden}{suffix}"); + let before = prefix.len(); + let inside = prefix.len() + "\u{200b}e\u{301}".len(); + let after = prefix.len() + hidden.len(); + let expected = format!("{}{}", "\u{2022}".repeat(14), suffix); + + let before_masked = build_masked_auth_token(&token, before); + let inside_masked = build_masked_auth_token(&token, inside); + let after_masked = build_masked_auth_token(&token, after); + assert_eq!(before_masked.display, expected); + assert_eq!(inside_masked.display, expected); + assert_eq!(after_masked.display, expected); + assert_eq!(before_masked.cursor_byte, "\u{2022}".len() * 8); + assert_eq!(inside_masked.cursor_byte, "\u{2022}".len() * 10); + assert_eq!(after_masked.cursor_byte, "\u{2022}".len() * 14); + + for width in [1, 2, 5] { + for cursor in [before, inside, after] { + let (view, cursor_column) = masked_auth_token_view(&token, cursor, width); + assert!(view.width() <= width); + assert!(cursor_column < width); + assert!(!view.contains('\u{200b}')); + assert!(!view.contains("e\u{301}")); + assert!(!view.contains("👩🏽\u{200d}💻")); + assert!(!view.contains("MID")); + } + } + + let wide_prefix = "中bcdefgh"; + let wide_token = format!("{wide_prefix}HIDDEN{suffix}"); + let (_, cursor_column) = masked_auth_token_view(&wide_token, wide_prefix.len(), 40); + assert_eq!(cursor_column, wide_prefix.graphemes(true).count()); + } + + #[test] + fn masked_auth_render_keeps_narrow_caret_visible() { + let token = "abcdefghSECRET-MIDDLEwxyz"; + let cursor = "abcdefghSECRET".len(); + let area = Rect::new(0, 0, 9, 3); + let theme = Theme::current(); + let mut buffer = Buffer::empty(area); + render_auth_input_box(area, &mut buffer, &theme, token, cursor); + assert!((0..area.width).any(|x| buffer[(x, 1)].bg == theme.text_primary)); } fn make_entry(id: &str, summary: &str, repo_name: &str) -> SessionPickerEntry { @@ -2538,7 +2682,8 @@ mod tests { trust_state, login_label: None, auth_code_input: "", - clipboard_copied: false, + auth_code_cursor_byte: 0, + clipboard_delivery: None, show_raw_url: false, announcement: None, tip: None, @@ -2721,10 +2866,8 @@ mod tests { let render = |entries_query: Option<&str>| -> String { let mut buf = Buffer::empty(area); - let mut state = PickerState { - query: "hit".into(), - ..PickerState::default() - }; + let mut state = PickerState::default(); + state.set_query("hit"); render_session_picker( area, &mut buf, @@ -2944,15 +3087,12 @@ mod tests { use crate::views::picker::{PickerOutcome, handle_picker_input}; use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; - let mut state = PickerState { - search_active: true, - ..PickerState::default() - }; + let mut state = PickerState::input_active(); let config = resume_picker_config(); let ev = Event::Key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)); let outcome = handle_picker_input(&ev, &mut state, 3, &config); - assert!(matches!(outcome, PickerOutcome::Changed)); - assert_eq!(state.query, "e"); + assert!(matches!(outcome, PickerOutcome::QueryChanged)); + assert_eq!(state.query(), "e"); } #[test] @@ -3466,8 +3606,9 @@ mod tests { logo_line_count(area.height), Some(url), AuthMode::Device, - "", // auth_code_input — unused in device mode - false, // clipboard_copied + "", // auth_code_input — unused in device mode + 0, + None, // clipboard_delivery false, // show_raw_url ); @@ -3521,7 +3662,8 @@ mod tests { Some(url), AuthMode::Device, "", - false, + 0, + None, true, // show_raw_url ); @@ -3547,7 +3689,8 @@ mod tests { Some(url), AuthMode::Device, "", - false, + 0, + None, true, // show_raw_url ); @@ -3584,7 +3727,8 @@ mod tests { Some(url), AuthMode::Device, "", - false, + 0, + None, true, // show_raw_url ); @@ -3622,8 +3766,9 @@ mod tests { logo_line_count(area.height), Some(url), AuthMode::Command, - "", // auth_code_input — unused - false, // clipboard_copied + "", // auth_code_input — unused + 0, + None, // clipboard_delivery false, // show_raw_url ); diff --git a/crates/codegen/xai-grok-pager/tests/pty_e2e/auto_wake_cancel_preserves_queued_user_prompt.rs b/crates/codegen/xai-grok-pager/tests/pty_e2e/auto_wake_cancel_preserves_queued_user_prompt.rs index 86e0db6..7b200ee 100644 --- a/crates/codegen/xai-grok-pager/tests/pty_e2e/auto_wake_cancel_preserves_queued_user_prompt.rs +++ b/crates/codegen/xai-grok-pager/tests/pty_e2e/auto_wake_cancel_preserves_queued_user_prompt.rs @@ -22,6 +22,12 @@ use super::common::*; #[cfg(unix)] const CLARIFY_MARKER: &str = "CLARIFY_MARKER_XYZ"; +#[cfg(unix)] +const POST_CANCEL_MARKER: &str = "POST_CANCEL_MARKER_XYZ"; + +#[cfg(unix)] +const UNWANTED_AUTO_WAKE_SENTINEL: &str = "UNWANTED_AUTO_WAKE_SENTINEL_XYZ"; + /// Background sleep that triggers the auto-wake on completion. Long enough /// that turn 1 settles and the auto-wake scripts are enqueued before it fires, /// even on a loaded CI host. @@ -260,3 +266,203 @@ async fn auto_wake_cancel_preserves_queued_user_prompt() { full contents:\n{resumed_full_text}" ); } + +#[cfg(unix)] +fn unified_log_diagnostics(content: &ContentController) -> String { + let path = content.home().join(".grok/logs/unified.jsonl"); + let log = std::fs::read_to_string(path).unwrap_or_default(); + let mut tail: Vec<&str> = log.lines().rev().take(80).collect(); + tail.reverse(); + let relevant = log + .lines() + .filter(|line| line.contains("task_wake") || line.contains("shell.cancel")) + .collect::>() + .join("\n"); + format!( + "{}\n--- all task_wake / shell.cancel lines ---\n{relevant}", + tail.join("\n") + ) +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"] +async fn cancel_before_task_completion_defers_auto_wake_until_user_prompt() { + let content = ContentController::start().await.expect("start content"); + + let bg_done_flag = content.home().join("post_cancel_bg_done"); + let bg_command = format!( + "while [ ! -e {} ]; do /bin/sleep 0.2; done", + bg_done_flag.display() + ); + let bg_args = json!({ + "command": bg_command, + "description": "post-cancel completion", + "is_background": true + }) + .to_string(); + content.enqueue_response( + "/v1/responses", + ScriptedResponse::sse(responses_api_tool_call_events( + "call_bg_after_cancel", + "run_terminal_command", + &bg_args, + )), + ); + content.enqueue_response( + "/v1/chat/completions", + ScriptedResponse::sse(chat_completions_tool_call_events_with_id( + "call_bg_after_cancel", + "run_terminal_command", + &bg_args, + )), + ); + + let hold_started_flag = content.home().join("post_cancel_hold_started"); + let hold_command = format!( + ": > {}; while true; do /bin/sleep 0.2; done", + hold_started_flag.display() + ); + let hold_args = json!({ + "command": hold_command, + "description": "ordinary turn hold" + }) + .to_string(); + content.enqueue_response( + "/v1/responses", + ScriptedResponse::sse(responses_api_tool_call_events( + "call_hold_after_bg", + "run_terminal_command", + &hold_args, + )), + ); + content.enqueue_response( + "/v1/chat/completions", + ScriptedResponse::sse(chat_completions_tool_call_events_with_id( + "call_hold_after_bg", + "run_terminal_command", + &hold_args, + )), + ); + content.set_response(UNWANTED_AUTO_WAKE_SENTINEL); + + let binary = pager_binary().expect("resolve pager binary"); + let mut harness = PtyHarness::spawn_with_content_in_dir( + &binary, + DEFAULT_ROWS, + DEFAULT_COLS, + &content, + &["--yolo", "--trust"], + Some(content.home()), + ) + .expect("spawn pager"); + + harness + .wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT) + .expect("welcome"); + harness + .inject_keys(format!("{PROMPT}\r").as_bytes()) + .expect("submit prompt"); + + let task_id = poll_for(Duration::from_secs(30), || { + content + .request_bodies() + .iter() + .find_map(|body| extract_task_id(&body.to_string())) + }) + .unwrap_or_else(|| { + panic!( + "background task never started\n--- non-system messages ---\n{}", + dump_non_system_messages(&content.request_bodies()) + ) + }); + let follow_up_started = poll_for(Duration::from_secs(15), || { + hold_started_flag.exists().then_some(()) + }) + .is_some(); + assert!( + follow_up_started, + "foreground hold never started\n--- non-system messages ---\n{}", + dump_non_system_messages(&content.request_bodies()) + ); + + harness.inject_keys(keys::CTRL_C).expect("press ctrl+c"); + harness + .wait_for_text("Turn cancelled by user", Duration::from_secs(15)) + .expect("ordinary turn cancelled"); + harness + .wait_for_turn_idle(Duration::from_secs(15)) + .expect("cancelled turn idle"); + assert!( + !harness.contains_full_text("Task completed in"), + "background task completed before release" + ); + std::fs::write(&bg_done_flag, b"done").expect("complete background task"); + harness + .wait_for_full_text("Task completed in", Duration::from_secs(15)) + .expect("background completion chip"); + harness + .wait_until_stable( + "no auto-wake response after background completion", + Duration::from_secs(2), + Duration::from_secs(2), + |h| !h.contains_full_text(UNWANTED_AUTO_WAKE_SENTINEL), + ) + .unwrap_or_else(|error| { + panic!( + "{error}\n--- unified diagnostics ---\n{}", + unified_log_diagnostics(&content) + ) + }); + + harness + .inject_keys(POST_CANCEL_MARKER.as_bytes()) + .expect("type post-cancel prompt"); + harness.update(Duration::from_millis(300)); + harness + .inject_keys(b"\r") + .expect("submit post-cancel prompt"); + + let reminder_on_wire = poll_for(Duration::from_secs(30), || { + content.request_bodies().iter().find_map(|body| { + let serialized = body.to_string(); + (serialized.contains(POST_CANCEL_MARKER) + && serialized.contains("Background task") + && serialized.contains("completed") + && serialized.contains(&task_id)) + .then_some(()) + }) + }) + .is_some(); + harness + .wait_for_full_text(UNWANTED_AUTO_WAKE_SENTINEL, Duration::from_secs(15)) + .expect("genuine user turn response"); + harness + .wait_for_turn_idle(Duration::from_secs(15)) + .expect("genuine user turn idle"); + harness + .wait_until_stable( + "no second completion request after the user turn", + Duration::from_secs(2), + Duration::from_secs(2), + |_| { + content + .request_bodies() + .iter() + .filter(|body| body.to_string().contains(POST_CANCEL_MARKER)) + .count() + == 1 + }, + ) + .expect("deferred completion consumed atomically"); + write_cast_if_requested(&harness, "auto_wake_cancel_before_completion.cast"); + harness.quit().expect("quit pager"); + + assert!( + reminder_on_wire, + "the next genuine user request must include the deferred task-completion reminder\n\ + --- non-system messages ---\n{}\n--- unified diagnostics ---\n{}", + dump_non_system_messages(&content.request_bodies()), + unified_log_diagnostics(&content) + ); +} diff --git a/crates/codegen/xai-grok-pager/tests/pty_e2e/endline_park_two_static_markers.rs b/crates/codegen/xai-grok-pager/tests/pty_e2e/endline_park_two_static_markers.rs index 4122d87..9e1099b 100644 --- a/crates/codegen/xai-grok-pager/tests/pty_e2e/endline_park_two_static_markers.rs +++ b/crates/codegen/xai-grok-pager/tests/pty_e2e/endline_park_two_static_markers.rs @@ -1,5 +1,6 @@ -//! PTY: a parked wait produces two static markers — the park pushes "Turn -//! completed in X. 1 command still running…" and the turn that follows ends +//! PTY: a parked wait produces two static markers — the park pushes a plain +//! "Worked for X" line (the still-running work shows on the status row's +//! "watching · …" cue, not in the transcript) and the turn that follows ends //! with its own marker below. A prompt typed mid-park is cancel-and-send: //! the shell silently cancels the parked turn (no "Turn cancelled by user" //! marker) and runs the message as its OWN next turn, whose completion pushes @@ -31,8 +32,8 @@ async fn endline_park_two_static_markers() { format!("while [ ! -e {} ]; do /bin/sleep 0.2; done", flag.display()) }; - // Tool call 1: a flag-gated background command — the work both markers - // snapshot ("1 command still running…"). + // Tool call 1: a flag-gated background command — the work the watching + // cue counts ("watching · 1 command"). let bg_args = json!({ "command": gated_loop(&park_flag), "description": "flag-gated command", @@ -147,9 +148,10 @@ async fn endline_park_two_static_markers() { // Everything downstream is scripted — let the id-extraction hold finish. std::fs::write(&id_ready_flag, b"ready").expect("release id-extraction hold"); - // Park: the first static marker reads as a completion with the count. + // Park: the first static marker reads as a plain completion; the + // still-running work shows on the status row's watching cue instead. harness - .wait_for_text("1 command still running", Duration::from_secs(90)) + .wait_for_text("Worked for", Duration::from_secs(90)) .unwrap_or_else(|_| { panic!( "parked marker never appeared; screen:\n{}\n--- non-system messages ---\n{}", @@ -157,9 +159,17 @@ async fn endline_park_two_static_markers() { dump_non_system_messages(&content.request_bodies()) ) }); + harness + .wait_for_text("watching · 1 command", Duration::from_secs(30)) + .unwrap_or_else(|_| { + panic!( + "parked watching cue never appeared; screen:\n{}", + harness.screen_contents() + ) + }); assert!( - harness.screen_contents().contains("Worked for"), - "the parked marker keeps the completion prefix; screen:\n{}", + !harness.contains_text("still running"), + "the parked marker carries no still-running suffix; screen:\n{}", harness.screen_contents() ); @@ -189,15 +199,16 @@ async fn endline_park_two_static_markers() { harness.inject_keys(b"g").expect("goto transcript top"); // Two static markers: the park line unchanged above the promoted prompt - // and the new turn's final marker (also counting the still-gated command) - // below it — with NO cancelled marker anywhere (silent send-now cancel). + // and the new turn's final marker below it — both plain "Worked for X" + // lines (no still-running suffix) — with NO cancelled marker anywhere + // (silent send-now cancel). let two_markers = wait_until(Duration::from_secs(90), || { harness.update(Duration::from_millis(100)); let screen = harness.screen_contents(); // Positional: park marker ABOVE the promoted prompt ABOVE the final // marker (screen text is row-major), both markers intact. screen.matches("Worked for").count() == 2 - && screen.matches("1 command still running").count() == 2 + && !screen.contains("still running") && !screen.contains("Turn cancelled by user") && matches!( ( diff --git a/crates/codegen/xai-grok-pager/tests/pty_e2e/endline_wake_markers_close_each_wakeup.rs b/crates/codegen/xai-grok-pager/tests/pty_e2e/endline_wakeups_are_markerless.rs similarity index 59% rename from crates/codegen/xai-grok-pager/tests/pty_e2e/endline_wake_markers_close_each_wakeup.rs rename to crates/codegen/xai-grok-pager/tests/pty_e2e/endline_wakeups_are_markerless.rs index 48d9d1f..c79965d 100644 --- a/crates/codegen/xai-grok-pager/tests/pty_e2e/endline_wake_markers_close_each_wakeup.rs +++ b/crates/codegen/xai-grok-pager/tests/pty_e2e/endline_wakeups_are_markerless.rs @@ -1,14 +1,13 @@ -//! PTY: every wake turn ends with a real marker — a turn ends with three -//! flag-gated background commands running ("3 commands still running…"), and -//! each released flag lands a completion chip, the auto-wake response, then a -//! FRESH wake-end marker snapshotting the remaining counts ("2 …", "1 …"), -//! while every earlier line stays unchanged above (nothing mutates). The last -//! wake's marker is the plain form ("Worked for X." — zero left), and -//! no after-chip work-only status lines appear anywhere: the shell stamps -//! `will_wake` on each completion, so the wake markers carry the counts. +//! PTY: auto-wake turns close MARKERLESS — a turn ends with three flag-gated +//! background commands running (one plain "Worked for" marker), and each +//! released flag lands a completion chip and the auto-wake response with NO +//! wake-end marker after it, while every earlier line stays unchanged above +//! (nothing mutates). The persistent "watching · N commands" status row above +//! the prompt counts the remaining work down between wakes and disappears +//! once nothing is left; no "still running" copy appears anywhere. //! -//! Positional chain asserted at the end: marker(3) < chip < wake reply < -//! marker(2) < chip < reply < marker(1) < chip < reply < plain final marker. +//! Positional chain asserted at the end: marker < chip < wake reply < chip < +//! reply < chip < reply — exactly ONE "Worked for" total (the user turn's). #[allow(unused_imports)] use super::common::*; @@ -24,7 +23,7 @@ const ROWS: u16 = 70; #[cfg(unix)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"] -async fn endline_wake_markers_close_each_wakeup() { +async fn endline_wakeups_are_markerless() { let content = ContentController::start().await.expect("start content"); let flags: Vec = (0..TASKS) .map(|i| content.home().join(format!("endline_status_flag_{i}"))) @@ -98,8 +97,8 @@ async fn endline_wake_markers_close_each_wakeup() { .inject_keys(format!("{PROMPT}\r").as_bytes()) .expect("submit prompt"); - // The turn ends with all three commands running: the final marker - // carries the snapshot count. + // The turn ends with all three commands running: one plain marker, and + // the status row's watching cue carrying the count. harness .wait_for_text("STATUS_TURN_SETTLED", Duration::from_secs(60)) .unwrap_or_else(|_| { @@ -110,79 +109,72 @@ async fn endline_wake_markers_close_each_wakeup() { ) }); harness - .wait_for_text("3 commands still running", Duration::from_secs(30)) + .wait_for_text("Worked for", Duration::from_secs(30)) .unwrap_or_else(|_| { panic!( - "marker never showed the snapshot count; screen:\n{}", + "the end marker never appeared; screen:\n{}", + harness.screen_contents() + ) + }); + harness + .wait_for_text("watching · 3 commands", Duration::from_secs(30)) + .unwrap_or_else(|_| { + panic!( + "the watching cue never showed the running count; screen:\n{}", harness.screen_contents() ) }); - assert!( - harness.screen_contents().contains("Worked for"), - "the marker keeps the completion prefix; screen:\n{}", - harness.screen_contents() - ); - // Release flag 0: chip → wake reply → a fresh "2 commands" wake-end - // marker below, with the original "3 commands" marker intact above + // Release flag 0: chip → wake reply, NO wake marker; the watching cue + // counts down to 2 while the one user-turn marker stays intact above // (screen text is row-major, so find offsets order the lines). std::fs::write(&flags[0], b"done").expect("release flag 0"); let wake_one = wait_until(Duration::from_secs(45), || { harness.update(Duration::from_millis(100)); let screen = harness.screen_contents(); - matches!( - ( - screen.find("3 commands still running"), - screen.find("WAKE_REPLY_ONE"), - screen.find("2 commands still running"), - ), - (Some(three), Some(reply), Some(two)) if three < reply && reply < two - ) + screen.contains("WAKE_REPLY_ONE") + && screen.matches("Worked for").count() == 1 + && screen.contains("watching · 2 commands") }); assert!( wake_one, - "expected chip → wake reply → fresh '2 commands' marker below the intact '3 commands' one; screen:\n{}", + "expected chip → wake reply with no wake marker, watching cue at 2; screen:\n{}", harness.screen_contents() ); - // Release flag 1: the "1 command" wake marker joins below its reply. + // Release flag 1: the second wake chain joins below; cue counts 1. std::fs::write(&flags[1], b"done").expect("release flag 1"); let wake_two = wait_until(Duration::from_secs(45), || { harness.update(Duration::from_millis(100)); let screen = harness.screen_contents(); - matches!( - ( - screen.find("2 commands still running"), - screen.find("WAKE_REPLY_TWO"), - screen.find("1 command still running"), - ), - (Some(two), Some(reply), Some(one)) if two < reply && reply < one - ) + screen.contains("WAKE_REPLY_TWO") + && screen.matches("Worked for").count() == 1 + && screen.contains("watching · 1 command") }); assert!( wake_two, - "expected the second wake chain below the earlier lines; screen:\n{}", + "expected the second markerless wake chain below the earlier lines; screen:\n{}", harness.screen_contents() ); - // Release flag 2: zero left — the last wake ends with the PLAIN marker - // (fourth "Worked for", no new "still running" suffix). + // Release flag 2: zero left — still exactly one marker, and the watching + // cue disappears entirely. std::fs::write(&flags[2], b"done").expect("release flag 2"); let wake_three = wait_until(Duration::from_secs(45), || { harness.update(Duration::from_millis(100)); let screen = harness.screen_contents(); - screen.contains("WAKE_REPLY_THREE") && screen.matches("Worked for").count() == 4 + screen.contains("WAKE_REPLY_THREE") + && screen.matches("Worked for").count() == 1 + && !screen.contains("watching ·") }); assert!( wake_three, - "the final plain wake marker never landed; screen:\n{}", + "the last wake must stay markerless and retire the watching cue; screen:\n{}", harness.screen_contents() ); - // Full chain, positional: marker(3) < chip < reply < marker(2) < chip < - // reply < marker(1) < chip < reply < plain final marker — and exactly - // three "still running" lines total (the markers'), i.e. the stamped - // `will_wake` suppressed every after-chip work-only status line. + // Full chain, positional: marker < chip < reply < chip < reply < chip < + // reply — one marker total, and ZERO "still running" lines anywhere. let screen = harness.screen_contents(); let chips: Vec = screen .match_indices("Task completed") @@ -193,34 +185,29 @@ async fn endline_wake_markers_close_each_wakeup() { TASKS, "one completion chip per task; screen:\n{screen}" ); - let m3 = screen.find("3 commands still running").expect("marker 3"); + let markers: Vec = screen.match_indices("Worked for").map(|(i, _)| i).collect(); + assert_eq!( + markers.len(), + 1, + "exactly one marker — the user turn's; screen:\n{screen}" + ); let w1 = screen.find("WAKE_REPLY_ONE").expect("wake reply 1"); - let m2 = screen.find("2 commands still running").expect("marker 2"); let w2 = screen.find("WAKE_REPLY_TWO").expect("wake reply 2"); - let m1 = screen.find("1 command still running").expect("marker 1"); let w3 = screen.find("WAKE_REPLY_THREE").expect("wake reply 3"); - let final_marker = screen - .match_indices("Worked for") - .map(|(i, _)| i) - .last() - .expect("final marker"); assert!( - m3 < chips[0] + markers[0] < chips[0] && chips[0] < w1 - && w1 < m2 - && m2 < chips[1] + && w1 < chips[1] && chips[1] < w2 - && w2 < m1 - && m1 < chips[2] - && chips[2] < w3 - && w3 < final_marker, + && w2 < chips[2] + && chips[2] < w3, "chain out of order; screen:\n{screen}" ); assert_eq!( screen.matches("still running").count(), - 3, - "wake-bound completions must not add work-only status lines; screen:\n{screen}" + 0, + "no still-running copy may appear in the transcript; screen:\n{screen}" ); - write_cast_if_requested(&harness, "endline_wake_markers_close_each_wakeup.cast"); + write_cast_if_requested(&harness, "endline_wakeups_are_markerless.cast"); } diff --git a/crates/codegen/xai-grok-pager/tests/pty_e2e/iterm_readline_editing.rs b/crates/codegen/xai-grok-pager/tests/pty_e2e/iterm_readline_editing.rs new file mode 100644 index 0000000..acdbfa0 --- /dev/null +++ b/crates/codegen/xai-grok-pager/tests/pty_e2e/iterm_readline_editing.rs @@ -0,0 +1,150 @@ +// Per-test-case module for the `pty_e2e` integration test crate. +#[allow(unused_imports)] +use super::common::*; + +const CTRL_BACKSLASH: &[u8] = b"\x1b[92;5u"; +const OPTION_BACKSPACE: &[u8] = b"\x1b\x7f"; +const META_B: &[u8] = b"\x1bb"; +const META_F: &[u8] = b"\x1bf"; +const ALT_LEFT: &[u8] = b"\x1b[1;3D"; +const ALT_RIGHT: &[u8] = b"\x1b[1;3C"; +const ROW_TITLE: &str = "ITERMROW"; + +fn click_visible_text(harness: &mut PtyHarness, text: &str) { + harness + .wait_for_text(text, Duration::from_secs(10)) + .unwrap_or_else(|_| panic!("{text:?} did not render\n{}", harness.screen_contents())); + let screen = harness.screen_contents(); + let (row, col) = screen + .lines() + .enumerate() + .find_map(|(row, line)| { + let byte = line.find(text)?; + let prefix_width = unicode_width::UnicodeWidthStr::width(&line[..byte]) as u16; + let text_width = unicode_width::UnicodeWidthStr::width(text) as u16; + Some((row as u16, prefix_width + text_width / 2)) + }) + .unwrap_or_else(|| panic!("could not locate {text:?}\n{screen}")); + let click = format!( + "{}{}", + sgr_mouse(0, row, col, 'M'), + sgr_mouse(0, row, col, 'm') + ); + harness + .inject_keys(click.as_bytes()) + .unwrap_or_else(|error| panic!("click {text:?}: {error}")); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "PTY e2e; CI runs the ignored pty_e2e suite"] +async fn iterm_raw_readline_sequences_edit_picker_and_dashboard_rename() { + let content = ContentController::start().await.expect("start content"); + content.set_response(format!("{MOCK_RESPONSE_SENTINEL} iTerm editing turn.")); + let binary = pager_binary().expect("resolve pager binary"); + let mut env = content.env_for_pager(); + env.push(("TERM_PROGRAM".into(), "iTerm.app".into())); + let env_refs: Vec<(&str, &str)> = env + .iter() + .map(|(key, value)| (key.as_str(), value.as_str())) + .collect(); + let mut harness = + PtyHarness::new(&binary, DEFAULT_ROWS, DEFAULT_COLS, &[], &env_refs).expect("spawn pager"); + + harness + .wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT) + .expect("welcome text"); + harness + .inject_keys(format!("{PROMPT}\r").as_bytes()) + .expect("submit setup prompt"); + harness + .wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30)) + .expect("turn rendered"); + + inject_keys_paced(&mut harness, format!("/rename {ROW_TITLE}").as_bytes()); + harness + .inject_keys(keys::ENTER) + .expect("seed session title"); + harness + .wait_for_text("Session renamed to", Duration::from_secs(15)) + .expect("seeded title persisted"); + + harness.inject_keys(b"\x10").expect("Ctrl+P"); + harness + .wait_for_text("Commands", Duration::from_secs(10)) + .expect("command palette open"); + inject_keys_paced(&mut harness, b"ITERMONE ITERMDELETE"); + harness + .wait_for_text("ITERMONE ITERMDELETE", Duration::from_secs(10)) + .expect("palette query rendered"); + harness + .inject_keys(OPTION_BACKSPACE) + .expect("iTerm Option+Backspace"); + wait_for_labels_absent(&mut harness, &["ITERMDELETE"], Duration::from_secs(10)); + inject_keys_paced(&mut harness, b"ITERMWORD"); + harness.inject_keys(META_B).expect("iTerm Meta-B"); + inject_keys_paced(&mut harness, b"MID"); + harness.inject_keys(META_F).expect("iTerm Meta-F"); + inject_keys_paced(&mut harness, b"END"); + harness + .wait_for_text("ITERMONE MIDITERMWORDEND", Duration::from_secs(10)) + .expect("raw Meta editing changed palette query"); + + harness.inject_keys(keys::ESC).expect("clear palette query"); + wait_for_labels_absent( + &mut harness, + &["ITERMONE MIDITERMWORDEND"], + Duration::from_secs(10), + ); + harness + .inject_keys(keys::ESC) + .expect("close command palette"); + wait_for_labels_absent(&mut harness, &["Commands"], Duration::from_secs(10)); + + harness.inject_keys(CTRL_BACKSLASH).expect("open dashboard"); + harness + .wait_for_text("+ New Agent", Duration::from_secs(10)) + .expect("dashboard open"); + click_visible_text(&mut harness, ROW_TITLE); + harness + .wait_for_text("[Dashboard]", Duration::from_secs(10)) + .expect("row click attached the dashboard overlay"); + harness + .inject_keys(keys::ESC) + .expect("close attached dashboard row"); + wait_for_labels_absent(&mut harness, &["[Dashboard]"], Duration::from_secs(10)); + harness + .inject_keys(b"\x12") + .expect("dashboard Ctrl+R rename"); + harness + .wait_for_text("rename:", Duration::from_secs(10)) + .expect("empty rename editor opened for the titled session row"); + inject_keys_paced(&mut harness, b"LEFT RIGHT"); + harness.inject_keys(ALT_LEFT).expect("iTerm Alt+Left"); + inject_keys_paced(&mut harness, b"MID"); + harness.inject_keys(ALT_RIGHT).expect("iTerm Alt+Right"); + inject_keys_paced(&mut harness, b"END"); + harness + .wait_for_text("rename: LEFT MIDRIGHTEND", Duration::from_secs(10)) + .expect("raw Alt arrows changed rename draft"); + harness.inject_keys(keys::ENTER).expect("commit rename"); + wait_for_labels_absent(&mut harness, &["rename:"], Duration::from_secs(10)); + harness + .wait_for_text("LEFT MIDRIGHTEND", Duration::from_secs(10)) + .expect("committed dashboard rename visible"); + + assert!( + !harness.contains_text("panicked"), + "pager panicked\nscreen:\n{}", + harness.screen_contents() + ); + harness.inject_keys(b"\x11").expect("Ctrl+Q arm"); + harness + .wait_for_text("press again to quit", Duration::from_secs(10)) + .expect("quit confirmation rendered"); + harness.inject_keys(b"\x11").expect("Ctrl+Q confirm"); + assert_eq!( + harness.wait_exit_code(Duration::from_secs(10)), + Some(0), + "pager must exit cleanly" + ); +} diff --git a/crates/codegen/xai-grok-pager/tests/pty_e2e/page_flip_on_send_pty.rs b/crates/codegen/xai-grok-pager/tests/pty_e2e/page_flip_on_send_pty.rs new file mode 100644 index 0000000..8af2a2f --- /dev/null +++ b/crates/codegen/xai-grok-pager/tests/pty_e2e/page_flip_on_send_pty.rs @@ -0,0 +1,91 @@ +// Per-test-case module for the `pty_e2e` integration test crate. +#[allow(unused_imports)] +use super::common::*; + +// Default (unset): send pins the new prompt at the viewport top. +// `[ui] page_flip_on_send = false`: send does not move the viewport. + +const TAIL_SENTINEL: &str = "TAILSENTINEL_T1"; +const SECOND_PROMPT: &str = "second-prompt-marker"; + +fn tall_first_response() -> String { + let mut s = String::from("```\n"); + for i in 0..80 { + s.push_str(&format!("line {i} payload\n")); + } + s.push_str(TAIL_SENTINEL); + s.push_str("\n```\n"); + s +} + +/// Welcome → tall turn 1 → submit turn 2 while holding turn 2 open. +async fn drive_to_second_send(content: &ContentController) -> PtyHarness { + content.set_response(tall_first_response()); + + let binary = pager_binary().expect("resolve pager binary"); + let mut harness = + PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, content, &[]) + .expect("spawn pager"); + harness + .wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT) + .expect("welcome text"); + harness + .inject_keys(format!("{PROMPT}\r").as_bytes()) + .expect("submit first prompt"); + harness + .wait_for_text(TAIL_SENTINEL, Duration::from_secs(30)) + .expect("turn 1 tail visible"); + + content.hold_agent_completions(); + content.set_response(format!("{MOCK_RESPONSE_SENTINEL} second turn.")); + harness + .inject_keys(format!("{SECOND_PROMPT}\r").as_bytes()) + .expect("submit second prompt"); + harness + .wait_for_text(SECOND_PROMPT, Duration::from_secs(15)) + .expect("second prompt rendered"); + harness.update(Duration::from_millis(600)); + harness +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore] +async fn send_page_flips_by_default() { + let content = ContentController::start().await.expect("start content"); + let mut harness = drive_to_second_send(&content).await; + + assert!( + !harness.contains_text(TAIL_SENTINEL), + "default send should page-flip turn 1's tail off screen\nscreen:\n{}", + harness.screen_contents() + ); + let screen = harness.screen_contents(); + let prompt_row = screen + .lines() + .position(|l| l.contains(SECOND_PROMPT)) + .expect("second prompt visible"); + assert!( + prompt_row < (DEFAULT_ROWS as usize) / 2, + "flipped prompt should be in the top half (row {prompt_row})\nscreen:\n{screen}" + ); + + content.release_agent_completions(); + harness.quit().expect("clean quit"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore] +async fn send_keeps_viewport_when_page_flip_disabled() { + let content = ContentController::start().await.expect("start content"); + seed_ui_config(&content, "page_flip_on_send = false"); + let mut harness = drive_to_second_send(&content).await; + + assert!( + harness.contains_text(TAIL_SENTINEL), + "page_flip_on_send=false must leave turn 1's tail on screen\nscreen:\n{}", + harness.screen_contents() + ); + + content.release_agent_completions(); + harness.quit().expect("clean quit"); +} diff --git a/crates/codegen/xai-grok-pager/tests/pty_e2e/reasoning_efforts_menu_renders_and_remaps_on_wire.rs b/crates/codegen/xai-grok-pager/tests/pty_e2e/reasoning_efforts_menu_renders_and_remaps_on_wire.rs index f4951f5..66c44a2 100644 --- a/crates/codegen/xai-grok-pager/tests/pty_e2e/reasoning_efforts_menu_renders_and_remaps_on_wire.rs +++ b/crates/codegen/xai-grok-pager/tests/pty_e2e/reasoning_efforts_menu_renders_and_remaps_on_wire.rs @@ -72,10 +72,10 @@ async fn reasoning_efforts_menu_renders_and_remaps_on_wire() { let sent_xhigh = content .request_bodies() .iter() - .any(|b| b.pointer("/reasoning_effort").and_then(|v| v.as_str()) == Some("xhigh")); + .any(|b| b.pointer("/reasoning/effort").and_then(|v| v.as_str()) == Some("xhigh")); assert!( sent_xhigh, - "`/effort deep` must send the mapped canonical reasoning_effort=xhigh\nbodies: {:#?}", + "`/effort deep` must send the mapped canonical reasoning.effort=xhigh\nbodies: {:#?}", content.request_bodies() ); diff --git a/crates/codegen/xai-grok-pager/tests/pty_e2e/reparked_wait_repushes_buried_marker.rs b/crates/codegen/xai-grok-pager/tests/pty_e2e/reparked_wait_repushes_buried_marker.rs index b075853..4c49a2b 100644 --- a/crates/codegen/xai-grok-pager/tests/pty_e2e/reparked_wait_repushes_buried_marker.rs +++ b/crates/codegen/xai-grok-pager/tests/pty_e2e/reparked_wait_repushes_buried_marker.rs @@ -1,13 +1,14 @@ -//! PTY: a re-parked wait re-pushes the parked marker when intervening -//! content buried the previous one, so the transcript tail keeps explaining -//! the idle-looking parked chrome. +//! PTY: a re-parked wait (new parent output between parks) pushes a fresh +//! parked marker for the new park episode, so the transcript keeps a +//! boundary where each park began, while the persistent "watching · …" +//! status row explains the still-running background work. //! //! Wire journey, flag-file driven like `endline_park_two_static_markers`: //! background a flag-gated command, hold on a flag-gated foreground command //! while the runtime task id is extracted, then script three more rounds on //! the real id — a short wait (`timeout_ms: 4000`) that expires with the //! task still running (park #1 + marker), a quick foreground echo, and a -//! long wait (park #2: chrome hidden and a fresh marker at the tail). +//! long wait (park #2: chrome hidden and a fresh marker for the new episode). #[allow(unused_imports)] use super::common::*; @@ -196,9 +197,9 @@ async fn reparked_wait_repushes_buried_marker() { // Everything downstream is scripted — release the id-extraction hold. std::fs::write(&id_ready_flag, b"ready").expect("release id-extraction hold"); - // Park #1 marker. + // Park #1 marker (plain "Worked for X" — no still-running suffix). harness - .wait_for_text("1 command still running", Duration::from_secs(90)) + .wait_for_text("Worked for", Duration::from_secs(90)) .unwrap_or_else(|_| { panic!( "park #1 marker never appeared; screen:\n{}\n--- non-system messages ---\n{}", @@ -206,6 +207,15 @@ async fn reparked_wait_repushes_buried_marker() { dump_non_system_messages(&content.request_bodies()) ) }); + // The parked status row carries the still-running story instead. + harness + .wait_for_text("watching · 1 command", Duration::from_secs(30)) + .unwrap_or_else(|_| { + panic!( + "parked watching cue never appeared; screen:\n{}", + harness.screen_contents() + ) + }); // The short wait expires and the same turn resumes. harness @@ -229,14 +239,15 @@ async fn reparked_wait_repushes_buried_marker() { harness.screen_contents() ); - // Park #2 re-pushes a second marker below the between-parks content. + // Park #2 pushes a second marker below the between-parks content (a new + // park episode after new parent output). let repushed = wait_until(Duration::from_secs(30), || { harness.update(Duration::from_millis(100)); harness.screen_contents().matches("Worked for").count() == 2 }); assert!( repushed, - "re-park with a buried marker must re-push a second marker; screen:\n{}", + "re-park after buried marker must push a fresh marker; screen:\n{}", harness.screen_contents() ); let screen = harness.screen_contents(); @@ -251,10 +262,14 @@ async fn reparked_wait_repushes_buried_marker() { first_marker < midwork_at && midwork_at < second_marker, "expected marker, content, then the re-pushed marker in order; screen:\n{screen}" ); - // The re-pushed marker still counts the running work. + // The still-running story lives in the status row, not the transcript. assert!( - screen[second_marker..].contains("1 command still running"), - "the re-pushed marker carries the live work count; screen:\n{screen}" + !screen.contains("still running"), + "no still-running suffix anywhere in the transcript; screen:\n{screen}" + ); + assert!( + screen.contains("watching · 1 command"), + "the parked status row keeps the watching cue during park #2; screen:\n{screen}" ); // The parked look still hides spinner and chrome. let below_midwork = &screen[midwork_at..]; diff --git a/crates/codegen/xai-grok-pager/tests/pty_e2e/spinner_reappears_after_wait_resumes.rs b/crates/codegen/xai-grok-pager/tests/pty_e2e/spinner_reappears_after_wait_resumes.rs index 45158d9..b0c4f68 100644 --- a/crates/codegen/xai-grok-pager/tests/pty_e2e/spinner_reappears_after_wait_resumes.rs +++ b/crates/codegen/xai-grok-pager/tests/pty_e2e/spinner_reappears_after_wait_resumes.rs @@ -139,10 +139,11 @@ async fn spinner_reappears_after_wait_resumes() { std::fs::write(&id_ready_flag, b"ready").expect("release id-extraction hold"); - // Parked look: the marker renders and the running chrome (turn-status - // row / cancel keybar) drops — the session reads as stopped. + // Parked look: the plain marker renders, the "watching · …" cue takes + // the status row, and the running chrome (cancel keybar) drops — the + // session reads as stopped. harness - .wait_for_text("1 command still running", Duration::from_secs(60)) + .wait_for_text("Worked for", Duration::from_secs(60)) .unwrap_or_else(|_| { panic!( "parked marker never appeared; screen:\n{}\n--- non-system messages ---\n{}", @@ -150,11 +151,14 @@ async fn spinner_reappears_after_wait_resumes() { dump_non_system_messages(&content.request_bodies()) ) }); - assert!( - harness.contains_text("Worked for"), - "the parked marker keeps the completion prefix; screen:\n{}", - harness.screen_contents() - ); + harness + .wait_for_text("watching · 1 command", Duration::from_secs(30)) + .unwrap_or_else(|_| { + panic!( + "parked watching cue never appeared; screen:\n{}", + harness.screen_contents() + ) + }); let chrome_hidden = wait_until(Duration::from_secs(10), || { harness.update(Duration::from_millis(100)); !harness.contains_text(CANCEL_HINT) diff --git a/crates/codegen/xai-grok-pager/tests/pty_e2e_config_ui.rs b/crates/codegen/xai-grok-pager/tests/pty_e2e_config_ui.rs index b56e2b3..44c3074 100644 --- a/crates/codegen/xai-grok-pager/tests/pty_e2e_config_ui.rs +++ b/crates/codegen/xai-grok-pager/tests/pty_e2e_config_ui.rs @@ -24,6 +24,8 @@ mod critical_announcement_session_banner_pty; mod dashboard_overlay_tab_esc_backout_and_ctrl_backslash; #[path = "pty_e2e/extensions_modal_copy_hints_pty.rs"] mod extensions_modal_copy_hints_pty; +#[path = "pty_e2e/iterm_readline_editing.rs"] +mod iterm_readline_editing; #[path = "pty_e2e/prompt_suggestion_ghost_tab_accepts.rs"] mod prompt_suggestion_ghost_tab_accepts; #[path = "pty_e2e/reasoning_efforts_fallback_menu_matches_builtin.rs"] diff --git a/crates/codegen/xai-grok-pager/tests/pty_e2e_persistence.rs b/crates/codegen/xai-grok-pager/tests/pty_e2e_persistence.rs index b3c1b16..220fd03 100644 --- a/crates/codegen/xai-grok-pager/tests/pty_e2e_persistence.rs +++ b/crates/codegen/xai-grok-pager/tests/pty_e2e_persistence.rs @@ -15,8 +15,8 @@ mod background_task_reaped_on_quit; mod continue_resumes_session_with_history; #[path = "pty_e2e/endline_park_two_static_markers.rs"] mod endline_park_two_static_markers; -#[path = "pty_e2e/endline_wake_markers_close_each_wakeup.rs"] -mod endline_wake_markers_close_each_wakeup; +#[path = "pty_e2e/endline_wakeups_are_markerless.rs"] +mod endline_wakeups_are_markerless; #[path = "pty_e2e/rename_title_shows_in_prompt_border.rs"] mod rename_title_shows_in_prompt_border; #[path = "pty_e2e/reparked_wait_repushes_buried_marker.rs"] diff --git a/crates/codegen/xai-grok-pager/tests/pty_e2e_scroll_selection.rs b/crates/codegen/xai-grok-pager/tests/pty_e2e_scroll_selection.rs index 7d273ad..55bb4ac 100644 --- a/crates/codegen/xai-grok-pager/tests/pty_e2e_scroll_selection.rs +++ b/crates/codegen/xai-grok-pager/tests/pty_e2e_scroll_selection.rs @@ -38,6 +38,8 @@ mod mouse_reporting_toggle_inactive_without_config_pty; mod mouse_reporting_toggle_sticky_persists_pty; #[path = "pty_e2e/nested_quote_drag_copy_excludes_bars_pty.rs"] mod nested_quote_drag_copy_excludes_bars_pty; +#[path = "pty_e2e/page_flip_on_send_pty.rs"] +mod page_flip_on_send_pty; #[path = "pty_e2e/quote_block_drag_copy_excludes_bars_pty.rs"] mod quote_block_drag_copy_excludes_bars_pty; #[path = "pty_e2e/quote_block_raw_mode_copy_keeps_source_pty.rs"] diff --git a/crates/codegen/xai-grok-pager/tests/settings_e2e.rs b/crates/codegen/xai-grok-pager/tests/settings_e2e.rs index bbcd504..7895549 100644 --- a/crates/codegen/xai-grok-pager/tests/settings_e2e.rs +++ b/crates/codegen/xai-grok-pager/tests/settings_e2e.rs @@ -32,6 +32,7 @@ const ALL_SETTINGS_EXERCISED: &[&str] = &[ "screen_mode", "show_timestamps", "show_timeline", + "page_flip_on_send", "simple_mode", "vim_mode", "remember_tool_approvals", @@ -209,6 +210,9 @@ fn assert_set_bool_action(outcome: SettingsKeyOutcome, key: &str, expected: bool ("show_timeline", Action::SetTimeline(b)) => { assert_eq!(b, expected, "SetTimeline value differs from expected") } + ("page_flip_on_send", Action::SetPageFlipOnSend(b)) => { + assert_eq!(b, expected, "SetPageFlipOnSend value differs from expected") + } ("simple_mode", Action::SetSimpleMode(b)) => { assert_eq!(b, expected, "SetSimpleMode value differs from expected") } @@ -333,11 +337,11 @@ fn cmd_comma_closes_modal_on_macos() { fn esc_in_filter_mode_exits_filter_not_modal() { let mut s = make_state(); let _ = handle_settings_key(&mut s, &press(KeyCode::Char('/'))); - assert!(matches!(s.mode, SettingsModalMode::FilterFocused)); + assert!(matches!(s.mode(), SettingsModalMode::FilterFocused)); let outcome = handle_settings_key(&mut s, &press(KeyCode::Esc)); // Esc inside filter should NOT close the modal. assert!(matches!(outcome, SettingsKeyOutcome::Changed)); - assert!(matches!(s.mode, SettingsModalMode::Browse)); + assert!(matches!(s.mode(), SettingsModalMode::Browse)); } // --------------------------------------------------------------------------- @@ -370,6 +374,15 @@ fn space_on_show_timeline_dispatches_typed_setter() { assert_set_bool_action(outcome, "show_timeline", !default_on); } +#[test] +fn space_on_page_flip_on_send_dispatches_typed_setter() { + let mut s = make_state(); + navigate_to(&mut s, "page_flip_on_send"); + let outcome = handle_settings_key(&mut s, &press(KeyCode::Char(' '))); + let default_on = UiConfig::default().page_flip_on_send_enabled(); + assert_set_bool_action(outcome, "page_flip_on_send", !default_on); +} + #[test] fn space_on_simple_mode_dispatches_typed_setter() { let mut s = make_state(); @@ -424,7 +437,7 @@ fn enter_on_contextual_hints_group_opens_sub_sheet_and_toggles_children() { let out = handle_settings_key(&mut s, &press(KeyCode::Enter)); assert!(matches!(out, SettingsKeyOutcome::Changed)); assert!(matches!( - s.mode, + s.mode(), SettingsModalMode::PickingGroup { child_idx: 0, .. } )); @@ -461,7 +474,7 @@ fn enter_on_contextual_hints_group_opens_sub_sheet_and_toggles_children() { // Esc returns to Browse. let out = handle_settings_key(&mut s, &press(KeyCode::Esc)); assert!(matches!(out, SettingsKeyOutcome::Changed)); - assert!(matches!(s.mode, SettingsModalMode::Browse)); + assert!(matches!(s.mode(), SettingsModalMode::Browse)); } /// Mouse parity for the group: clicking the group row's value column opens the @@ -481,9 +494,9 @@ fn mouse_click_on_contextual_hints_group_opens_sub_sheet_and_toggles_child() { ); assert!(matches!(out, SettingsKeyOutcome::Changed)); assert!( - matches!(s.mode, SettingsModalMode::PickingGroup { .. }), + matches!(s.mode(), SettingsModalMode::PickingGroup { .. }), "click on the group value column must open the sub-sheet, got {:?}", - s.mode, + s.mode(), ); // Synthesize child hit-rects (the renderer doesn't run in tests) and click @@ -604,6 +617,21 @@ fn mouse_click_on_show_timeline_indicator_toggles_in_one_click() { assert_set_bool_action(outcome, "show_timeline", !default_on); } +#[test] +fn mouse_click_on_page_flip_on_send_indicator_toggles_in_one_click() { + let mut s = make_state(); + synth_rects(&mut s); + let row_y = row_idx_for(&s, "page_flip_on_send") as u16; + let outcome = handle_settings_mouse( + &mut s, + MouseEventKind::Down(crossterm::event::MouseButton::Left), + 72, + row_y, + ); + let default_on = UiConfig::default().page_flip_on_send_enabled(); + assert_set_bool_action(outcome, "page_flip_on_send", !default_on); +} + /// Value-column click toggles `remember_tool_approvals` in one click. #[test] fn mouse_click_on_remember_tool_approvals_indicator_toggles_in_one_click() { @@ -763,14 +791,14 @@ fn mouse_scroll_up_returns_selection_to_first() { // Filter mode // --------------------------------------------------------------------------- -/// Filter mode accepts chars into `state.query` and must never leak +/// Filter mode accepts chars into the query editor and must never leak /// an `Action`. #[test] fn slash_enters_filter_mode_and_chars_go_to_query_no_action_leak() { let mut s = make_state(); let outcome = handle_settings_key(&mut s, &press(KeyCode::Char('/'))); assert!(matches!(outcome, SettingsKeyOutcome::Changed)); - assert!(matches!(s.mode, SettingsModalMode::FilterFocused)); + assert!(matches!(s.mode(), SettingsModalMode::FilterFocused)); for c in "compact density".chars() { let outcome = handle_settings_key(&mut s, &press(KeyCode::Char(c))); @@ -787,10 +815,10 @@ fn slash_enters_filter_mode_and_chars_go_to_query_no_action_leak() { } } } - assert_eq!(s.query, "compact density"); + assert_eq!(s.query(), "compact density"); let reg = SettingsRegistry::defaults(); - let hits = reg.search(&s.query); + let hits = reg.search(s.query()); assert_eq!(hits.len(), 1); assert_eq!(hits[0].key, "compact_mode"); } @@ -800,7 +828,7 @@ fn slash_enters_filter_mode_and_chars_go_to_query_no_action_leak() { fn ctrl_comma_from_filter_mode_closes_modal() { let mut s = make_state(); let _ = handle_settings_key(&mut s, &press(KeyCode::Char('/'))); - assert!(matches!(s.mode, SettingsModalMode::FilterFocused)); + assert!(matches!(s.mode(), SettingsModalMode::FilterFocused)); let outcome = handle_settings_key( &mut s, &press_with(KeyCode::Char(','), KeyModifiers::CONTROL), @@ -813,7 +841,7 @@ fn ctrl_comma_from_filter_mode_closes_modal() { fn f2_from_filter_mode_closes_modal() { let mut s = make_state(); let _ = handle_settings_key(&mut s, &press(KeyCode::Char('/'))); - assert!(matches!(s.mode, SettingsModalMode::FilterFocused)); + assert!(matches!(s.mode(), SettingsModalMode::FilterFocused)); let outcome = handle_settings_key(&mut s, &press(KeyCode::F(2))); assert!(matches!(outcome, SettingsKeyOutcome::Close)); } @@ -879,13 +907,13 @@ fn filter_esc_clears_query_and_returns_to_browse() { for c in "stamp".chars() { let _ = handle_settings_key(&mut s, &press(KeyCode::Char(c))); } - assert_eq!(s.query, "stamp"); + assert_eq!(s.query(), "stamp"); assert_eq!(s.filtered_indices().len(), 2); let outcome = handle_settings_key(&mut s, &press(KeyCode::Esc)); assert!(matches!(outcome, SettingsKeyOutcome::Changed)); - assert!(matches!(s.mode, SettingsModalMode::Browse)); - assert_eq!(s.query, "", "Esc must clear the query"); + assert!(matches!(s.mode(), SettingsModalMode::Browse)); + assert_eq!(s.query(), "", "Esc must clear the query"); // Filter is inert again — full set restored in original order. let expected: Vec = (0..s.rows.len()).collect(); assert_eq!( @@ -969,18 +997,18 @@ fn filter_backspace_broadens_visible_set() { // Pop trailing 'p' → "stam". Still matches only show_timestamps // (substring of "timestamps"); same 2 visible rows. let _ = handle_settings_key(&mut s, &press(KeyCode::Backspace)); - assert_eq!(s.query, "stam"); + assert_eq!(s.query(), "stam"); assert_eq!(s.filtered_indices().len(), 2); // Empty the query out — now everything is visible. We pop one at // a time and check at each step that the cache regenerates // (rather than just shrinks). let _ = handle_settings_key(&mut s, &press(KeyCode::Backspace)); // → "sta" - assert_eq!(s.query, "sta"); + assert_eq!(s.query(), "sta"); let _ = handle_settings_key(&mut s, &press(KeyCode::Backspace)); // → "st" - assert_eq!(s.query, "st"); + assert_eq!(s.query(), "st"); let _ = handle_settings_key(&mut s, &press(KeyCode::Backspace)); // → "s" - assert_eq!(s.query, "s"); + assert_eq!(s.query(), "s"); // "s" matches multiple settings (compact_mode via "messages" in // its description, show_timestamps via "show"/"timestamps", // simple_mode via "simple"/"ascii"). So the filtered set MUST be @@ -1000,7 +1028,7 @@ fn filter_backspace_broadens_visible_set() { // Final pop → "". Filter inert, full set restored in order. let _ = handle_settings_key(&mut s, &press(KeyCode::Backspace)); - assert_eq!(s.query, ""); + assert_eq!(s.query(), ""); let expected: Vec = (0..s.rows.len()).collect(); assert_eq!( s.filtered_indices(), @@ -1009,6 +1037,15 @@ fn filter_backspace_broadens_visible_set() { ); } +#[test] +fn programmatic_filter_query_is_single_line_and_cursor_ends() { + let mut state = make_state(); + state.set_query("sta\r\nmp\n"); + assert_eq!(state.query(), "stamp"); + assert_eq!(state.query_cursor(), state.query().len()); + assert_eq!(state.filtered_indices().len(), 2); +} + /// Multi-keyword AND query narrows correctly. #[test] fn filter_with_multiple_matches_navigates_between_settings() { @@ -1057,7 +1094,7 @@ fn filter_with_multiple_matches_navigates_between_settings() { for _ in 0..8 { let _ = handle_settings_key(&mut s, &press(KeyCode::Backspace)); } - assert_eq!(s.query, "ascii"); + assert_eq!(s.query(), "ascii"); let filtered_after_pop = s.filtered_indices(); let after_pop_keys: Vec<&str> = filtered_after_pop .iter() @@ -1082,17 +1119,21 @@ fn filter_enter_commits_and_preserves_query() { let _ = handle_settings_key(&mut s, &press(KeyCode::Char(c))); } let show_ts_idx = row_idx_for(&s, "show_timestamps"); - assert!(matches!(s.mode, SettingsModalMode::FilterFocused)); - assert_eq!(s.query, "stamp"); + assert!(matches!(s.mode(), SettingsModalMode::FilterFocused)); + assert_eq!(s.query(), "stamp"); assert_eq!(s.selected, show_ts_idx); let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter)); assert!(matches!(outcome, SettingsKeyOutcome::Changed)); assert!( - matches!(s.mode, SettingsModalMode::Browse), + matches!(s.mode(), SettingsModalMode::Browse), "Enter must exit FilterFocused" ); - assert_eq!(s.query, "stamp", "Enter must PRESERVE the query (Issue 1)"); + assert_eq!( + s.query(), + "stamp", + "Enter must PRESERVE the query (Issue 1)" + ); assert_eq!( s.filtered_indices().len(), 2, @@ -1113,21 +1154,47 @@ fn browse_backspace_pops_query_after_filter_commit() { for c in "stamp".chars() { let _ = handle_settings_key(&mut s, &press(KeyCode::Char(c))); } + let _ = handle_settings_key(&mut s, &press(KeyCode::Home)); + assert_eq!(s.query_cursor(), 0); // Commit let _ = handle_settings_key(&mut s, &press(KeyCode::Enter)); - assert!(matches!(s.mode, SettingsModalMode::Browse)); - assert_eq!(s.query, "stamp"); + assert!(matches!(s.mode(), SettingsModalMode::Browse)); + assert_eq!(s.query(), "stamp"); + let _ = handle_settings_key(&mut s, &press(KeyCode::Left)); + assert_eq!( + s.query(), + "stamp", + "unfocused Browse navigation must not edit the query", + ); // Backspace in Browse pops one char, stays in Browse, re-runs // invalidate_filter. let outcome = handle_settings_key(&mut s, &press(KeyCode::Backspace)); assert!(matches!(outcome, SettingsKeyOutcome::Changed)); - assert!(matches!(s.mode, SettingsModalMode::Browse)); - assert_eq!(s.query, "stam"); + assert!(matches!(s.mode(), SettingsModalMode::Browse)); + assert_eq!(s.query(), "stam"); assert_eq!(s.filtered_indices().len(), 2); + let grapheme = "👩🏽\u{200d}💻"; + for modifiers in [ + KeyModifiers::ALT, + KeyModifiers::CONTROL, + KeyModifiers::SUPER, + KeyModifiers::SHIFT, + ] { + s.set_query(format!("x{grapheme}")); + let outcome = handle_settings_key(&mut s, &press_with(KeyCode::Backspace, modifiers)); + assert!(matches!(outcome, SettingsKeyOutcome::Changed)); + assert_eq!( + s.query(), + "x", + "{modifiers:?}+Backspace must remove exactly one trailing grapheme", + ); + assert!(matches!(s.mode(), SettingsModalMode::Browse)); + } + // Backspace on empty query is Unchanged (and the query stays ""). - s.query.clear(); + s.set_query(""); let outcome = handle_settings_key(&mut s, &press(KeyCode::Backspace)); assert!( matches!(outcome, SettingsKeyOutcome::Unchanged), @@ -1135,13 +1202,123 @@ fn browse_backspace_pops_query_after_filter_commit() { ); } +#[test] +fn filter_uses_canonical_word_keys_without_cursor_side_effects() { + let mut state = make_state(); + let _ = handle_settings_key(&mut state, &press(KeyCode::Char('/'))); + + for key in [ + press_with(KeyCode::Left, KeyModifiers::ALT), + press_with(KeyCode::Char('b'), KeyModifiers::ALT), + press_with(KeyCode::Left, KeyModifiers::CONTROL), + ] { + state.set_query("alpha-beta"); + let outcome = handle_settings_key(&mut state, &key); + assert!(matches!(outcome, SettingsKeyOutcome::Changed)); + assert_eq!(state.query(), "alpha-beta"); + assert_eq!(state.query_cursor(), "alpha-".len()); + } + for key in [ + press_with(KeyCode::Right, KeyModifiers::ALT), + press_with(KeyCode::Char('f'), KeyModifiers::ALT), + ] { + state.set_query("alpha-beta"); + let _ = handle_settings_key(&mut state, &press(KeyCode::Home)); + let outcome = handle_settings_key(&mut state, &key); + assert!(matches!(outcome, SettingsKeyOutcome::Changed)); + assert_eq!(state.query_cursor(), "alpha".len()); + } + + state.set_query("stamp"); + let compact_idx = row_idx_for(&state, "compact_mode"); + let show_timestamps_idx = row_idx_for(&state, "show_timestamps"); + let filtered_before = state.filtered_indices().to_vec(); + state.selected = compact_idx; + let _ = handle_settings_key(&mut state, &press_with(KeyCode::Left, KeyModifiers::ALT)); + assert_eq!(state.filtered_indices(), filtered_before.as_slice()); + assert_eq!( + state.selected, compact_idx, + "cursor-only motion must not clamp selection", + ); + let _ = handle_settings_key(&mut state, &press(KeyCode::End)); + let _ = handle_settings_key(&mut state, &press(KeyCode::Backspace)); + assert_eq!(state.query(), "stam"); + assert_eq!( + state.selected, show_timestamps_idx, + "text mutation must recompute filtering and clamp selection", + ); + + state.set_query("alpha-beta"); + let _ = handle_settings_key( + &mut state, + &press_with(KeyCode::Backspace, KeyModifiers::ALT), + ); + assert_eq!(state.query(), "alpha-"); +} + +#[test] +fn filter_grapheme_and_line_commands_are_canonical() { + let mut state = make_state(); + let _ = handle_settings_key(&mut state, &press(KeyCode::Char('/'))); + let grapheme = "👩🏽\u{200d}💻"; + state.set_query(format!("a{grapheme}b")); + + let _ = handle_settings_key(&mut state, &press(KeyCode::Home)); + assert_eq!(state.query_cursor(), 0); + let _ = handle_settings_key(&mut state, &press(KeyCode::Right)); + assert_eq!(state.query_cursor(), 1); + let _ = handle_settings_key(&mut state, &press(KeyCode::Delete)); + assert_eq!(state.query(), "ab"); + let _ = handle_settings_key(&mut state, &press(KeyCode::End)); + assert_eq!(state.query_cursor(), state.query().len()); + let _ = handle_settings_key(&mut state, &press_with(KeyCode::Left, KeyModifiers::SUPER)); + assert_eq!(state.query_cursor(), 0); + let _ = handle_settings_key(&mut state, &press_with(KeyCode::Right, KeyModifiers::SUPER)); + assert_eq!(state.query_cursor(), state.query().len()); +} + +#[test] +fn filter_ctrl_kill_keys_and_unsafe_insert_policy() { + let mut state = make_state(); + let _ = handle_settings_key(&mut state, &press(KeyCode::Char('/'))); + + state.set_query("alpha beta"); + let _ = handle_settings_key( + &mut state, + &press_with(KeyCode::Char('w'), KeyModifiers::CONTROL), + ); + assert_eq!(state.query(), "alpha "); + + state.set_query("alpha beta"); + let _ = handle_settings_key( + &mut state, + &press_with(KeyCode::Char('u'), KeyModifiers::CONTROL), + ); + assert!(state.query().is_empty()); + + state.set_query("alpha beta"); + let _ = handle_settings_key(&mut state, &press(KeyCode::Home)); + let _ = handle_settings_key( + &mut state, + &press_with(KeyCode::Char('k'), KeyModifiers::CONTROL), + ); + assert!(state.query().is_empty()); + + let outcome = handle_settings_key(&mut state, &press(KeyCode::Char('\u{202e}'))); + assert!(matches!(outcome, SettingsKeyOutcome::Changed)); + assert!( + state.query().is_empty(), + "unsafe display characters must be consumed without insertion", + ); +} + /// PageDown/PageUp navigate in filter mode (advance x10 per press). #[test] fn filter_pageup_pagedown_navigates_in_filter_mode() { let mut s = make_state(); // Enter filter mode without typing — filtered_cache stays full. let _ = handle_settings_key(&mut s, &press(KeyCode::Char('/'))); - assert!(matches!(s.mode, SettingsModalMode::FilterFocused)); + assert!(matches!(s.mode(), SettingsModalMode::FilterFocused)); // PageDown from compact_mode advances toward the last row. let compact_idx = row_idx_for(&s, "compact_mode"); @@ -1225,7 +1402,7 @@ fn g_jumps_to_first_filtered_row_under_active_filter() { } // Commit filter into Browse with query preserved. let _ = handle_settings_key(&mut s, &press(KeyCode::Enter)); - assert!(matches!(s.mode, SettingsModalMode::Browse)); + assert!(matches!(s.mode(), SettingsModalMode::Browse)); // Selection is already on show_timestamps (snapped by // clamp_selected_to_visible). g should be a no-op (Unchanged) @@ -1517,30 +1694,22 @@ fn render_no_matches_placeholder_includes_query() { #[test] fn esc_in_picking_enum_mode_returns_to_browse() { let mut s = make_state(); - s.mode = SettingsModalMode::PickingEnum { - key: "compact_mode", - choices_idx: 0, - original_value: SettingValue::Bool(false), - supports_preview: true, - }; + navigate_to(&mut s, "scroll_mode"); + let _ = handle_settings_key(&mut s, &press(KeyCode::Enter)); let outcome = handle_settings_key(&mut s, &press(KeyCode::Esc)); assert!(matches!(outcome, SettingsKeyOutcome::Changed)); - assert!(matches!(s.mode, SettingsModalMode::Browse)); + assert!(matches!(s.mode(), SettingsModalMode::Browse)); } /// Esc in `EditingValue` returns to Browse. #[test] fn esc_in_editing_value_mode_returns_to_browse() { let mut s = make_state(); - s.mode = SettingsModalMode::EditingValue { - key: "compact_mode", - buffer: String::new(), - cursor_byte: 0, - validation_error: None, - }; + navigate_to(&mut s, "max_thoughts_width"); + let _ = handle_settings_key(&mut s, &press(KeyCode::Enter)); let outcome = handle_settings_key(&mut s, &press(KeyCode::Esc)); assert!(matches!(outcome, SettingsKeyOutcome::Changed)); - assert!(matches!(s.mode, SettingsModalMode::Browse)); + assert!(matches!(s.mode(), SettingsModalMode::Browse)); } // --------------------------------------------------------------------------- @@ -1589,6 +1758,7 @@ fn registry_kind_membership_through_pr_14() { "show_thinking_blocks", "show_timeline", "show_timestamps", + "page_flip_on_send", "simple_mode", "vim_mode", "remember_tool_approvals", @@ -1720,6 +1890,7 @@ fn defaults_round_trip_through_registry() { xai_grok_pager::appearance::cache::set_show_thinking_blocks(true); xai_grok_pager::appearance::cache::set_prompt_suggestions(true); xai_grok_pager::appearance::cache::set_group_tool_verbs(true); + xai_grok_pager::appearance::cache::set_page_flip_on_send(true); xai_grok_pager::appearance::cache::set_scroll_mode( xai_grok_pager::appearance::ScrollMode::Auto, ); @@ -1734,6 +1905,7 @@ fn defaults_round_trip_through_registry() { "screen_mode" => SettingValue::Enum("fullscreen"), "show_timestamps" => SettingValue::Bool(true), "show_timeline" => SettingValue::Bool(false), + "page_flip_on_send" => SettingValue::Bool(true), "simple_mode" => SettingValue::Bool(true), "vim_mode" => SettingValue::Bool(false), "remember_tool_approvals" => SettingValue::Bool(false), @@ -1752,7 +1924,7 @@ fn defaults_round_trip_through_registry() { "scroll_lines" => SettingValue::Int(3), "invert_scroll" => SettingValue::Bool(false), "display_refresh_auto_cadence" => SettingValue::Bool(false), - "coding_data_sharing" => SettingValue::Enum("opt-in"), + "coding_data_sharing" => SettingValue::Enum("opt-out"), "default_selected_permission" => SettingValue::Enum("always_allow_all_sessions"), "hunk_tracker_mode" => SettingValue::Enum("agent_only"), "voice_capture_mode" => SettingValue::Enum("hold"), @@ -1831,6 +2003,7 @@ fn settings_value_payload_matches_kind() { SettingsKeyOutcome::Action(Action::SetCompactMode(_)) | SettingsKeyOutcome::Action(Action::SetTimestamps(_)) | SettingsKeyOutcome::Action(Action::SetTimeline(_)) + | SettingsKeyOutcome::Action(Action::SetPageFlipOnSend(_)) | SettingsKeyOutcome::Action(Action::SetSimpleMode(_)) | SettingsKeyOutcome::Action(Action::SetMultilineMode(_)) | SettingsKeyOutcome::Action(Action::SetVimMode(_)) @@ -2153,7 +2326,7 @@ fn pr3_esc_in_picker_reverts_to_original() { ); // Sanity: modal starts in Browse on the synthetic Enum row. - assert!(matches!(s.mode, SettingsModalMode::Browse)); + assert!(matches!(s.mode(), SettingsModalMode::Browse)); match &s.rows[s.selected] { RowEntry::Setting { key, .. } => assert_eq!(*key, "test_enum"), _ => panic!("initial selection must be the synthetic Enum row"), @@ -2167,7 +2340,7 @@ fn pr3_esc_in_picker_reverts_to_original() { matches!(outcome, SettingsKeyOutcome::Changed), "Enter on Enum row should produce Changed, got {outcome:?}" ); - match s.mode { + match s.mode() { SettingsModalMode::PickingEnum { key, choices_idx, @@ -2185,7 +2358,7 @@ fn pr3_esc_in_picker_reverts_to_original() { // dispatch via action_for_enum, returns None here → Changed). let outcome = handle_settings_key(&mut s, &press(KeyCode::Down)); assert!(matches!(outcome, SettingsKeyOutcome::Changed)); - match s.mode { + match s.mode() { SettingsModalMode::PickingEnum { choices_idx, .. } => assert_eq!(choices_idx, 1), ref other => panic!("expected PickingEnum mode after Down, got {other:?}"), } @@ -2200,7 +2373,7 @@ fn pr3_esc_in_picker_reverts_to_original() { "Esc revert outcome should be Changed (or Action when arms exist), got {outcome:?}" ); assert!( - matches!(s.mode, SettingsModalMode::Browse), + matches!(s.mode(), SettingsModalMode::Browse), "Esc must transition the modal back to Browse" ); } @@ -2264,7 +2437,7 @@ fn pr4_theme_preview_and_commit_e2e() { matches!(outcome, SettingsKeyOutcome::Changed), "Enter on theme row should transition to PickingEnum, got {outcome:?}" ); - let original_canonical = match &s.mode { + let original_canonical = match &s.mode() { SettingsModalMode::PickingEnum { key, choices_idx, @@ -2296,7 +2469,7 @@ fn pr4_theme_preview_and_commit_e2e() { } other => panic!("expected Action::PreviewTheme(\"{next_canonical}\"), got {other:?}"), } - match s.mode { + match s.mode() { SettingsModalMode::PickingEnum { choices_idx, .. } => assert_eq!(choices_idx, next_idx), ref other => panic!("expected PickingEnum after Down, got {other:?}"), } @@ -2328,7 +2501,7 @@ fn pr4_theme_preview_and_commit_e2e() { other => panic!("expected Action::SetTheme(\"{next_canonical}\") commit, got {other:?}"), } assert!( - matches!(s.mode, SettingsModalMode::Browse), + matches!(s.mode(), SettingsModalMode::Browse), "Enter commit must return to Browse" ); } @@ -2357,7 +2530,7 @@ fn pr4_theme_picker_esc_dispatches_revert_action() { // Enter PickingEnum. let _ = handle_settings_key(&mut s, &press(KeyCode::Enter)); - assert!(matches!(s.mode, SettingsModalMode::PickingEnum { .. })); + assert!(matches!(s.mode(), SettingsModalMode::PickingEnum { .. })); // Preview-navigate so the original/current distinction is visible. let _ = handle_settings_key(&mut s, &press(KeyCode::Down)); @@ -2377,7 +2550,7 @@ fn pr4_theme_picker_esc_dispatches_revert_action() { } } assert!( - matches!(s.mode, SettingsModalMode::Browse), + matches!(s.mode(), SettingsModalMode::Browse), "Esc must return to Browse" ); } @@ -2536,12 +2709,8 @@ fn pr4_mouse_click_on_auto_light_theme_row_selects_without_emitting_action() { #[test] fn pr4_mouse_click_in_theme_picker_is_no_op() { let mut s = make_state(); - s.mode = SettingsModalMode::PickingEnum { - key: "theme", - choices_idx: 0, - original_value: SettingValue::Enum("groknight"), - supports_preview: true, - }; + navigate_to(&mut s, "theme"); + let _ = handle_settings_key(&mut s, &press(KeyCode::Enter)); synth_rects(&mut s); let outcome = handle_settings_mouse( &mut s, @@ -2553,7 +2722,7 @@ fn pr4_mouse_click_in_theme_picker_is_no_op() { matches!(outcome, SettingsKeyOutcome::Unchanged), "picker mode must ignore mouse clicks in PR 4, got {outcome:?}", ); - assert!(matches!(s.mode, SettingsModalMode::PickingEnum { .. })); + assert!(matches!(s.mode(), SettingsModalMode::PickingEnum { .. })); } // --------------------------------------------------------------------------- @@ -2779,7 +2948,7 @@ fn pr6_enter_on_permission_mode_row_enters_picking_enum() { matches!(outcome, SettingsKeyOutcome::Changed), "Enter on permission_mode row must transition to PickingEnum, got {outcome:?}" ); - match &s.mode { + match &s.mode() { SettingsModalMode::PickingEnum { key, original_value, @@ -2814,7 +2983,7 @@ fn pr6_permission_mode_picker_nav_does_not_dispatch_preview() { let mut s = make_state(); navigate_to(&mut s, "permission_mode"); let _ = handle_settings_key(&mut s, &press(KeyCode::Enter)); - assert!(matches!(s.mode, SettingsModalMode::PickingEnum { .. })); + assert!(matches!(s.mode(), SettingsModalMode::PickingEnum { .. })); // For "retreat" keys (Up/k) at choices_idx=0, the outcome is // Unchanged (clamp at first). We pre-navigate down so retreat @@ -2830,7 +2999,7 @@ fn pr6_permission_mode_picker_nav_does_not_dispatch_preview() { Action — that would persist on every keystroke and drain the \ permission_queue. Got {outcome:?}", ); - assert!(matches!(s.mode, SettingsModalMode::PickingEnum { .. })); + assert!(matches!(s.mode(), SettingsModalMode::PickingEnum { .. })); } } @@ -2862,9 +3031,9 @@ fn pr6_permission_mode_picker_enter_dispatches_set_permission_mode_commit() { let _ = handle_settings_key(&mut s, &press(KeyCode::Enter)); assert!( - matches!(s.mode, SettingsModalMode::PickingEnum { key, .. } if key == "permission_mode"), + matches!(s.mode(), SettingsModalMode::PickingEnum { key, .. } if key == "permission_mode"), "Enter on permission_mode row must open the picker, got {:?}", - s.mode, + s.mode(), ); // Navigate from the seeded default canonical to "always-approve". let steps = always_idx as isize - initial_idx as isize; @@ -2888,7 +3057,7 @@ fn pr6_permission_mode_picker_enter_dispatches_set_permission_mode_commit() { ), } assert!( - matches!(s.mode, SettingsModalMode::Browse), + matches!(s.mode(), SettingsModalMode::Browse), "Enter commit must return to Browse" ); } @@ -2910,7 +3079,7 @@ fn pr6_permission_mode_picker_esc_does_not_dispatch_action() { doing so would re-persist on every Esc. Got {outcome:?}" ); assert!( - matches!(s.mode, SettingsModalMode::Browse), + matches!(s.mode(), SettingsModalMode::Browse), "Esc must return to Browse" ); } @@ -2939,7 +3108,7 @@ fn pr6_picker_seeds_choices_idx_from_pager_snapshot_yolo_true() { .expect("permission_mode must have 'always-approve' choice"), _ => panic!("permission_mode must be Enum"), }; - match s.mode { + match s.mode() { SettingsModalMode::PickingEnum { choices_idx, ref original_value, @@ -3041,9 +3210,9 @@ fn pr6_mouse_click_on_unselected_permission_mode_row_only_selects() { ); assert!( - matches!(s.mode, SettingsModalMode::Browse), + matches!(s.mode(), SettingsModalMode::Browse), "first body-click must NOT enter the picker (two-stage UX), mode is {:?}", - s.mode, + s.mode(), ); } @@ -3062,7 +3231,7 @@ fn pr6_mouse_click_on_selected_permission_mode_row_opens_picker() { row_y, ); assert_eq!(s.selected, row_y as usize); - assert!(matches!(s.mode, SettingsModalMode::Browse)); + assert!(matches!(s.mode(), SettingsModalMode::Browse)); // Second click on the now-focused row: open the picker. let outcome = handle_settings_mouse( @@ -3075,7 +3244,7 @@ fn pr6_mouse_click_on_selected_permission_mode_row_opens_picker() { matches!(outcome, SettingsKeyOutcome::Changed), "second click on focused Enum row must open picker (Changed), got: {outcome:?}", ); - match &s.mode { + match &s.mode() { SettingsModalMode::PickingEnum { key, .. } => { assert_eq!(*key, "permission_mode"); } @@ -3102,7 +3271,7 @@ fn pr6_mouse_click_on_permission_mode_indicator_opens_picker_in_one_click() { matches!(outcome, SettingsKeyOutcome::Changed), "value click must open picker in one click, got: {outcome:?}", ); - match &s.mode { + match &s.mode() { SettingsModalMode::PickingEnum { key, .. } => { assert_eq!(*key, "permission_mode"); } @@ -3143,9 +3312,9 @@ fn pr11_picker_commit_for_default_dispatches_set_permission_mode_default() { navigate_to(&mut s, "permission_mode"); let _ = handle_settings_key(&mut s, &press(KeyCode::Enter)); assert!( - matches!(s.mode, SettingsModalMode::PickingEnum { key, .. } if key == "permission_mode"), + matches!(s.mode(), SettingsModalMode::PickingEnum { key, .. } if key == "permission_mode"), "Enter on permission_mode row must open the picker, got {:?}", - s.mode, + s.mode(), ); let steps = default_idx as isize - initial_idx as isize; let nav_key = if steps > 0 { @@ -3164,7 +3333,7 @@ fn pr11_picker_commit_for_default_dispatches_set_permission_mode_default() { ), } assert!( - matches!(s.mode, SettingsModalMode::Browse), + matches!(s.mode(), SettingsModalMode::Browse), "Enter commit must return to Browse" ); } @@ -3188,9 +3357,9 @@ fn pr11_picker_commit_for_ask_dispatches_set_permission_mode_ask() { navigate_to(&mut s, "permission_mode"); let _ = handle_settings_key(&mut s, &press(KeyCode::Enter)); assert!( - matches!(s.mode, SettingsModalMode::PickingEnum { key, .. } if key == "permission_mode"), + matches!(s.mode(), SettingsModalMode::PickingEnum { key, .. } if key == "permission_mode"), "Enter on permission_mode row must open the picker, got {:?}", - s.mode, + s.mode(), ); let reg = SettingsRegistry::defaults(); @@ -3605,7 +3774,7 @@ fn docs_footer_renders_for_browse_and_picker() { // Navigate to a row with an Enum kind (theme). navigate_to(&mut s, "theme"); let _ = handle_settings_key(&mut s, &press(KeyCode::Enter)); - assert!(matches!(s.mode, SettingsModalMode::PickingEnum { .. })); + assert!(matches!(s.mode(), SettingsModalMode::PickingEnum { .. })); } let mut buf = Buffer::empty(area); xai_grok_pager::views::settings_modal::render_settings_modal( @@ -3998,22 +4167,18 @@ fn pr15_int_stepper_commit_dispatches_typed_setter() { matches!(outcome, SettingsKeyOutcome::Changed), "Enter on Int row must transition to EditingValue, got {outcome:?}" ); - match &s.mode { - SettingsModalMode::EditingValue { buffer, .. } => { - assert_eq!(buffer, "120", "buffer must seed from default"); - } - other => panic!("expected EditingValue mode, got {other:?}"), - } + assert_eq!( + s.editing_buffer(), + Some("120"), + "buffer must seed from default", + ); // Stepper: Up = +5 → 125. Right = +10 → 135. Down x3 = -15 // → 120. Up x16 = +80 → 200. for _ in 0..16 { let _ = handle_settings_key(&mut s, &press(KeyCode::Up)); } - match &s.mode { - SettingsModalMode::EditingValue { buffer, .. } => assert_eq!(buffer, "200"), - other => panic!("expected EditingValue, got {other:?}"), - } + assert_eq!(s.editing_buffer(), Some("200")); // Enter commits at 200. let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter)); @@ -4022,7 +4187,7 @@ fn pr15_int_stepper_commit_dispatches_typed_setter() { other => panic!("expected SetMaxThoughtsWidth(200), got {other:?}"), } assert!( - matches!(s.mode, SettingsModalMode::Browse), + matches!(s.mode(), SettingsModalMode::Browse), "successful commit must return to Browse" ); } @@ -4056,7 +4221,7 @@ fn pr14_default_model_picker_commits_resolved_model_id() { "Enter on DynamicEnum row must transition to PickingEnum, got {outcome:?}" ); assert!( - matches!(s.mode, SettingsModalMode::PickingEnum { key, .. } if key == "default_model"), + matches!(s.mode(), SettingsModalMode::PickingEnum { key, .. } if key == "default_model"), "Enter must transition to PickingEnum for default_model" ); @@ -4080,7 +4245,7 @@ fn pr14_default_model_picker_commits_resolved_model_id() { other => panic!("expected SetDefaultModel() on commit, got {other:?}"), } assert!( - matches!(s.mode, SettingsModalMode::Browse), + matches!(s.mode(), SettingsModalMode::Browse), "successful commit must return to Browse" ); } @@ -4105,7 +4270,7 @@ fn pr14_default_model_picker_row_zero_commits_clear_action() { // Picker opens with choices_idx at the snapshot's current model, // OR at 0 when current_model_name is None. The fixture above // leaves current_model_name as None → picker opens on row 0. - match &s.mode { + match &s.mode() { SettingsModalMode::PickingEnum { choices_idx, .. } => { assert_eq!( *choices_idx, 0, @@ -4171,9 +4336,9 @@ fn pr14_mouse_click_on_dynamic_enum_row_opens_picker() { "second click on DynamicEnum row must open picker, got {outcome:?}", ); assert!( - matches!(s.mode, SettingsModalMode::PickingEnum { key, .. } if key == "default_model"), + matches!(s.mode(), SettingsModalMode::PickingEnum { key, .. } if key == "default_model"), "second click on DynamicEnum row must transition to PickingEnum, got {:?}", - s.mode, + s.mode(), ); } @@ -4215,9 +4380,9 @@ fn pr8_mouse_click_on_int_row_opens_editor() { "second click on Int row must be Changed, got {outcome:?}", ); assert!( - matches!(s.mode, SettingsModalMode::EditingValue { key, .. } if key == "max_thoughts_width"), + matches!(s.mode(), SettingsModalMode::EditingValue { key, .. } if key == "max_thoughts_width"), "second click on Int row must transition to EditingValue, got {:?}", - s.mode, + s.mode(), ); } @@ -4229,46 +4394,25 @@ fn pr15_int_stepper_up_down_left_right_steps_and_clamps() { let _ = handle_settings_key(&mut s, &press(KeyCode::Enter)); // Default 120. Up: 120 + 5 = 125. let _ = handle_settings_key(&mut s, &press(KeyCode::Up)); - match &s.mode { - SettingsModalMode::EditingValue { buffer, .. } => assert_eq!(buffer, "125"), - _ => panic!("expected EditingValue"), - } + assert_eq!(s.editing_buffer(), Some("125")); // Right: 125 + 10 = 135. let _ = handle_settings_key(&mut s, &press(KeyCode::Right)); - match &s.mode { - SettingsModalMode::EditingValue { buffer, .. } => assert_eq!(buffer, "135"), - _ => panic!("expected EditingValue"), - } + assert_eq!(s.editing_buffer(), Some("135")); // Down 20x: 135 - 100 = 35 → clamps to min (40). for _ in 0..20 { let _ = handle_settings_key(&mut s, &press(KeyCode::Down)); } - match &s.mode { - SettingsModalMode::EditingValue { buffer, .. } => { - assert_eq!(buffer, "40", "must clamp to min"); - } - _ => panic!("expected EditingValue"), - } + assert_eq!(s.editing_buffer(), Some("40"), "must clamp to min"); // Right 100x: 40 + 1000 = 1040 → clamps to max (500). for _ in 0..100 { let _ = handle_settings_key(&mut s, &press(KeyCode::Right)); } - match &s.mode { - SettingsModalMode::EditingValue { buffer, .. } => { - assert_eq!(buffer, "500", "must clamp to max"); - } - _ => panic!("expected EditingValue"), - } + assert_eq!(s.editing_buffer(), Some("500"), "must clamp to max"); // Left 100x: 500 - 1000 = -500 → clamps to min (40). for _ in 0..100 { let _ = handle_settings_key(&mut s, &press(KeyCode::Left)); } - match &s.mode { - SettingsModalMode::EditingValue { buffer, .. } => { - assert_eq!(buffer, "40", "Left must clamp to min"); - } - _ => panic!("expected EditingValue"), - } + assert_eq!(s.editing_buffer(), Some("40"), "Left must clamp to min",); } /// Int stepper rejects all text-input keys. @@ -4277,10 +4421,10 @@ fn pr15_int_stepper_rejects_text_input_keys() { let mut s = make_state(); navigate_to(&mut s, "max_thoughts_width"); let _ = handle_settings_key(&mut s, &press(KeyCode::Enter)); - let initial_buffer = match &s.mode { - SettingsModalMode::EditingValue { buffer, .. } => buffer.clone(), - _ => panic!("expected EditingValue"), - }; + let initial_buffer = s + .editing_buffer() + .expect("expected EditingValue") + .to_owned(); assert_eq!(initial_buffer, "120", "buffer seeds from default"); let reject_keys = &[ @@ -4305,15 +4449,11 @@ fn pr15_int_stepper_rejects_text_input_keys() { matches!(outcome, SettingsKeyOutcome::Unchanged), "Int stepper must reject {k:?} (got {outcome:?})", ); - match &s.mode { - SettingsModalMode::EditingValue { buffer, .. } => { - assert_eq!( - buffer, &initial_buffer, - "buffer must stay at {initial_buffer:?} after {k:?}", - ); - } - _ => panic!("mode must remain EditingValue"), - } + assert_eq!( + s.editing_buffer(), + Some(initial_buffer.as_str()), + "buffer must stay at {initial_buffer:?} after {k:?}", + ); } // Ctrl-modifier chords are also rejected. @@ -4328,15 +4468,35 @@ fn pr15_int_stepper_rejects_text_input_keys() { matches!(outcome, SettingsKeyOutcome::Unchanged), "Int stepper must reject Ctrl+c (got {outcome:?})", ); - match &s.mode { - SettingsModalMode::EditingValue { buffer, .. } => { - assert_eq!( - buffer, &initial_buffer, - "buffer must stay unchanged after Ctrl+c" - ); - } - _ => panic!("mode must remain EditingValue"), + assert_eq!( + s.editing_buffer(), + Some(initial_buffer.as_str()), + "buffer must stay unchanged after Ctrl+c", + ); +} + +#[test] +fn int_stepper_rejects_modified_esc_but_plain_esc_cancels() { + let mut state = make_state(); + navigate_to(&mut state, "max_thoughts_width"); + let _ = handle_settings_key(&mut state, &press(KeyCode::Enter)); + for modifiers in [ + KeyModifiers::ALT, + KeyModifiers::CONTROL, + KeyModifiers::SUPER, + ] { + let outcome = handle_settings_key(&mut state, &press_with(KeyCode::Esc, modifiers)); + assert!(matches!(outcome, SettingsKeyOutcome::Unchanged)); + assert!(matches!( + state.mode(), + SettingsModalMode::EditingValue { .. } + )); + assert_eq!(state.editing_buffer(), Some("120")); } + + let outcome = handle_settings_key(&mut state, &press(KeyCode::Esc)); + assert!(matches!(outcome, SettingsKeyOutcome::Changed)); + assert!(matches!(state.mode(), SettingsModalMode::Browse)); } /// Esc in EditingValue returns to Browse without dispatching. @@ -4355,7 +4515,7 @@ fn pr8_esc_in_editing_value_cancels_without_dispatch() { "Esc in EditingValue must be Changed (mode swap), got {outcome:?}" ); assert!( - matches!(s.mode, SettingsModalMode::Browse), + matches!(s.mode(), SettingsModalMode::Browse), "Esc must return to Browse" ); } @@ -4485,7 +4645,7 @@ fn pr9_enter_on_coding_data_sharing_row_enters_picking_enum() { matches!(outcome, SettingsKeyOutcome::Changed), "Enter on coding_data_sharing row must transition to PickingEnum, got {outcome:?}" ); - match &s.mode { + match &s.mode() { SettingsModalMode::PickingEnum { key, original_value, @@ -4494,8 +4654,8 @@ fn pr9_enter_on_coding_data_sharing_row_enters_picking_enum() { assert_eq!(*key, "coding_data_sharing"); assert_eq!( original_value, - &SettingValue::Enum("opt-in"), - "default snapshot opt_out=false → original 'opt-in'" + &SettingValue::Enum("opt-out"), + "default snapshot opt_out=true → original 'opt-out'" ); } other => panic!("expected PickingEnum mode, got {other:?}"), @@ -4514,10 +4674,15 @@ fn pr9_coding_data_sharing_picker_nav_does_not_dispatch_preview() { let mut s = make_state(); navigate_to(&mut s, "coding_data_sharing"); let _ = handle_settings_key(&mut s, &press(KeyCode::Enter)); - assert!(matches!(s.mode, SettingsModalMode::PickingEnum { .. })); + assert!(matches!(s.mode(), SettingsModalMode::PickingEnum { .. })); + // Pre-position so the nav key under test has room to move no matter + // which choice the registry default opens the picker on (Up needs + // idx > 0, Down needs idx < last). if matches!(nav_key, KeyCode::Up | KeyCode::Char('k')) { let _ = handle_settings_key(&mut s, &press(KeyCode::Down)); + } else { + let _ = handle_settings_key(&mut s, &press(KeyCode::Up)); } let outcome = handle_settings_key(&mut s, &press(*nav_key)); @@ -4526,7 +4691,7 @@ fn pr9_coding_data_sharing_picker_nav_does_not_dispatch_preview() { "Nav key {nav_key:?} in coding_data_sharing picker MUST NOT dispatch a preview \ Action — that would fire a network round-trip per keystroke. Got {outcome:?}", ); - assert!(matches!(s.mode, SettingsModalMode::PickingEnum { .. })); + assert!(matches!(s.mode(), SettingsModalMode::PickingEnum { .. })); } } @@ -4557,8 +4722,22 @@ fn pr9_coding_data_sharing_picker_enter_dispatches_set_commit() { let mut s = make_state(); navigate_to(&mut s, "coding_data_sharing"); let _ = handle_settings_key(&mut s, &press(KeyCode::Enter)); - // Nav to the OTHER choice. - let _ = handle_settings_key(&mut s, &press(KeyCode::Down)); + // Nav to the OTHER choice — direction depends on where the registry + // default opened the picker, so derive it instead of hardcoding Down. + let default_idx = choices + .iter() + .position(|c| c.canonical == default_canonical) + .expect("default must be a registry choice"); + let other_idx = choices + .iter() + .position(|c| c.canonical == other_canonical) + .expect("other choice must be in the registry"); + let nav = if other_idx > default_idx { + KeyCode::Down + } else { + KeyCode::Up + }; + let _ = handle_settings_key(&mut s, &press(nav)); // Enter → commit. let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter)); match outcome { @@ -4571,7 +4750,7 @@ fn pr9_coding_data_sharing_picker_enter_dispatches_set_commit() { other => panic!("expected Action::SetCodingDataSharing commit, got {other:?}"), } assert!( - matches!(s.mode, SettingsModalMode::Browse), + matches!(s.mode(), SettingsModalMode::Browse), "Enter commit must return to Browse" ); } @@ -4591,7 +4770,7 @@ fn pr9_coding_data_sharing_picker_esc_does_not_dispatch_action() { doing so would fire an ACP round-trip on every Esc. Got {outcome:?}" ); assert!( - matches!(s.mode, SettingsModalMode::Browse), + matches!(s.mode(), SettingsModalMode::Browse), "Esc must return to Browse" ); } @@ -4618,7 +4797,7 @@ fn pr9_picker_seeds_choices_idx_from_pager_snapshot_opt_out_true() { .expect("coding_data_sharing must have 'opt-out' choice"), _ => panic!("coding_data_sharing must be Enum"), }; - match s.mode { + match s.mode() { SettingsModalMode::PickingEnum { choices_idx, ref original_value, @@ -4713,7 +4892,7 @@ fn pr9_mouse_click_on_unselected_coding_data_sharing_row_only_selects() { "first body-click on unselected coding_data_sharing row should only select, got: {outcome:?}", ); assert_eq!(s.selected, row_y as usize); - assert!(matches!(s.mode, SettingsModalMode::Browse)); + assert!(matches!(s.mode(), SettingsModalMode::Browse)); } /// Second click on selected row opens picker. @@ -4743,7 +4922,7 @@ fn pr9_mouse_click_on_selected_coding_data_sharing_row_opens_picker() { matches!(outcome, SettingsKeyOutcome::Changed), "second click on focused Enum row must open picker, got: {outcome:?}", ); - match &s.mode { + match &s.mode() { SettingsModalMode::PickingEnum { key, .. } => { assert_eq!(*key, "coding_data_sharing"); } @@ -4768,7 +4947,7 @@ fn pr9_mouse_click_on_coding_data_sharing_indicator_opens_picker_in_one_click() matches!(outcome, SettingsKeyOutcome::Changed), "value click must open picker in one click, got: {outcome:?}", ); - match &s.mode { + match &s.mode() { SettingsModalMode::PickingEnum { key, .. } => { assert_eq!(*key, "coding_data_sharing"); } @@ -4847,7 +5026,7 @@ fn default_selected_permission_enter_enters_picking_enum() { matches!(outcome, SettingsKeyOutcome::Changed), "Enter on default_selected_permission row must transition to PickingEnum, got {outcome:?}" ); - match &s.mode { + match &s.mode() { SettingsModalMode::PickingEnum { key, original_value, @@ -4876,7 +5055,7 @@ fn default_selected_permission_picker_nav_does_not_dispatch_preview() { let mut s = make_state(); navigate_to(&mut s, "default_selected_permission"); let _ = handle_settings_key(&mut s, &press(KeyCode::Enter)); - assert!(matches!(s.mode, SettingsModalMode::PickingEnum { .. })); + assert!(matches!(s.mode(), SettingsModalMode::PickingEnum { .. })); if matches!(nav_key, KeyCode::Up | KeyCode::Char('k')) { let _ = handle_settings_key(&mut s, &press(KeyCode::Down)); @@ -4888,7 +5067,7 @@ fn default_selected_permission_picker_nav_does_not_dispatch_preview() { "Nav key {nav_key:?} in default_selected_permission picker MUST NOT dispatch an \ Action (no preview). Got {outcome:?}", ); - assert!(matches!(s.mode, SettingsModalMode::PickingEnum { .. })); + assert!(matches!(s.mode(), SettingsModalMode::PickingEnum { .. })); } } @@ -4933,7 +5112,7 @@ fn default_selected_permission_picker_enter_dispatches_set_commit() { other => panic!("expected Action::SetDefaultSelectedPermission commit, got {other:?}"), } assert!( - matches!(s.mode, SettingsModalMode::Browse), + matches!(s.mode(), SettingsModalMode::Browse), "Enter commit must return to Browse" ); } @@ -4952,7 +5131,7 @@ fn default_selected_permission_picker_esc_does_not_dispatch_action() { "Esc on non-preview Enum picker must NOT emit an Action. Got {outcome:?}" ); assert!( - matches!(s.mode, SettingsModalMode::Browse), + matches!(s.mode(), SettingsModalMode::Browse), "Esc must return to Browse" ); } @@ -4979,7 +5158,7 @@ fn default_selected_permission_mouse_click_on_unselected_row_only_selects() { "first body-click on unselected default_selected_permission row should only select, got: {outcome:?}", ); assert_eq!(s.selected, row_y as usize); - assert!(matches!(s.mode, SettingsModalMode::Browse)); + assert!(matches!(s.mode(), SettingsModalMode::Browse)); } /// Second click on selected row opens picker. @@ -5009,7 +5188,7 @@ fn default_selected_permission_mouse_click_on_selected_row_opens_picker() { matches!(outcome, SettingsKeyOutcome::Changed), "second click on focused Enum row must open picker, got: {outcome:?}", ); - match &s.mode { + match &s.mode() { SettingsModalMode::PickingEnum { key, .. } => { assert_eq!(*key, "default_selected_permission"); } @@ -5036,7 +5215,7 @@ fn default_selected_permission_mouse_click_on_indicator_opens_picker_in_one_clic matches!(outcome, SettingsKeyOutcome::Changed), "value click must open picker in one click, got: {outcome:?}", ); - match &s.mode { + match &s.mode() { SettingsModalMode::PickingEnum { key, .. } => { assert_eq!(*key, "default_selected_permission"); } @@ -5194,7 +5373,7 @@ fn pr10_enter_on_plan_mode_row_enters_picking_enum() { matches!(outcome, SettingsKeyOutcome::Changed), "Enter on plan_mode row must transition to PickingEnum, got {outcome:?}" ); - match &s.mode { + match &s.mode() { SettingsModalMode::PickingEnum { key, original_value, @@ -5227,7 +5406,7 @@ fn pr10_plan_mode_picker_nav_does_not_dispatch_preview() { let mut s = make_state(); navigate_to(&mut s, "plan_mode"); let _ = handle_settings_key(&mut s, &press(KeyCode::Enter)); - assert!(matches!(s.mode, SettingsModalMode::PickingEnum { .. })); + assert!(matches!(s.mode(), SettingsModalMode::PickingEnum { .. })); if matches!(nav_key, KeyCode::Up | KeyCode::Char('k')) { let _ = handle_settings_key(&mut s, &press(KeyCode::Down)); @@ -5239,7 +5418,7 @@ fn pr10_plan_mode_picker_nav_does_not_dispatch_preview() { "Nav key {nav_key:?} in plan_mode picker MUST NOT dispatch a preview \ Action — that would fire an ACP round-trip per keystroke. Got {outcome:?}", ); - assert!(matches!(s.mode, SettingsModalMode::PickingEnum { .. })); + assert!(matches!(s.mode(), SettingsModalMode::PickingEnum { .. })); } } @@ -5270,7 +5449,7 @@ fn pr10_plan_mode_picker_enter_dispatches_set_commit() { other => panic!("expected Action::SetPlanMode commit, got {other:?}"), } assert!( - matches!(s.mode, SettingsModalMode::Browse), + matches!(s.mode(), SettingsModalMode::Browse), "Enter commit must return to Browse" ); } @@ -5293,7 +5472,7 @@ fn pr10_plan_mode_picker_esc_does_not_dispatch_action() { doing so would fire an ACP round-trip on every Esc. Got {outcome:?}" ); assert!( - matches!(s.mode, SettingsModalMode::Browse), + matches!(s.mode(), SettingsModalMode::Browse), "Esc must return to Browse" ); } @@ -5322,7 +5501,7 @@ fn pr10_picker_seeds_choices_idx_from_pager_snapshot_plan_mode_active() { .expect("plan_mode must have 'on' choice"), _ => panic!("plan_mode must be Enum"), }; - match s.mode { + match s.mode() { SettingsModalMode::PickingEnum { choices_idx, ref original_value, @@ -5398,7 +5577,7 @@ fn pr10_mouse_click_on_unselected_plan_mode_row_only_selects() { "first body-click on unselected plan_mode row should only select, got: {outcome:?}", ); assert_eq!(s.selected, row_y as usize); - assert!(matches!(s.mode, SettingsModalMode::Browse)); + assert!(matches!(s.mode(), SettingsModalMode::Browse)); } /// Second mouse-click on the selected row opens the picker for the @@ -5429,7 +5608,7 @@ fn pr10_mouse_click_on_selected_plan_mode_row_opens_picker() { matches!(outcome, SettingsKeyOutcome::Changed), "second click on focused Enum row must open picker, got: {outcome:?}", ); - match &s.mode { + match &s.mode() { SettingsModalMode::PickingEnum { key, .. } => { assert_eq!(*key, "plan_mode"); } @@ -5456,7 +5635,7 @@ fn pr10_mouse_click_on_plan_mode_indicator_opens_picker_in_one_click() { matches!(outcome, SettingsKeyOutcome::Changed), "value click must open picker in one click, got: {outcome:?}", ); - match &s.mode { + match &s.mode() { SettingsModalMode::PickingEnum { key, .. } => { assert_eq!(*key, "plan_mode"); } @@ -5533,7 +5712,7 @@ fn enter_on_render_mermaid_row_enters_picking_enum() { matches!(outcome, SettingsKeyOutcome::Changed), "Enter on render_mermaid row must transition to PickingEnum, got {outcome:?}" ); - match &s.mode { + match &s.mode() { SettingsModalMode::PickingEnum { key, original_value, @@ -5564,7 +5743,7 @@ fn render_mermaid_picker_nav_does_not_dispatch_preview() { let mut s = make_state(); navigate_to(&mut s, "render_mermaid"); let _ = handle_settings_key(&mut s, &press(KeyCode::Enter)); - assert!(matches!(s.mode, SettingsModalMode::PickingEnum { .. })); + assert!(matches!(s.mode(), SettingsModalMode::PickingEnum { .. })); if matches!(nav_key, KeyCode::Up | KeyCode::Char('k')) { let _ = handle_settings_key(&mut s, &press(KeyCode::Down)); @@ -5576,7 +5755,7 @@ fn render_mermaid_picker_nav_does_not_dispatch_preview() { "Nav key {nav_key:?} in render_mermaid picker MUST NOT dispatch a preview \ Action. Got {outcome:?}", ); - assert!(matches!(s.mode, SettingsModalMode::PickingEnum { .. })); + assert!(matches!(s.mode(), SettingsModalMode::PickingEnum { .. })); } } @@ -5605,7 +5784,7 @@ fn render_mermaid_picker_enter_dispatches_set_commit() { other => panic!("expected Action::SetRenderMermaid commit, got {other:?}"), } assert!( - matches!(s.mode, SettingsModalMode::Browse), + matches!(s.mode(), SettingsModalMode::Browse), "Enter commit must return to Browse" ); } @@ -5626,7 +5805,7 @@ fn render_mermaid_picker_esc_does_not_dispatch_action() { "Esc on non-preview Enum picker must NOT emit an Action. Got {outcome:?}" ); assert!( - matches!(s.mode, SettingsModalMode::Browse), + matches!(s.mode(), SettingsModalMode::Browse), "Esc must return to Browse" ); } @@ -5675,7 +5854,7 @@ fn mouse_click_on_unselected_render_mermaid_row_only_selects() { "first body-click on unselected render_mermaid row should only select, got: {outcome:?}", ); assert_eq!(s.selected, row_y as usize); - assert!(matches!(s.mode, SettingsModalMode::Browse)); + assert!(matches!(s.mode(), SettingsModalMode::Browse)); } /// Second mouse-click on the selected row opens the picker for the Enum row — @@ -5704,7 +5883,7 @@ fn mouse_click_on_selected_render_mermaid_row_opens_picker() { matches!(outcome, SettingsKeyOutcome::Changed), "second click on focused Enum row must open picker, got: {outcome:?}", ); - match &s.mode { + match &s.mode() { SettingsModalMode::PickingEnum { key, .. } => assert_eq!(*key, "render_mermaid"), _ => panic!("second click on focused render_mermaid row must enter PickingEnum"), } @@ -5727,7 +5906,7 @@ fn mouse_click_on_render_mermaid_indicator_opens_picker_in_one_click() { matches!(outcome, SettingsKeyOutcome::Changed), "value click must open picker in one click, got: {outcome:?}", ); - match &s.mode { + match &s.mode() { SettingsModalMode::PickingEnum { key, .. } => assert_eq!(*key, "render_mermaid"), _ => panic!("value click on render_mermaid must enter PickingEnum"), } @@ -5750,16 +5929,16 @@ fn enter_on_screen_mode_row_enters_picking_enum() { matches!(outcome, SettingsKeyOutcome::Changed), "Enter on screen_mode row must transition to PickingEnum, got {outcome:?}" ); - match &s.mode { + match s.mode() { SettingsModalMode::PickingEnum { key, original_value, .. } => { - assert_eq!(*key, "screen_mode"); + assert_eq!(key, "screen_mode"); assert_eq!( original_value, - &SettingValue::Enum("fullscreen"), + SettingValue::Enum("fullscreen"), "default UiConfig screen_mode=None → original 'fullscreen'" ); } @@ -5780,7 +5959,7 @@ fn screen_mode_picker_nav_does_not_dispatch_preview() { let mut s = make_state(); navigate_to(&mut s, "screen_mode"); let _ = handle_settings_key(&mut s, &press(KeyCode::Enter)); - assert!(matches!(s.mode, SettingsModalMode::PickingEnum { .. })); + assert!(matches!(s.mode(), SettingsModalMode::PickingEnum { .. })); if matches!(nav_key, KeyCode::Up | KeyCode::Char('k')) { let _ = handle_settings_key(&mut s, &press(KeyCode::Down)); @@ -5792,7 +5971,7 @@ fn screen_mode_picker_nav_does_not_dispatch_preview() { "Nav key {nav_key:?} in screen_mode picker MUST NOT dispatch a preview \ Action. Got {outcome:?}", ); - assert!(matches!(s.mode, SettingsModalMode::PickingEnum { .. })); + assert!(matches!(s.mode(), SettingsModalMode::PickingEnum { .. })); } } @@ -5816,7 +5995,7 @@ fn screen_mode_picker_enter_dispatches_set_commit() { other => panic!("expected Action::SetScreenMode commit, got {other:?}"), } assert!( - matches!(s.mode, SettingsModalMode::Browse), + matches!(s.mode(), SettingsModalMode::Browse), "Enter commit must return to Browse" ); } @@ -5869,8 +6048,8 @@ fn mouse_click_on_screen_mode_indicator_opens_picker_in_one_click() { matches!(outcome, SettingsKeyOutcome::Changed), "value click must open picker in one click, got: {outcome:?}", ); - match &s.mode { - SettingsModalMode::PickingEnum { key, .. } => assert_eq!(*key, "screen_mode"), + match s.mode() { + SettingsModalMode::PickingEnum { key, .. } => assert_eq!(key, "screen_mode"), _ => panic!("value click on screen_mode must enter PickingEnum"), } } @@ -5892,7 +6071,7 @@ fn enter_on_hunk_tracker_mode_row_enters_picking_enum() { matches!(outcome, SettingsKeyOutcome::Changed), "Enter on hunk_tracker_mode row must transition to PickingEnum, got {outcome:?}" ); - match &s.mode { + match &s.mode() { SettingsModalMode::PickingEnum { key, original_value, @@ -5923,7 +6102,7 @@ fn hunk_tracker_mode_picker_nav_does_not_dispatch_preview() { let mut s = make_state(); navigate_to(&mut s, "hunk_tracker_mode"); let _ = handle_settings_key(&mut s, &press(KeyCode::Enter)); - assert!(matches!(s.mode, SettingsModalMode::PickingEnum { .. })); + assert!(matches!(s.mode(), SettingsModalMode::PickingEnum { .. })); if matches!(nav_key, KeyCode::Up | KeyCode::Char('k')) { let _ = handle_settings_key(&mut s, &press(KeyCode::Down)); @@ -5935,7 +6114,7 @@ fn hunk_tracker_mode_picker_nav_does_not_dispatch_preview() { "Nav key {nav_key:?} in hunk_tracker_mode picker MUST NOT dispatch a preview \ Action. Got {outcome:?}", ); - assert!(matches!(s.mode, SettingsModalMode::PickingEnum { .. })); + assert!(matches!(s.mode(), SettingsModalMode::PickingEnum { .. })); } } @@ -5961,7 +6140,7 @@ fn hunk_tracker_mode_picker_enter_dispatches_set_commit() { other => panic!("expected Action::SetHunkTrackerMode commit, got {other:?}"), } assert!( - matches!(s.mode, SettingsModalMode::Browse), + matches!(s.mode(), SettingsModalMode::Browse), "Enter commit must return to Browse" ); } @@ -6003,7 +6182,7 @@ fn mouse_click_on_hunk_tracker_mode_indicator_opens_picker_in_one_click() { matches!(outcome, SettingsKeyOutcome::Changed), "value click must open picker in one click, got: {outcome:?}", ); - match &s.mode { + match &s.mode() { SettingsModalMode::PickingEnum { key, .. } => assert_eq!(*key, "hunk_tracker_mode"), _ => panic!("value click on hunk_tracker_mode must enter PickingEnum"), } @@ -6024,7 +6203,7 @@ fn enter_on_voice_stt_language_row_enters_picking_enum() { matches!(outcome, SettingsKeyOutcome::Changed), "Enter on voice_stt_language row must transition to PickingEnum, got {outcome:?}" ); - match &s.mode { + match &s.mode() { SettingsModalMode::PickingEnum { key, original_value, @@ -6058,7 +6237,7 @@ fn voice_stt_language_picker_enter_dispatches_set_commit() { other => panic!("expected Action::SetVoiceSttLanguage commit, got {other:?}"), } assert!( - matches!(s.mode, SettingsModalMode::Browse), + matches!(s.mode(), SettingsModalMode::Browse), "Enter commit must return to Browse" ); } @@ -6081,7 +6260,7 @@ fn mouse_click_on_voice_stt_language_indicator_opens_picker_in_one_click() { matches!(outcome, SettingsKeyOutcome::Changed), "value click must open picker in one click, got: {outcome:?}", ); - match &s.mode { + match &s.mode() { SettingsModalMode::PickingEnum { key, .. } => assert_eq!(*key, "voice_stt_language"), _ => panic!("value click on voice_stt_language must enter PickingEnum"), } @@ -6460,7 +6639,7 @@ fn enter_on_keep_text_selection_row_enters_picking_enum() { matches!(outcome, SettingsKeyOutcome::Changed), "Enter on keep_text_selection row must transition to PickingEnum, got {outcome:?}" ); - match &s.mode { + match &s.mode() { SettingsModalMode::PickingEnum { key, original_value, @@ -6589,7 +6768,7 @@ fn mouse_click_on_selected_keep_text_selection_row_opens_picker() { matches!(outcome, SettingsKeyOutcome::Changed), "second click on focused keep_text_selection row must enter PickingEnum, got {outcome:?}" ); - match &s.mode { + match &s.mode() { SettingsModalMode::PickingEnum { key, .. } => assert_eq!(*key, "keep_text_selection"), _ => panic!("second click on focused keep_text_selection row must enter PickingEnum"), } @@ -6607,7 +6786,7 @@ fn mouse_click_on_keep_text_selection_indicator_opens_picker_in_one_click() { 70, row_y, ); - match &s.mode { + match &s.mode() { SettingsModalMode::PickingEnum { key, .. } => assert_eq!(*key, "keep_text_selection"), _ => panic!("value click on keep_text_selection must enter PickingEnum, got {outcome:?}"), } @@ -6629,7 +6808,7 @@ fn keep_text_selection_hold_snapshot_seeds_picker_at_hold() { ); navigate_to(&mut s, "keep_text_selection"); let _ = handle_settings_key(&mut s, &press(KeyCode::Enter)); - match &s.mode { + match &s.mode() { SettingsModalMode::PickingEnum { original_value, choices_idx, @@ -6657,27 +6836,23 @@ fn scroll_speed_int_stepper_commit_dispatches_typed_setter() { matches!(outcome, SettingsKeyOutcome::Changed), "Enter on Int row must transition to EditingValue, got {outcome:?}" ); - match &s.mode { - SettingsModalMode::EditingValue { buffer, .. } => { - assert_eq!(buffer, "50", "buffer must seed from default 50"); - } - other => panic!("expected EditingValue mode, got {other:?}"), - } + assert_eq!( + s.editing_buffer(), + Some("50"), + "buffer must seed from default 50", + ); // Up = +1 → 51. Right = +5 → 56. let _ = handle_settings_key(&mut s, &press(KeyCode::Up)); let _ = handle_settings_key(&mut s, &press(KeyCode::Right)); - match &s.mode { - SettingsModalMode::EditingValue { buffer, .. } => assert_eq!(buffer, "56"), - other => panic!("expected EditingValue, got {other:?}"), - } + assert_eq!(s.editing_buffer(), Some("56")); let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter)); match outcome { SettingsKeyOutcome::Action(Action::SetScrollSpeed(56)) => {} other => panic!("expected SetScrollSpeed(56), got {other:?}"), } - assert!(matches!(s.mode, SettingsModalMode::Browse)); + assert!(matches!(s.mode(), SettingsModalMode::Browse)); } #[test] @@ -6705,9 +6880,9 @@ fn scroll_speed_mouse_click_opens_editor() { "second click on focused Int row must enter the editor, got {outcome:?}" ); assert!( - matches!(s.mode, SettingsModalMode::EditingValue { key, .. } if key == "scroll_speed"), + matches!(s.mode(), SettingsModalMode::EditingValue { key, .. } if key == "scroll_speed"), "mode must be EditingValue(scroll_speed) after Enter-equivalent click, got {:?}", - s.mode, + s.mode(), ); } @@ -6773,7 +6948,7 @@ fn scroll_mode_picker_enter_dispatches_set_commit() { matches!(outcome, SettingsKeyOutcome::Changed), "Enter on scroll_mode row must transition to PickingEnum, got {outcome:?}" ); - match &s.mode { + match &s.mode() { SettingsModalMode::PickingEnum { key, original_value, @@ -6817,7 +6992,7 @@ fn mouse_click_on_selected_scroll_mode_row_opens_picker() { matches!(outcome, SettingsKeyOutcome::Changed), "second click on focused scroll_mode row must enter PickingEnum, got {outcome:?}" ); - match &s.mode { + match &s.mode() { SettingsModalMode::PickingEnum { key, .. } => assert_eq!(*key, "scroll_mode"), _ => panic!("second click on focused scroll_mode row must enter PickingEnum"), } @@ -6855,26 +7030,22 @@ fn scroll_lines_int_stepper_commit_dispatches_typed_setter() { matches!(outcome, SettingsKeyOutcome::Changed), "Enter on Int row must transition to EditingValue, got {outcome:?}" ); - match &s.mode { - SettingsModalMode::EditingValue { buffer, .. } => { - assert_eq!(buffer, "3", "buffer must seed from default 3"); - } - other => panic!("expected EditingValue mode, got {other:?}"), - } + assert_eq!( + s.editing_buffer(), + Some("3"), + "buffer must seed from default 3", + ); // Narrow-range policy: Up = +1 → 4 (unit steps so every 1..=10 is reachable). let _ = handle_settings_key(&mut s, &press(KeyCode::Up)); - match &s.mode { - SettingsModalMode::EditingValue { buffer, .. } => assert_eq!(buffer, "4"), - other => panic!("expected EditingValue, got {other:?}"), - } + assert_eq!(s.editing_buffer(), Some("4")); let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter)); match outcome { SettingsKeyOutcome::Action(Action::SetScrollLines(4)) => {} other => panic!("expected SetScrollLines(4), got {other:?}"), } - assert!(matches!(s.mode, SettingsModalMode::Browse)); + assert!(matches!(s.mode(), SettingsModalMode::Browse)); } #[test] @@ -6899,9 +7070,9 @@ fn scroll_lines_mouse_click_opens_editor() { "second click on focused Int row must enter the editor, got {outcome:?}" ); assert!( - matches!(s.mode, SettingsModalMode::EditingValue { key, .. } if key == "scroll_lines"), + matches!(s.mode(), SettingsModalMode::EditingValue { key, .. } if key == "scroll_lines"), "mode must be EditingValue(scroll_lines), got {:?}", - s.mode, + s.mode(), ); } diff --git a/crates/codegen/xai-grok-plugin-marketplace/src/git.rs b/crates/codegen/xai-grok-plugin-marketplace/src/git.rs index 15d89a2..31d5b8a 100644 --- a/crates/codegen/xai-grok-plugin-marketplace/src/git.rs +++ b/crates/codegen/xai-grok-plugin-marketplace/src/git.rs @@ -59,6 +59,10 @@ pub fn sync_source_cache_with_mode( cache_root: &Path, mode: SyncMode, ) -> Result { + let url = xai_grok_agent::plugins::git_install::validate_git_url(url)?; + let branch = branch + .map(xai_grok_agent::plugins::git_install::validate_git_ref) + .transpose()?; let hash = cache_hash(url); let cache_dir = cache_root.join(&hash); let start = Instant::now(); @@ -89,6 +93,10 @@ fn sync_cache_locked( cache_dir: &Path, mode: SyncMode, ) -> Result<(), String> { + let url = xai_grok_agent::plugins::git_install::validate_git_url(url)?; + let branch = branch + .map(xai_grok_agent::plugins::git_install::validate_git_ref) + .transpose()?; if cache_dir.join(".git").exists() { if mode == SyncMode::UseTtl && is_cache_fresh(cache_dir) { return Ok(()); @@ -225,6 +233,10 @@ fn unique_reclone_suffix() -> u128 { } fn clone_with_git2(url: &str, branch: Option<&str>, dest: &Path) -> Result<(), String> { + let url = xai_grok_agent::plugins::git_install::validate_git_url(url)?; + let branch = branch + .map(xai_grok_agent::plugins::git_install::validate_git_ref) + .transpose()?; let mut fetch_opts = git2::FetchOptions::new(); fetch_opts.depth(1); @@ -261,15 +273,22 @@ pub fn git_command() -> std::process::Command { cmd } -fn clone_with_cli(url: &str, branch: Option<&str>, dest: &Path) -> Result<(), String> { +fn clone_cli_command(url: &str, branch: Option<&str>, dest: &Path) -> std::process::Command { let mut cmd = git_command(); cmd.args(["clone", "--depth", "1"]); if let Some(b) = branch { cmd.args(["--branch", b]); } - cmd.arg(url).arg(dest.as_os_str()); + cmd.arg("--").arg(url).arg(dest.as_os_str()); + cmd +} - let output = cmd +fn clone_with_cli(url: &str, branch: Option<&str>, dest: &Path) -> Result<(), String> { + let url = xai_grok_agent::plugins::git_install::validate_git_url(url)?; + let branch = branch + .map(xai_grok_agent::plugins::git_install::validate_git_ref) + .transpose()?; + let output = clone_cli_command(url, branch, dest) .output() .map_err(|e| format!("failed to run git clone: {e}"))?; if !output.status.success() { @@ -279,11 +298,24 @@ fn clone_with_cli(url: &str, branch: Option<&str>, dest: &Path) -> Result<(), St Ok(()) } +fn fetch_cli_command(repo_dir: &Path, branch: Option<&str>) -> std::process::Command { + let mut cmd = git_command(); + cmd.current_dir(repo_dir).args([ + "fetch", + "--depth", + "1", + "--", + "origin", + branch.unwrap_or("HEAD"), + ]); + cmd +} + fn fetch_reset_cached_repo(repo_dir: &Path, branch: Option<&str>) -> Result<(), String> { - let branch_arg = branch.unwrap_or("HEAD"); - let fetch_output = git_command() - .current_dir(repo_dir) - .args(["fetch", "--depth", "1", "origin", branch_arg]) + let branch = branch + .map(xai_grok_agent::plugins::git_install::validate_git_ref) + .transpose()?; + let fetch_output = fetch_cli_command(repo_dir, branch) .output() .map_err(|e| format!("failed to run git fetch: {e}"))?; @@ -343,6 +375,60 @@ mod tests { assert!(root.to_string_lossy().contains("marketplace-cache")); } + #[test] + fn cli_git_args_terminate_options_before_operands() { + let clone_cmd = clone_cli_command("repo", Some("main"), Path::new("dest")); + let clone_args: Vec<_> = clone_cmd + .get_args() + .map(|arg| arg.to_str().unwrap()) + .collect(); + assert_eq!( + clone_args, + [ + "--no-optional-locks", + "clone", + "--depth", + "1", + "--branch", + "main", + "--", + "repo", + "dest", + ] + ); + + let fetch_cmd = fetch_cli_command(Path::new("repo"), Some("main")); + let fetch_args: Vec<_> = fetch_cmd + .get_args() + .map(|arg| arg.to_str().unwrap()) + .collect(); + assert_eq!( + fetch_args, + [ + "--no-optional-locks", + "fetch", + "--depth", + "1", + "--", + "origin", + "main", + ] + ); + } + + #[test] + fn invalid_cache_operands_fail_before_cache_root_creation() { + for (url, branch) in [ + ("--upload-pack=cmd", Some("main")), + ("https://example.com/repo.git", Some("--upload-pack=cmd")), + ] { + let parent = tempfile::tempdir().unwrap(); + let cache_root = parent.path().join("cache"); + assert!(sync_source_cache(url, branch, &cache_root).is_err()); + assert!(!cache_root.exists()); + } + } + #[test] fn sync_source_cache_uses_ttl_by_default() { if !git_available() { diff --git a/crates/codegen/xai-grok-plugin-marketplace/src/installer.rs b/crates/codegen/xai-grok-plugin-marketplace/src/installer.rs index 2da7916..ede246e 100644 --- a/crates/codegen/xai-grok-plugin-marketplace/src/installer.rs +++ b/crates/codegen/xai-grok-plugin-marketplace/src/installer.rs @@ -5,7 +5,6 @@ use std::collections::HashMap; use std::path::Path; -use std::process::Command; use xai_grok_agent::plugins::git_install::{self, InstallSource}; use xai_grok_agent::plugins::install_registry::{ @@ -56,7 +55,6 @@ pub fn install_from_marketplace( .map_err(|e| InstallError::InstallFailed { detail: format!("invalid marketplace plugin path: {e}"), })?; - let plugin_relative_path = plugin_relative_path.as_str(); let source = InstallSource::Local { path: plugin_dir, subdir: None, @@ -67,17 +65,6 @@ pub fn install_from_marketplace( match git_install::install_from_source(&source, registry, false) { Ok(result) => { let repo_key = result.repo_key.clone(); - let installed_path = registry.install_dir().join(&repo_key); - - // If the installed dir has no manifest but has SKILL.md files - // at the root level (e.g. default-skills/), write a synthetic - // plugin.json so the plugin discovery system finds the skills. - ensure_manifest_for_root_skills( - &installed_path, - plugin_relative_path, - &provenance.source_display_name, - ); - let mut repo = git_install::build_installed_repo(&result, &source); repo.marketplace = Some(provenance); registry.insert(repo_key.clone(), repo); @@ -98,12 +85,6 @@ pub fn install_from_marketplace( match git_install::install_from_source(&source, registry, false) { Ok(result) => { let repo_key = result.repo_key.clone(); - let installed_path = registry.install_dir().join(&repo_key); - ensure_manifest_for_root_skills( - &installed_path, - plugin_relative_path, - &provenance.source_display_name, - ); let mut repo = git_install::build_installed_repo(&result, &source); repo.marketplace = Some(provenance); registry.insert(repo_key.clone(), repo); @@ -153,6 +134,7 @@ pub fn install_from_remote_url( }) }) .transpose()?; + let (url, git_ref, git_sha) = git_install::clone_operands(url, git_ref, git_sha)?; // No-fetch short-circuit before the pin gate: re-install of an already-present // plugin must not refuse just because the catalog entry is unpinned. if let Some((existing_key, _)) = find_installed_marketplace_plugin( @@ -166,8 +148,8 @@ pub fn install_from_remote_url( } let source = InstallSource::Git { url: url.to_string(), - git_ref: git_ref.map(|s| s.to_string()), - git_sha: git_sha.map(|s| s.to_string()), + git_ref: git_ref.map(str::to_owned), + git_sha: git_sha.map(str::to_owned), subdir, }; @@ -181,12 +163,6 @@ pub fn install_from_remote_url( ) { Ok(result) => { let repo_key = result.repo_key.clone(); - let installed_path = registry.install_dir().join(&repo_key); - ensure_manifest_for_root_skills( - &installed_path, - plugin_name, - &provenance.source_display_name, - ); let mut repo = git_install::build_installed_repo(&result, &source); repo.marketplace = Some(provenance); registry.insert(repo_key.clone(), repo); @@ -209,12 +185,6 @@ pub fn install_from_remote_url( ) { Ok(result) => { let repo_key = result.repo_key.clone(); - let installed_path = registry.install_dir().join(&repo_key); - ensure_manifest_for_root_skills( - &installed_path, - plugin_name, - &provenance.source_display_name, - ); let mut repo = git_install::build_installed_repo(&result, &source); repo.marketplace = Some(provenance); registry.insert(repo_key.clone(), repo); @@ -288,6 +258,21 @@ pub fn update_from_marketplace_entry_transactional( name: provenance.plugin_subdir.clone(), })?; + let remote_source = entry + .remote_url + .as_deref() + .map(|url| { + // Catalog pins published as `ref` still need hoisting for the verified clone path. + let (git_ref, git_sha) = git_install::hoist_pin_slots( + entry.remote_ref.as_deref(), + entry.remote_sha.as_deref(), + ); + let source = git_install::clone_operands(url, git_ref, git_sha)?; + git_install::ensure_pinned(require_sha, source.2, &entry.name, source.0)?; + Ok::<_, InstallError>(source) + }) + .transpose()?; + let install_dir = registry.install_dir().to_path_buf(); std::fs::create_dir_all(&install_dir).map_err(|e| InstallError::Io { path: install_dir.clone(), @@ -302,11 +287,7 @@ pub fn update_from_marketplace_entry_transactional( remove_path_if_exists(&staging_path)?; remove_path_if_exists(&backup_path)?; - let stage_result = if let Some(url) = entry.remote_url.as_deref() { - // Catalog pins published as `ref` still need hoisting for the verified clone path. - let (git_ref, git_sha) = - git_install::hoist_pin_slots(entry.remote_ref.as_deref(), entry.remote_sha.as_deref()); - git_install::ensure_pinned(require_sha, git_sha, &entry.name, url)?; + let stage_result = if let Some((url, git_ref, git_sha)) = remote_source { clone_repo_to_path(url, git_ref, git_sha, &staging_path) } else { let source_path = plugin_relative_path @@ -330,11 +311,6 @@ pub fn update_from_marketplace_entry_transactional( return Err(e); } - ensure_manifest_for_root_skills( - &staging_path, - plugin_relative_path.as_str(), - &provenance.source_display_name, - ); let plugins = match discover_plugins_in_dir(&staging_path, remote_subdir.as_deref()) { Ok(plugins) if !plugins.is_empty() => plugins, Ok(_) => { @@ -355,16 +331,14 @@ pub fn update_from_marketplace_entry_transactional( let new_version = first_plugin_version(&new_plugins); let changed = old_version != new_version; let updated_at = chrono::Utc::now().to_rfc3339(); - let kind = if let Some(url) = entry.remote_url.as_ref() { - InstallKind::Git { - url: url.clone(), - git_ref: entry - .remote_sha - .clone() - .or_else(|| entry.remote_ref.clone()), - commit: read_head_commit(&staging_path).unwrap_or_default(), - subdir: remote_subdir.clone(), - } + let kind = if let Some((url, git_ref, git_sha)) = remote_source { + remote_install_kind( + url, + git_ref, + git_sha, + read_head_commit(&staging_path).unwrap_or_default(), + remote_subdir.clone(), + ) } else { let source_path = plugin_relative_path .join_under(marketplace_root) @@ -511,27 +485,39 @@ fn remove_path_if_exists(path: &Path) -> Result<(), InstallError> { Ok(()) } +fn remote_install_kind( + url: &str, + git_ref: Option<&str>, + git_sha: Option<&str>, + commit: String, + subdir: Option, +) -> InstallKind { + InstallKind::Git { + url: url.to_owned(), + git_ref: git_sha.or(git_ref).map(str::to_owned), + commit, + subdir, + } +} + fn clone_repo_to_path( url: &str, git_ref: Option<&str>, git_sha: Option<&str>, target: &Path, ) -> Result<(), InstallError> { + let (url, git_ref, git_sha) = git_install::clone_operands(url, git_ref, git_sha)?; if let Some(sha) = git_sha { return clone_repo_at_sha(url, sha, target); } - let mut cmd = Command::new("git"); - xai_tty_utils::detach_std_command(&mut cmd); - cmd.arg("clone") - .arg("--depth") - .arg("1") - .stdin(std::process::Stdio::null()) - .envs(xai_tty_utils::pager_env()); + // Same auth/LFS/SSH suppression as marketplace cache clones. + let mut cmd = xai_tty_utils::git_command(); + cmd.arg("clone").arg("--depth").arg("1"); if let Some(r) = git_ref { cmd.arg("--branch").arg(r); } - cmd.arg(url).arg(target); + cmd.arg("--").arg(url).arg(target); let output = cmd.output().map_err(|e| InstallError::InstallFailed { detail: format!("failed to run git clone: {e}"), })?; @@ -549,11 +535,10 @@ fn clone_repo_to_path( } fn clone_repo_at_sha(url: &str, sha: &str, target: &Path) -> Result<(), InstallError> { - if sha.is_empty() { - return Err(InstallError::InstallFailed { - detail: "empty SHA provided for pinned clone".into(), - }); - } + let url = git_install::validate_git_url(url) + .map_err(|detail| InstallError::InstallFailed { detail })?; + let sha = git_install::validate_git_sha(sha) + .map_err(|detail| InstallError::InstallFailed { detail })?; std::fs::create_dir_all(target).map_err(|e| InstallError::Io { path: target.to_path_buf(), source: e, @@ -563,8 +548,8 @@ fn clone_repo_at_sha(url: &str, sha: &str, target: &Path) -> Result<(), InstallE InstallError::InstallFailed { detail } }; run_git_in(target, &["init", "--quiet"]).map_err(wrap_fail)?; - run_git_in(target, &["remote", "add", "origin", url]).map_err(wrap_fail)?; - run_git_in(target, &["fetch", "--depth", "1", "origin", sha]) + run_git_in(target, &git_install::remote_add_args(url)).map_err(wrap_fail)?; + run_git_in(target, &git_install::fetch_sha_args(sha)) .map_err(|d| wrap_fail(format!("fetch-by-sha failed: {d}")))?; run_git_in(target, &["checkout", "--quiet", "FETCH_HEAD"]).map_err(wrap_fail)?; let head = read_head_commit(target).ok_or_else(|| { @@ -588,12 +573,8 @@ fn run_git_in(cwd: &Path, args: &[&str]) -> Result<(), String> { } fn run_git_in_capture(cwd: &Path, args: &[&str]) -> Result { - let mut cmd = Command::new("git"); - xai_tty_utils::detach_std_command(&mut cmd); - cmd.args(args) - .current_dir(cwd) - .stdin(std::process::Stdio::null()) - .envs(xai_tty_utils::pager_env()); + let mut cmd = xai_tty_utils::git_command(); + cmd.args(args).current_dir(cwd); let output = cmd .output() .map_err(|e| format!("failed to run git {}: {e}", args.first().unwrap_or(&"")))?; @@ -740,89 +721,47 @@ fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> { Ok(()) } -/// Write a synthetic `plugin.json` for directories that have SKILL.md files -/// at the root level (not under a `skills/` subdirectory). -/// -/// This handles `default-skills/` directories where each subdirectory IS a -/// skill, rather than a plugin with a `skills/` convention directory. -fn ensure_manifest_for_root_skills( - installed_path: &Path, - plugin_relative_path: &str, - source_display_name: &str, -) { - use xai_grok_agent::plugins::manifest::load_manifest; - - // Skip if a manifest already exists. - if let Ok(xai_grok_agent::plugins::manifest::ManifestLoadResult::Found(_)) = - load_manifest(installed_path) - { - return; - } - - // Skip if there's already a skills/ directory (convention will work). - if installed_path.join("skills").is_dir() { - return; - } - - // Check if there are SKILL.md files at the root level. - let has_root_skills = std::fs::read_dir(installed_path) - .ok() - .map(|rd| { - rd.filter_map(|e| e.ok()) - .any(|e| e.path().join("SKILL.md").exists()) - }) - .unwrap_or(false); - - if !has_root_skills { - return; - } - - // Build a unique name from the source display name + relative path. - // e.g. source="xAI Marketplace", path="default-skills" - // -> "xai-marketplace-default-skills" - let source_slug: String = source_display_name - .chars() - .map(|c| { - if c.is_alphanumeric() { - c.to_ascii_lowercase() - } else { - '-' - } - }) - .collect::() - .split('-') - .filter(|s| !s.is_empty()) - .collect::>() - .join("-"); - let path_slug = plugin_relative_path - .rsplit('/') - .next() - .unwrap_or(plugin_relative_path); - let name = format!("{source_slug}-{path_slug}"); - - // Write a minimal plugin.json with skills pointing to root. - let manifest = serde_json::json!({ - "name": name, - "description": format!("Default skills from {source_display_name}"), - "skills": "./" - }); - let manifest_path = installed_path.join("plugin.json"); - if let Err(e) = std::fs::write(&manifest_path, manifest.to_string()) { - tracing::warn!( - path = %manifest_path.display(), - error = %e, - "failed to write synthetic plugin.json for root-level skills" - ); - } -} #[cfg(test)] mod tests { use super::*; + use std::process::Command; use std::sync::{Mutex, OnceLock}; static TEST_HOME: OnceLock = OnceLock::new(); static TEST_LOCK: Mutex<()> = Mutex::new(()); + #[test] + fn transactional_sha_git_args_terminate_options_before_operands() { + assert_eq!( + git_install::remote_add_args("repo"), + ["remote", "add", "--", "origin", "repo"] + ); + assert_eq!( + git_install::fetch_sha_args("0123456789abcdef0123456789abcdef01234567"), + [ + "fetch", + "--depth", + "1", + "--", + "origin", + "0123456789abcdef0123456789abcdef01234567", + ] + ); + } + + #[test] + fn transactional_sha_clone_rejects_before_target_creation() { + for bad in ["deadbeef", "--upload-pack=cmd"] { + let root = tempfile::tempdir().unwrap(); + let target = root.path().join("staging"); + assert!(matches!( + clone_repo_to_path("file:///unused", None, Some(bad), &target), + Err(InstallError::InstallFailed { .. }) + )); + assert!(!target.exists()); + } + } + #[test] fn require_sha_rejects_unpinned_remote_install() { with_test_registry(|registry| { @@ -861,10 +800,50 @@ mod tests { true, ) .unwrap_err(); - assert!( - matches!(err, InstallError::UnpinnedRemoteRefused { .. }), - "a non-hex 'pin' must be refused up front, got: {err}" - ); + match err { + InstallError::InstallFailed { detail } => assert!( + detail.contains("40 or 64 hexadecimal"), + "expected full-SHA validation detail, got: {detail}" + ), + other => panic!("expected InstallFailed for malformed SHA, got: {other}"), + } + }); + } + + #[test] + fn already_installed_remote_still_rejects_malformed_operands() { + with_test_registry(|registry| { + let marketplace = tempfile::tempdir().unwrap(); + write_plugin(marketplace.path(), "demo", "1.0.0", "old"); + install_test_plugin(registry, marketplace.path(), "demo"); + let provenance = provenance(marketplace.path(), "plugins/demo"); + let registry_len = registry.list().len(); + let installed_path = registry.list().into_iter().next().unwrap().1.path.clone(); + + for (url, git_ref, git_sha) in [ + ("--upload-pack=cmd", Some("main"), None), + ( + "https://example.com/plugin.git", + Some("--upload-pack=cmd"), + None, + ), + ("https://example.com/plugin.git", None, Some("deadbeef")), + ] { + let err = install_from_remote_url( + url, + git_ref, + git_sha, + None, + "plugins/demo", + provenance.clone(), + registry, + false, + ) + .unwrap_err(); + assert!(matches!(err, InstallError::InstallFailed { .. })); + assert_eq!(registry.list().len(), registry_len); + assert!(installed_path.exists()); + } }); } @@ -1078,6 +1057,39 @@ mod tests { }); } + #[test] + fn transactional_git_kind_uses_normalized_operands() { + let sha = "a".repeat(40); + let padded_sha = format!(" {sha} "); + let (url, git_ref, git_sha) = git_install::clone_operands( + " https://example.com/plugin.git ", + Some(" v1.2.3 "), + Some(&padded_sha), + ) + .unwrap(); + let kind = remote_install_kind(url, git_ref, git_sha, sha.clone(), None); + let repo = InstalledRepo { + kind, + installed_at: String::new(), + updated_at: String::new(), + path: Path::new("/unused").to_path_buf(), + plugins: HashMap::new(), + marketplace: None, + }; + + match &repo.kind { + InstallKind::Git { url, git_ref, .. } => { + assert_eq!(url, "https://example.com/plugin.git"); + assert_eq!(git_ref.as_deref(), Some(sha.as_str())); + } + InstallKind::Local { .. } => panic!("expected Git"), + } + assert!(matches!( + git_install::update_repo("repo", &repo, true), + Ok(git_install::UpdateStatus::Pinned { ref_name }) if ref_name == sha + )); + } + #[test] fn transactional_update_preserves_installed_at_and_updates_updated_at() { with_test_registry(|registry| { diff --git a/crates/codegen/xai-grok-plugin-marketplace/src/scanner.rs b/crates/codegen/xai-grok-plugin-marketplace/src/scanner.rs index 4b13562..056cd00 100644 --- a/crates/codegen/xai-grok-plugin-marketplace/src/scanner.rs +++ b/crates/codegen/xai-grok-plugin-marketplace/src/scanner.rs @@ -14,46 +14,10 @@ use crate::types::{MarketplaceEntry, MarketplaceScan}; /// Scan a marketplace directory for plugins, reporting whether a /// `plugin-index.json` component catalog was loaded. /// -/// Tries indexed mode first, falls back to filesystem scanning. +/// Tries indexed mode first, falls back to filesystem scanning. The component +/// catalog is only consulted in indexed mode: its keys are defined as index +/// names, so the filesystem fallback ignores it. pub fn scan_marketplace(root: &Path) -> MarketplaceScan { - let MarketplaceScan { - entries: mut plugins, - catalog_loaded, - } = scan_plugins(root); - - // Also scan `default-skills/` as a virtual plugin if present. - let default_skills_dir = root.join("default-skills"); - if default_skills_dir.is_dir() { - // default-skills/ has skills at root level (each subdir is a skill), - // not under a skills/ subdirectory. Count SKILL.md files directly. - let skill_count = std::fs::read_dir(&default_skills_dir) - .ok() - .map(|rd| { - rd.filter_map(|e| e.ok()) - .filter(|e| e.path().join("SKILL.md").exists()) - .count() - }) - .unwrap_or(0); - if skill_count > 0 { - let mut entry = scan_single_plugin(&default_skills_dir, "default-skills"); - // Override skill_count since scan_single_plugin looks under skills/. - entry.skill_count = skill_count; - plugins.push(entry); - } - } - - MarketplaceScan { - entries: plugins, - catalog_loaded, - } -} - -/// Core plugin scanning — tries indexed mode first, falls back to filesystem. -/// -/// The component catalog is only consulted in indexed mode: its keys are -/// defined as index names, so the filesystem fallback ignores it. -fn scan_plugins(root: &Path) -> MarketplaceScan { - // Try indexed mode. match index::load_index(root) { Ok(Some(idx)) => { tracing::debug!( @@ -738,34 +702,6 @@ mod tests { assert_eq!(scan.entries[0].skill_count, 1); } - #[test] - fn default_skills_virtual_plugin_has_no_components() { - let dir = tempfile::tempdir().unwrap(); - write_grok_file( - dir.path(), - "marketplace.json", - r#"{"name": "m", "plugins": []}"#, - ); - write_grok_file( - dir.path(), - "plugin-index.json", - r#"{ - "version": 1, - "plugins": { "default-skills": { "components": { "skills": [ { "name": "s" } ] } } } - }"#, - ); - let skill_dir = dir.path().join("default-skills").join("a-skill"); - std::fs::create_dir_all(&skill_dir).unwrap(); - std::fs::write(skill_dir.join("SKILL.md"), "# A Skill").unwrap(); - - let scan = scan_marketplace(dir.path()); - assert!(scan.catalog_loaded); - assert_eq!(scan.entries.len(), 1); - assert_eq!(scan.entries[0].name, "default-skills"); - assert_eq!(scan.entries[0].skill_count, 1); - assert!(scan.entries[0].components.is_none()); - } - #[test] fn root_plugin_json_preferred() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/codegen/xai-grok-sampler/src/client.rs b/crates/codegen/xai-grok-sampler/src/client.rs index 05aff78..28b1058 100644 --- a/crates/codegen/xai-grok-sampler/src/client.rs +++ b/crates/codegen/xai-grok-sampler/src/client.rs @@ -20,7 +20,7 @@ use reqwest::header::{ }; use serde::Serialize; -use xai_grok_sampling_types::error::{parse_error_bytes, try_parse_stream_error}; +use xai_grok_sampling_types::error::{try_parse_stream_error, user_facing_api_error_message}; use xai_grok_sampling_types::{ ChatCompletionChunk, ChatCompletionRequest, ChatCompletionResponse, ConversationRequest, ConversationResponse, CreateResponseWrapper, DOOM_LOOP_CHECK_HEADER, MessagesRequestWrapper, @@ -678,48 +678,9 @@ impl SamplingClient { || lower.contains("secret") } - /// Format a single header for error messages, redacting sensitive values. - fn format_header(name: &str, value: &str) -> String { - let display_value = if Self::is_sensitive_header(name) { - "[REDACTED]" - } else { - value - }; - format!(" {}: {}", name, display_value) - } - - /// Build request headers string for error messages (redacting sensitive values). - fn format_request_headers( - &self, - x_grok_conv_id: &str, - x_grok_req_id: &str, - model_id: &str, - include_accept: bool, - ) -> Vec { - let mut req_headers: Vec = self - .default_headers - .iter() - .map(|(name, value)| { - Self::format_header(name.as_str(), value.to_str().unwrap_or("[non-utf8]")) - }) - .collect(); - - req_headers.push(Self::format_header("x-grok-conv-id", x_grok_conv_id)); - req_headers.push(Self::format_header("x-grok-req-id", x_grok_req_id)); - req_headers.push(Self::format_header("x-grok-model-override", model_id)); - if include_accept { - req_headers.push(Self::format_header("accept", "text/event-stream")); - } - req_headers - } - - /// Build response headers string for error messages. - fn format_response_headers(response: &reqwest::Response) -> Vec { - response - .headers() - .iter() - .map(|(name, value)| Self::format_header(name.as_str(), &format!("{:?}", value))) - .collect() + /// Short lossy body snippet for error logs (never user-facing). + fn body_preview(bytes: &[u8]) -> String { + String::from_utf8_lossy(bytes).chars().take(500).collect() } /// Log all headers from a request at debug level (redacting sensitive values). @@ -739,36 +700,6 @@ impl SamplingClient { } } - /// Build error context message based on error type and status code. - /// Includes relevant request/response details depending on what the error is about. - fn build_api_error_message( - &self, - status: reqwest::StatusCode, - server_message: &str, - endpoint: &str, - req_headers: &[String], - resp_headers: Option<&[String]>, - ) -> String { - let server_message_lower = server_message.to_lowercase(); - - let mut context_parts = vec![server_message.to_string()]; - context_parts.push(format!("\nRequest URL: {}", endpoint)); - - // Show headers if error mentions headers - if server_message_lower.contains("header") { - context_parts.push(format!("Request headers:\n{}", req_headers.join("\n"))); - } - - // Always show response headers for server errors - if status.is_server_error() - && let Some(resp_hdrs) = resp_headers - { - context_parts.push(format!("Response headers:\n{}", resp_hdrs.join("\n"))); - } - - context_parts.join("\n") - } - fn endpoint(&self, path: &str) -> String { let base = self.base_url.trim_end_matches('/'); let path = path.trim_start_matches('/'); @@ -805,12 +736,12 @@ impl SamplingClient { if !status.is_success() { if status == reqwest::StatusCode::UNAUTHORIZED { self.record_401_attribution(crate::attribution::SamplingConsumer::ChatCompletions); - let server_message = parse_error_bytes(bytes.as_ref()); + let server_message = user_facing_api_error_message(status, bytes.as_ref()); return Err(SamplingError::Auth(format!( "Unauthorized (401): {server_message}" ))); } - let message = parse_error_bytes(bytes.as_ref()); + let message = user_facing_api_error_message(status, bytes.as_ref()); return Err(SamplingError::Api { status, message, @@ -956,30 +887,20 @@ impl SamplingClient { crate::attribution::SamplingConsumer::ChatCompletionsStream, ); let endpoint = self.endpoint("chat/completions"); - let server_message = response.text().await.unwrap_or_default(); + let body = response.bytes().await.unwrap_or_default(); + let server_message = user_facing_api_error_message(status, body.as_ref()); return Err(SamplingError::Auth(format!( "Unauthorized (401) from {endpoint}: {server_message}" ))); } - let req_headers = - self.format_request_headers(x_grok_conv_id, x_grok_req_id, &model_id, true); - let resp_headers = Self::format_response_headers(&response); let bytes = response.bytes().await?; - let server_message = parse_error_bytes(bytes.as_ref()); - - let message = self.build_api_error_message( - status, - &server_message, - &self.endpoint("chat/completions"), - &req_headers, - Some(&resp_headers), - ); - + let message = user_facing_api_error_message(status, bytes.as_ref()); span.record("error", message.as_str()); tracing::error!( status = %status, error_message = %message, + body_preview = %Self::body_preview(bytes.as_ref()), model_id = %model_id, "chat/completions API error" ); @@ -1161,26 +1082,17 @@ impl SamplingClient { if status == reqwest::StatusCode::UNAUTHORIZED { self.record_401_attribution(crate::attribution::SamplingConsumer::Responses); let endpoint = self.endpoint("responses"); - let server_message = parse_error_bytes(bytes.as_ref()); + let server_message = user_facing_api_error_message(status, bytes.as_ref()); return Err(SamplingError::Auth(format!( "Unauthorized (401) from {endpoint}: {server_message}" ))); } - let req_headers = - self.format_request_headers(x_grok_conv_id, x_grok_req_id, &model_id, false); - let server_message = parse_error_bytes(bytes.as_ref()); - - let message = self.build_api_error_message( - status, - &server_message, - &self.endpoint("responses"), - &req_headers, - None, - ); + let message = user_facing_api_error_message(status, bytes.as_ref()); tracing::warn!( status = %status, error_message = %message, + body_preview = %Self::body_preview(bytes.as_ref()), model_id = %model_id, "responses API error" ); @@ -1329,7 +1241,8 @@ impl SamplingClient { span.record("error", "unauthorized (401)"); self.record_401_attribution(crate::attribution::SamplingConsumer::ResponsesStream); let endpoint = self.endpoint("responses"); - let server_message = response.text().await.unwrap_or_default(); + let body = response.bytes().await.unwrap_or_default(); + let server_message = user_facing_api_error_message(status, body.as_ref()); return Err(SamplingError::Auth(format!( "Unauthorized (401) from {endpoint}: {server_message}" ))); @@ -1337,24 +1250,13 @@ impl SamplingClient { let model_metadata = extract_model_metadata(response.headers()); let retry_after_secs = extract_retry_after(response.headers()); let should_retry = extract_should_retry(response.headers()); - let req_headers = - self.format_request_headers(x_grok_conv_id, x_grok_req_id, &model_id, true); - let resp_headers = Self::format_response_headers(&response); let bytes = response.bytes().await?; - let server_message = parse_error_bytes(bytes.as_ref()); - - let message = self.build_api_error_message( - status, - &server_message, - &self.endpoint("responses"), - &req_headers, - Some(&resp_headers), - ); - + let message = user_facing_api_error_message(status, bytes.as_ref()); span.record("error", message.as_str()); tracing::error!( status = %status, error_message = %message, + body_preview = %Self::body_preview(bytes.as_ref()), model_id = %model_id, "responses API error" ); @@ -1519,26 +1421,17 @@ impl SamplingClient { if status == reqwest::StatusCode::UNAUTHORIZED { self.record_401_attribution(crate::attribution::SamplingConsumer::Messages); let endpoint = self.endpoint("messages"); - let server_message = parse_error_bytes(bytes.as_ref()); + let server_message = user_facing_api_error_message(status, bytes.as_ref()); return Err(SamplingError::Auth(format!( "Unauthorized (401) from {endpoint}: {server_message}" ))); } - let req_headers = - self.format_request_headers(x_grok_conv_id, x_grok_req_id, &model_id, false); - let server_message = parse_error_bytes(bytes.as_ref()); - - let message = self.build_api_error_message( - status, - &server_message, - &self.endpoint("messages"), - &req_headers, - None, - ); + let message = user_facing_api_error_message(status, bytes.as_ref()); tracing::warn!( status = %status, error_message = %message, + body_preview = %Self::body_preview(bytes.as_ref()), model_id = %model_id, "messages API error" ); @@ -1648,7 +1541,8 @@ impl SamplingClient { span.record("error", "unauthorized (401)"); self.record_401_attribution(crate::attribution::SamplingConsumer::MessagesStream); let endpoint = self.endpoint("messages"); - let server_message = response.text().await.unwrap_or_default(); + let body = response.bytes().await.unwrap_or_default(); + let server_message = user_facing_api_error_message(status, body.as_ref()); return Err(SamplingError::Auth(format!( "Unauthorized (401) from {endpoint}: {server_message}" ))); @@ -1656,24 +1550,13 @@ impl SamplingClient { let model_metadata = extract_model_metadata(response.headers()); let retry_after_secs = extract_retry_after(response.headers()); let should_retry = extract_should_retry(response.headers()); - let req_headers = - self.format_request_headers(x_grok_conv_id, x_grok_req_id, &model_id, true); - let resp_headers = Self::format_response_headers(&response); let bytes = response.bytes().await?; - let server_message = parse_error_bytes(bytes.as_ref()); - - let message = self.build_api_error_message( - status, - &server_message, - &self.endpoint("messages"), - &req_headers, - Some(&resp_headers), - ); - + let message = user_facing_api_error_message(status, bytes.as_ref()); span.record("error", message.as_str()); tracing::error!( status = %status, error_message = %message, + body_preview = %Self::body_preview(bytes.as_ref()), model_id = %model_id, "messages API error" ); diff --git a/crates/codegen/xai-grok-sampler/tests/cf_edge_error_message.rs b/crates/codegen/xai-grok-sampler/tests/cf_edge_error_message.rs new file mode 100644 index 0000000..cd02fc9 --- /dev/null +++ b/crates/codegen/xai-grok-sampler/tests/cf_edge_error_message.rs @@ -0,0 +1,120 @@ +//! Wire e2e + pure tests for user-facing API error sanitization. +//! +//! Edge proxies return non-JSON bodies (HTML). Those must never reach TUI +//! scrollback; only structured JSON error envelopes and status-based copy. + +use std::sync::Arc; + +use xai_grok_sampler::{SamplerConfig, SamplingClient}; +use xai_grok_sampling_types::{ + ContentPart, ConversationItem, ConversationRequest, UserItem, status_user_message, + user_facing_api_error_message, +}; +use xai_grok_test_support::{MockInferenceServer, ScriptedResponse}; + +const CF_524_HTML: &str = r#" + +grok.com | 524: A timeout occurred + +

A timeout occurred Error code 524

+
Visit cloudflare.com for more information.
+ +"#; + +fn test_config(base_url: &str, api_key: &str) -> SamplerConfig { + SamplerConfig { + api_key: Some(api_key.to_string()), + base_url: base_url.to_string(), + model: "test-model".to_string(), + ..SamplerConfig::default() + } +} + +fn user_request(text: &str) -> ConversationRequest { + ConversationRequest { + items: vec![ConversationItem::User(UserItem { + content: vec![ContentPart::Text { + text: Arc::::from(text), + }], + ..Default::default() + })], + ..Default::default() + } +} + +async fn stream_err(status: u16, body: &str) -> xai_grok_sampling_types::SamplingError { + let server = MockInferenceServer::start().await.expect("start mock"); + server.enqueue_response("/v1/chat/completions", ScriptedResponse::text(status, body)); + let mut cfg = test_config(&server.url(), "test-key"); + cfg.max_retries = Some(0); + let client = SamplingClient::new(cfg).expect("client"); + match client.conversation_stream(user_request("hi")).await { + Ok(_) => panic!("expected API error"), + Err(e) => e, + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn stream_524_html_uses_status_copy() { + let err = stream_err(524, CF_524_HTML).await; + let s = err.to_string(); + assert!(!s.contains("Service Unavailable").await; + let s = err.to_string(); + assert!(!s.contains(" Option<(String, String)> { None } -pub fn parse_error_bytes(bytes: &[u8]) -> String { - if let Some((error_type, message)) = std::str::from_utf8(bytes).ok().and_then(try_parse_error) { - if error_type == "unknown" || error_type == "server_error" { - return message; +/// Max chars of a structured (JSON) error message shown to users. +pub const MAX_USER_ERROR_BODY_CHARS: usize = 280; + +/// Short status-based copy when the body is not a structured JSON error. +/// +/// Edge proxies (Cloudflare 52x, 502/503/504) return HTML pages; we never +/// sniff body text — only the HTTP status drives this fallback. +pub fn status_user_message(status: StatusCode) -> String { + match status.as_u16() { + code @ 502..=504 => { + format!("Grok is temporarily unavailable. Please try again in a moment. (HTTP {code}).") } - return format!("{error_type}: {message}"); + // Cloudflare edge codes (origin down / connect fail / timeout / …). + code @ 520..=524 => { + format!( + "Connection to Grok timed out or was interrupted. Please try again. (HTTP {code})." + ) + } + code if status.is_server_error() => { + format!("Something went wrong on the server (HTTP {code}).") + } + code if status.is_client_error() => format!("Request failed (HTTP {code})."), + code => format!("Request failed (HTTP {code})."), } - String::from_utf8_lossy(bytes).trim().to_owned() +} + +fn truncate_user_error(s: &str) -> String { + let s = s.trim(); + let count = s.chars().count(); + if count <= MAX_USER_ERROR_BODY_CHARS { + return s.to_owned(); + } + let mut out: String = s.chars().take(MAX_USER_ERROR_BODY_CHARS).collect(); + out.push('\u{2026}'); + out +} + +/// Format a known JSON error envelope; `None` if the body is not structured. +fn structured_error_message(bytes: &[u8]) -> Option { + let (error_type, message) = std::str::from_utf8(bytes).ok().and_then(try_parse_error)?; + let msg = if error_type == "unknown" || error_type == "server_error" { + message + } else { + format!("{error_type}: {message}") + }; + Some(truncate_user_error(&msg)) +} + +/// Parse an API error body into a short string. +/// +/// Only structured JSON error envelopes are surfaced. Non-JSON bodies +/// (HTML edge pages, plain text dumps) return a fixed placeholder — never +/// the raw bytes. Prefer [`user_facing_api_error_message`] when a status +/// code is available. +pub fn parse_error_bytes(bytes: &[u8]) -> String { + structured_error_message(bytes).unwrap_or_else(|| "upstream error".into()) +} + +/// User-facing message for a failed API call. +/// +/// Structured JSON error envelopes keep their message. Everything else +/// (including Cloudflare HTML) maps to a status-based string — no body +/// content matching. +pub fn user_facing_api_error_message(status: StatusCode, bytes: &[u8]) -> String { + structured_error_message(bytes).unwrap_or_else(|| status_user_message(status)) } pub fn try_parse_stream_error(data: &str) -> Option { @@ -528,6 +585,52 @@ mod tests { ); } + #[test] + fn parse_error_bytes_rejects_non_json_body() { + let html = br#" + +grok.com | 524: A timeout occurred +

A timeout occurred Error code 524

+"#; + let msg = parse_error_bytes(html); + assert_eq!(msg, "upstream error"); + // Plain non-JSON text is also rejected (no body sniffing). + assert_eq!( + parse_error_bytes(b"some random gateway text"), + "upstream error" + ); + } + + #[test] + fn user_facing_api_error_message_maps_non_json_by_status() { + let html = br#"timeout"#; + let msg = user_facing_api_error_message(StatusCode::from_u16(524).unwrap(), html); + assert_eq!(msg, status_user_message(StatusCode::from_u16(524).unwrap())); + + let msg_503 = + user_facing_api_error_message(StatusCode::SERVICE_UNAVAILABLE, b"not json either"); + assert_eq!( + msg_503, + status_user_message(StatusCode::SERVICE_UNAVAILABLE) + ); + } + + #[test] + fn user_facing_keeps_json_error_message() { + let bytes = br#"{"error":{"message":"rate limit exceeded","type":"rate_limit_error"}}"#; + let msg = user_facing_api_error_message(StatusCode::TOO_MANY_REQUESTS, bytes); + assert_eq!(msg, "rate_limit_error: rate limit exceeded"); + } + + #[test] + fn structured_error_message_is_length_capped() { + let long_msg = "x".repeat(MAX_USER_ERROR_BODY_CHARS + 50); + let bytes = format!(r#"{{"error":{{"message":"{long_msg}","type":"server_error"}}}}"#); + let msg = parse_error_bytes(bytes.as_bytes()); + assert!(msg.chars().count() <= MAX_USER_ERROR_BODY_CHARS + 1); + assert!(msg.ends_with('\u{2026}')); + } + /// Regression test: 403 Forbidden must NOT be classified as an auth /// error. The proxy returns 403 for policy denials that are unrelated /// to the caller's credentials (content-safety blocks, ZDR-gated diff --git a/crates/codegen/xai-grok-sampling-types/src/lib.rs b/crates/codegen/xai-grok-sampling-types/src/lib.rs index 7afb46b..92437af 100644 --- a/crates/codegen/xai-grok-sampling-types/src/lib.rs +++ b/crates/codegen/xai-grok-sampling-types/src/lib.rs @@ -20,7 +20,7 @@ pub use self::doom_loop::{ }; pub use self::error::{ EmptyReason, EmptyResponseContext, ResponseModelMetadata, Result, SamplingError, - is_context_length_error, + is_context_length_error, status_user_message, user_facing_api_error_message, }; pub use self::types::*; diff --git a/crates/codegen/xai-grok-shared/src/ui_config.rs b/crates/codegen/xai-grok-shared/src/ui_config.rs index 46a60cb..1b63d1f 100644 --- a/crates/codegen/xai-grok-shared/src/ui_config.rs +++ b/crates/codegen/xai-grok-shared/src/ui_config.rs @@ -43,6 +43,10 @@ pub struct UiConfig { /// `None` = off (client default; opt-in). Written by the pager's settings modal. #[serde(default, skip_serializing_if = "Option::is_none")] pub show_timeline: Option, + /// Snap a just-sent prompt to the viewport top. `None` = on (default). + /// Written by the pager's settings modal. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub page_flip_on_send: Option, /// Theme to use when the OS is in dark mode. Written by the pager's theme persist module. #[serde(default, skip_serializing_if = "Option::is_none")] pub auto_dark_theme: Option, @@ -244,6 +248,7 @@ impl Default for UiConfig { default_selected_permission: None, show_timestamps: None, show_timeline: None, + page_flip_on_send: None, auto_dark_theme: None, auto_light_theme: None, scroll_speed: None, @@ -292,6 +297,14 @@ impl UiConfig { self.show_timeline.unwrap_or(Self::SHOW_TIMELINE_DEFAULT) } + /// Default for [`Self::page_flip_on_send`] when unset. + pub const PAGE_FLIP_ON_SEND_DEFAULT: bool = true; + + pub fn page_flip_on_send_enabled(&self) -> bool { + self.page_flip_on_send + .unwrap_or(Self::PAGE_FLIP_ON_SEND_DEFAULT) + } + /// True when the highlight should not timer-dismiss (`hold` / `word_select`, /// or legacy duration 0). pub fn keep_text_selection_enabled(&self) -> bool { @@ -306,6 +319,16 @@ impl UiConfig { mod tests { use super::*; + #[test] + fn page_flip_on_send_defaults_on() { + assert!(UiConfig::default().page_flip_on_send_enabled()); + let off = UiConfig { + page_flip_on_send: Some(false), + ..Default::default() + }; + assert!(!off.page_flip_on_send_enabled()); + } + #[test] fn keep_text_selection_enabled_precedence() { let mut ui = UiConfig::default(); diff --git a/crates/codegen/xai-grok-shell/CHANGELOG.md b/crates/codegen/xai-grok-shell/CHANGELOG.md index 94d979e..0dc958d 100644 --- a/crates/codegen/xai-grok-shell/CHANGELOG.md +++ b/crates/codegen/xai-grok-shell/CHANGELOG.md @@ -1,5 +1,66 @@ # Changelog +# 0.2.105 — 2026-07-18 + +## Features + +- **/btw** now works inside `grok --minimal`, showing answers in the live area and committing them to scrollback on Esc. +- **New Appearance setting** "Snap prompt to top on send" lets you keep the viewport where it is instead of jumping to the new prompt. +- **Default model** is now Grok 4.5 with high/medium/low reasoning effort and improved compaction settings. +- **New `/summarize` slash command** is now available as an alias for `/recap` to request an on-demand session summary. + +## Bug Fixes + +- **Local shell tools** now see the same environment variables, aliases, and functions as your login shell. +- **Syntax highlighting** in diffs and the file viewer no longer miscolors strings or comments that span multiple lines. +- **Global rules** from ~/.grok/rules and compatible vendor homes are now discovered correctly. +- **Background tasks** that finish after you press Ctrl+C no longer automatically resume the model. +- **Ctrl+\** out of the dashboard now returns you to the agent you came from. +- **MCP OAuth logins** now succeed against servers that require the RFC 9207 issuer parameter in the callback. +- **Agent dashboard** now shows fleet roster entries even when the local agent list is empty. +- **Long-session compaction** no longer fails on servers that reject tool_choice none when tools are attached. + +## Performance + +- **Scrolling** feels smoother and less jagged under load or over slow connections. + + +# 0.2.104 — 2026-07-17 + +## Features + +- **Background work counts** now appear in a persistent status line instead of repeated transcript messages. + +## Bug Fixes + +- **Fixed authentication recovery** for idle sessions after token timeouts. +- **Retry failed** messages no longer contain raw HTML error pages. +- **Rate limit messages** now show the server detail without the wire prefix. +- **In-place prompt editing** is temporarily disabled due to scroll behavior issues. + + +# 0.2.103 — 2026-07-17 + +## Features + +- **New require_sha option** prevents remote plugins from tracking mutable branches or tags. +- **Local sessions now inherit full rc environment, cwd, and exports** across tool calls (configurable). +- **MCP servers** from plugins can now require setup choices such as a regional site before connecting. +- Quitting a fullscreen session now shows the session title and last exchange above the resume command. +- **SSH sessions** now show a one-time tip recommending `grok wrap ssh ` for clipboard and terminal restore. + +## Bug Fixes + +- **Fixed GitHub PR status detection** when the gh CLI inherits forcing color environment variables. +- **Fixed a race** where an early cancel could permanently wedge a session's turn slot. +- **grok** and the agent binary now stay in sync even when no update is installed. +- **Copying** a multiline queued prompt now copies the complete text instead of a collapsed summary. +- **grok wrap** now restores the terminal after SSH disconnects or other abrupt child exits. +- **Voice speech-to-text** now works with per-model API keys in config.toml without requiring `grok login`. +- **Copy over SSH** or in containers now shows clearer feedback when delivery cannot be confirmed. +- **Local Bash sessions** no longer keep a persistent shell across calls, avoiding failures after directory deletion. + + # 0.2.102 — 2026-07-16 ## Breaking Changes @@ -51,7 +112,6 @@ - **Improved recap and compaction** behavior. - # 0.2.101 — 2026-07-13 ## Features diff --git a/crates/codegen/xai-grok-shell/Cargo.toml b/crates/codegen/xai-grok-shell/Cargo.toml index 7d2caf7..14d9785 100644 --- a/crates/codegen/xai-grok-shell/Cargo.toml +++ b/crates/codegen/xai-grok-shell/Cargo.toml @@ -1,12 +1,13 @@ [package] license = "Apache-2.0" name = "xai-grok-shell" -version = "0.2.102" +version = "0.2.105" edition.workspace = true [features] default = [] unstable = [] +dhat-heap = ["dep:dhat"] default-bazel = [] [dependencies] @@ -106,6 +107,7 @@ arc-swap = { workspace = true } infer = "0.15" image = { workspace = true, features = ["png", "jpeg", "gif", "webp"] } moka = { workspace = true, features = ["future"] } +dhat = { workspace = true, optional = true } base64 = { workspace = true } jsonwebtoken = { version = "10", features = ["rust_crypto"] } sha2 = { workspace = true } diff --git a/crates/codegen/xai-grok-shell/changelogs/0.2.103.json b/crates/codegen/xai-grok-shell/changelogs/0.2.103.json new file mode 100644 index 0000000..7e4439f --- /dev/null +++ b/crates/codegen/xai-grok-shell/changelogs/0.2.103.json @@ -0,0 +1,67 @@ +[ + { + "category": "fixes", + "description": "**Fixed GitHub PR status detection** when the gh CLI inherits forcing color environment variables.", + "breaking_change": false + }, + { + "category": "features", + "description": "**New require_sha option** prevents remote plugins from tracking mutable branches or tags.", + "breaking_change": false + }, + { + "category": "features", + "description": "**Local sessions now inherit full rc environment, cwd, and exports** across tool calls (configurable).", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Fixed a race** where an early cancel could permanently wedge a session's turn slot.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**grok** and the agent binary now stay in sync even when no update is installed.", + "breaking_change": false + }, + { + "category": "features", + "description": "**MCP servers** from plugins can now require setup choices such as a regional site before connecting.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Copying** a multiline queued prompt now copies the complete text instead of a collapsed summary.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**grok wrap** now restores the terminal after SSH disconnects or other abrupt child exits.", + "breaking_change": false + }, + { + "category": "features", + "description": "Quitting a fullscreen session now shows the session title and last exchange above the resume command.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Voice speech-to-text** now works with per-model API keys in config.toml without requiring `grok login`.", + "breaking_change": false + }, + { + "category": "features", + "description": "**SSH sessions** now show a one-time tip recommending `grok wrap ssh ` for clipboard and terminal restore.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Copy over SSH** or in containers now shows clearer feedback when delivery cannot be confirmed.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Local Bash sessions** no longer keep a persistent shell across calls, avoiding failures after directory deletion.", + "breaking_change": false + } +] diff --git a/crates/codegen/xai-grok-shell/changelogs/0.2.103.md b/crates/codegen/xai-grok-shell/changelogs/0.2.103.md new file mode 100644 index 0000000..00ec4fb --- /dev/null +++ b/crates/codegen/xai-grok-shell/changelogs/0.2.103.md @@ -0,0 +1,21 @@ +# 0.2.103 — 2026-07-17 + +## Features + +- **New require_sha option** prevents remote plugins from tracking mutable branches or tags. +- **Local sessions now inherit full rc environment, cwd, and exports** across tool calls (configurable). +- **MCP servers** from plugins can now require setup choices such as a regional site before connecting. +- Quitting a fullscreen session now shows the session title and last exchange above the resume command. +- **SSH sessions** now show a one-time tip recommending `grok wrap ssh ` for clipboard and terminal restore. + +## Bug Fixes + +- **Fixed GitHub PR status detection** when the gh CLI inherits forcing color environment variables. +- **Fixed a race** where an early cancel could permanently wedge a session's turn slot. +- **grok** and the agent binary now stay in sync even when no update is installed. +- **Copying** a multiline queued prompt now copies the complete text instead of a collapsed summary. +- **grok wrap** now restores the terminal after SSH disconnects or other abrupt child exits. +- **Voice speech-to-text** now works with per-model API keys in config.toml without requiring `grok login`. +- **Copy over SSH** or in containers now shows clearer feedback when delivery cannot be confirmed. +- **Local Bash sessions** no longer keep a persistent shell across calls, avoiding failures after directory deletion. + diff --git a/crates/codegen/xai-grok-shell/changelogs/0.2.104.json b/crates/codegen/xai-grok-shell/changelogs/0.2.104.json new file mode 100644 index 0000000..b435fcd --- /dev/null +++ b/crates/codegen/xai-grok-shell/changelogs/0.2.104.json @@ -0,0 +1,27 @@ +[ + { + "category": "fixes", + "description": "**Fixed authentication recovery** for idle sessions after token timeouts.", + "breaking_change": false + }, + { + "category": "features", + "description": "**Background work counts** now appear in a persistent status line instead of repeated transcript messages.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Retry failed** messages no longer contain raw HTML error pages.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Rate limit messages** now show the server detail without the wire prefix.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**In-place prompt editing** is temporarily disabled due to scroll behavior issues.", + "breaking_change": false + } +] diff --git a/crates/codegen/xai-grok-shell/changelogs/0.2.104.md b/crates/codegen/xai-grok-shell/changelogs/0.2.104.md new file mode 100644 index 0000000..545b157 --- /dev/null +++ b/crates/codegen/xai-grok-shell/changelogs/0.2.104.md @@ -0,0 +1,13 @@ +# 0.2.104 — 2026-07-17 + +## Features + +- **Background work counts** now appear in a persistent status line instead of repeated transcript messages. + +## Bug Fixes + +- **Fixed authentication recovery** for idle sessions after token timeouts. +- **Retry failed** messages no longer contain raw HTML error pages. +- **Rate limit messages** now show the server detail without the wire prefix. +- **In-place prompt editing** is temporarily disabled due to scroll behavior issues. + diff --git a/crates/codegen/xai-grok-shell/changelogs/0.2.105.json b/crates/codegen/xai-grok-shell/changelogs/0.2.105.json new file mode 100644 index 0000000..4beb217 --- /dev/null +++ b/crates/codegen/xai-grok-shell/changelogs/0.2.105.json @@ -0,0 +1,67 @@ +[ + { + "category": "performance", + "description": "**Scrolling** feels smoother and less jagged under load or over slow connections.", + "breaking_change": false + }, + { + "category": "features", + "description": "**/btw** now works inside `grok --minimal`, showing answers in the live area and committing them to scrollback on Esc.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Local shell tools** now see the same environment variables, aliases, and functions as your login shell.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Syntax highlighting** in diffs and the file viewer no longer miscolors strings or comments that span multiple lines.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Global rules** from ~/.grok/rules and compatible vendor homes are now discovered correctly.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Background tasks** that finish after you press Ctrl+C no longer automatically resume the model.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Ctrl+\\** out of the dashboard now returns you to the agent you came from.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**MCP OAuth logins** now succeed against servers that require the RFC 9207 issuer parameter in the callback.", + "breaking_change": false + }, + { + "category": "features", + "description": "**New Appearance setting** \"Snap prompt to top on send\" lets you keep the viewport where it is instead of jumping to the new prompt.", + "breaking_change": false + }, + { + "category": "features", + "description": "**Default model** is now Grok 4.5 with high/medium/low reasoning effort and improved compaction settings.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Agent dashboard** now shows fleet roster entries even when the local agent list is empty.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Long-session compaction** no longer fails on servers that reject tool_choice none when tools are attached.", + "breaking_change": false + }, + { + "category": "features", + "description": "**New `/summarize` slash command** is now available as an alias for `/recap` to request an on-demand session summary.", + "breaking_change": false + } +] diff --git a/crates/codegen/xai-grok-shell/changelogs/0.2.105.md b/crates/codegen/xai-grok-shell/changelogs/0.2.105.md new file mode 100644 index 0000000..1ea7929 --- /dev/null +++ b/crates/codegen/xai-grok-shell/changelogs/0.2.105.md @@ -0,0 +1,24 @@ +# 0.2.105 — 2026-07-18 + +## Features + +- **/btw** now works inside `grok --minimal`, showing answers in the live area and committing them to scrollback on Esc. +- **New Appearance setting** "Snap prompt to top on send" lets you keep the viewport where it is instead of jumping to the new prompt. +- **Default model** is now Grok 4.5 with high/medium/low reasoning effort and improved compaction settings. +- **New `/summarize` slash command** is now available as an alias for `/recap` to request an on-demand session summary. + +## Bug Fixes + +- **Local shell tools** now see the same environment variables, aliases, and functions as your login shell. +- **Syntax highlighting** in diffs and the file viewer no longer miscolors strings or comments that span multiple lines. +- **Global rules** from ~/.grok/rules and compatible vendor homes are now discovered correctly. +- **Background tasks** that finish after you press Ctrl+C no longer automatically resume the model. +- **Ctrl+\** out of the dashboard now returns you to the agent you came from. +- **MCP OAuth logins** now succeed against servers that require the RFC 9207 issuer parameter in the callback. +- **Agent dashboard** now shows fleet roster entries even when the local agent list is empty. +- **Long-session compaction** no longer fails on servers that reject tool_choice none when tools are attached. + +## Performance + +- **Scrolling** feels smoother and less jagged under load or over slow connections. + diff --git a/crates/codegen/xai-grok-shell/src/agent/config.rs b/crates/codegen/xai-grok-shell/src/agent/config.rs index c934b1c..d076f39 100644 --- a/crates/codegen/xai-grok-shell/src/agent/config.rs +++ b/crates/codegen/xai-grok-shell/src/agent/config.rs @@ -630,7 +630,6 @@ pub struct Requirements { pub struct RuntimeResolutionContext<'a> { pub raw_config: &'a toml::Value, pub remote_settings: Option<&'a crate::util::config::RemoteSettings>, - pub cwd: Option<&'a std::path::Path>, pub is_headless: bool, /// `Some(true)` = CLI explicitly enabled, `None` = defer to config/env/remote. pub cli_subagents: Option, @@ -1151,6 +1150,9 @@ pub struct MarketplaceConfig { /// Written/read out-of-band by `extensions::marketplace`, opaque so a wrong-typed value can't fail load. #[serde(default)] pub official_marketplace_auto_installed: Option, + /// Written/read out-of-band by `extensions::marketplace`, opaque so a wrong-typed value can't fail load. + #[serde(default)] + pub default_skills_installs_purged: Option, } /// A single `[[marketplace.sources]]` entry. #[derive(Clone, Debug, Deserialize)] @@ -1455,11 +1457,11 @@ pub struct Config { /// Keys are agent names, values are booleans. Omitted agents default to enabled. #[serde(skip)] pub subagent_toggle: std::collections::HashMap, - /// Per-subagent role definitions from `[subagents.roles]` in config.toml - /// and `.grok/roles/*.toml` file discovery. + /// Trust-independent roles from inline, user, and bundled sources. #[serde(skip)] pub subagent_roles: std::collections::HashMap, + /// Trust-independent personas from inline, user, and bundled sources. #[serde(skip)] pub subagent_personas: std::collections::HashMap, @@ -1885,18 +1887,13 @@ impl Config { config.apply_env_overrides(); Ok(config) } - /// Populate `#[serde(skip)]` subagent fields from `SubagentsConfig::resolve()`. + /// Populate trust-independent `#[serde(skip)]` subagent base fields. /// /// Must be called after `new_from_toml_cfg` on the **primary startup path** - /// before the config is handed to `MvpAgent`. Model-reload and API-key-reload - /// paths only read model/key fields and do not need this call. - pub fn resolve_subagents( - &mut self, - cli_flag: bool, - raw_config: &toml::Value, - cwd: Option<&std::path::Path>, - ) { - let sa = crate::config::SubagentsConfig::resolve(cli_flag, raw_config, cwd); + /// before the config is handed to `MvpAgent`. Project definitions are overlaid + /// per cwd after that cwd's authoritative folder-trust resolve. + pub fn resolve_subagents(&mut self, cli_flag: bool, raw_config: &toml::Value) { + let sa = crate::config::SubagentsConfig::resolve(cli_flag, raw_config); self.subagents_enabled = sa.enabled; self.subagent_model_overrides = sa.models; self.subagent_toggle = sa.toggle; @@ -1906,7 +1903,7 @@ impl Config { /// Resolve all `#[serde(skip)]` runtime fields that have resolver functions. /// /// Call immediately after `new_from_toml_cfg()`. Fields resolved: - /// - subagents (6 fields) via `SubagentsConfig::resolve` + /// - subagents base layers (6 fields) via `SubagentsConfig::resolve` /// - respect_gitignore via `ToolsConfig::resolve` /// - disable_zdr_incompatible_tools via `ToolsConfig::resolve` /// - managed_mcps_enabled via `ManagedMcpsConfig::resolve` @@ -1924,7 +1921,7 @@ impl Config { self.web_search_model_override = ctx.cli_web_search_model.map(|s| s.to_owned()); self.session_summary_model_override = ctx.cli_session_summary_model.map(|s| s.to_owned()); let cli_flag = ctx.cli_subagents.unwrap_or(false); - self.resolve_subagents(cli_flag, ctx.raw_config, ctx.cwd); + self.resolve_subagents(cli_flag, ctx.raw_config); let tools = crate::config::ToolsConfig::resolve(ctx.raw_config); self.respect_gitignore = match self.requirements.respect_gitignore.pinned() { Some(pinned) => pinned, @@ -1975,16 +1972,11 @@ impl Config { self.compat_resolved = resolve_compat_config(&self.compat, ctx.remote_settings); } /// Re-resolve eagerly-resolved runtime fields using the current `Config` - /// state and fresh `raw_config` + `cwd`. Builds a - /// [`RuntimeResolutionContext`] from the CLI flags already stored on this - /// `Config` so callers don't need to manually extract each field. + /// state and fresh `raw_config`. Builds a [`RuntimeResolutionContext`] from + /// the CLI flags already stored on this `Config`. /// /// Integration test coverage: `tests/test_settings_refresh.rs`. - pub fn re_resolve_runtime_fields( - &mut self, - raw_config: &toml::Value, - cwd: Option<&std::path::Path>, - ) { + pub fn re_resolve_runtime_fields(&mut self, raw_config: &toml::Value) { let remote_settings = self.remote_settings.clone(); let cli_web_search_model = self.web_search_model_override.clone(); let cli_session_summary_model = self.session_summary_model_override.clone(); @@ -1992,7 +1984,6 @@ impl Config { let ctx = RuntimeResolutionContext { raw_config, remote_settings: remote_settings.as_ref(), - cwd, is_headless: self.mode == AgentMode::Headless, cli_subagents: self.cli_subagents, cli_web_search_model: cli_web_search_model.as_deref(), @@ -2599,6 +2590,17 @@ impl Config { .resolve() .value } + pub(crate) fn resolve_compaction_tool_choice( + &self, + ) -> crate::util::config::CompactionToolChoice { + crate::util::config::resolve_compaction_tool_choice_from( + env_string(crate::util::config::ENV_COMPACTION_TOOL_CHOICE).as_deref(), + self.features.compaction_tool_choice.as_deref(), + self.remote_settings + .as_ref() + .and_then(|r| r.compaction_tool_choice.as_deref()), + ) + } /// Precedence: env `GROK_COMPACTION_DETAIL`, then config /// `features.compaction_detail`, then remote settings /// `remote_settings.compaction_detail`, then default (`verbose`). Drives the @@ -3368,6 +3370,10 @@ struct DefaultModelJson { compaction_at_tokens: Option, #[serde(default)] show_model_fingerprint: bool, + #[serde(default)] + auto_compact_threshold_percent: Option, + #[serde(default)] + system_prompt_label: Option, } fn default_models(endpoints: &EndpointsConfig) -> IndexMap { let root: serde_json::Value = serde_json::from_str(crate::models::DEFAULT_MODELS_JSON) @@ -3402,8 +3408,8 @@ fn default_models(endpoints: &EndpointsConfig) -> IndexMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub compaction_tool_choice: Option, /// Snapshot a completed subagent's isolated worktree into a durable git ref /// and delete its directory (resume rehydrates from the ref). This is the /// per-deployment rollout lever (set in managed_config.toml `[features]`). @@ -5153,7 +5161,6 @@ reasoning_effort = "low" RuntimeResolutionContext { raw_config: raw, remote_settings: None, - cwd: None, is_headless: false, cli_subagents: None, cli_web_search_model: None, @@ -5182,7 +5189,6 @@ reasoning_effort = "low" RuntimeResolutionContext { raw_config: raw, remote_settings: None, - cwd: None, is_headless: true, cli_subagents: None, cli_web_search_model: None, @@ -9215,6 +9221,7 @@ agent_type = "cursor" url = "https://mcp.test.com" [toolset.bash] timeout_secs = 120 + login_shell_capture = true [shortcuts] ctrl_k = "search" [grok_com_config] @@ -9325,6 +9332,7 @@ agent_type = "cursor" let toml_str = r#" [marketplace] official_marketplace_auto_installed = "yes" + default_skills_installs_purged = "yes" "#; let unused = unused_keys_from_toml(toml_str); assert!(unused.is_empty(), "got: {unused:?}"); @@ -9342,6 +9350,7 @@ agent_type = "cursor" deny = ["Bash(rm *)"] [marketplace] official_marketplace_auto_installed = true + default_skills_installs_purged = true [ui] yollo = true "#, @@ -10108,7 +10117,6 @@ hooks = true config.resolve_runtime_fields(&RuntimeResolutionContext { raw_config: &raw, remote_settings: Some(&remote), - cwd: None, is_headless: false, cli_subagents: None, cli_web_search_model: None, @@ -10134,7 +10142,6 @@ hooks = true cfg.resolve_runtime_fields(&RuntimeResolutionContext { raw_config: &raw, remote_settings: None, - cwd: None, is_headless: false, cli_subagents: None, cli_web_search_model: None, @@ -10170,7 +10177,6 @@ hooks = true cfg.resolve_runtime_fields(&RuntimeResolutionContext { raw_config: &raw, remote_settings: None, - cwd: None, is_headless: true, cli_subagents: None, cli_web_search_model: None, @@ -10202,7 +10208,6 @@ hooks = true cfg.resolve_runtime_fields(&RuntimeResolutionContext { raw_config: &raw, remote_settings: Some(&remote), - cwd: None, is_headless: false, cli_subagents: None, cli_web_search_model: None, @@ -10225,7 +10230,6 @@ hooks = true cfg.resolve_runtime_fields(&RuntimeResolutionContext { raw_config: &raw, remote_settings: None, - cwd: None, is_headless: false, cli_subagents: None, cli_web_search_model: None, @@ -10248,7 +10252,6 @@ hooks = true cfg.resolve_runtime_fields(&RuntimeResolutionContext { raw_config: &raw, remote_settings: None, - cwd: None, is_headless: false, cli_subagents: Some(true), cli_web_search_model: None, @@ -10272,7 +10275,6 @@ hooks = true cfg.resolve_runtime_fields(&RuntimeResolutionContext { raw_config: &raw, remote_settings: None, - cwd: None, is_headless: false, cli_subagents: None, cli_web_search_model: None, @@ -10296,7 +10298,6 @@ hooks = true cfg.resolve_runtime_fields(&RuntimeResolutionContext { raw_config: &raw, remote_settings: None, - cwd: None, is_headless: false, cli_subagents: None, cli_web_search_model: Some("custom-ws"), @@ -10324,7 +10325,6 @@ hooks = true cfg.resolve_runtime_fields(&RuntimeResolutionContext { raw_config: &raw, remote_settings: Some(&remote), - cwd: None, is_headless: false, cli_subagents: None, cli_web_search_model: None, @@ -10347,7 +10347,6 @@ hooks = true let ctx = RuntimeResolutionContext { raw_config: &raw, remote_settings: None, - cwd: None, is_headless: false, cli_subagents: None, cli_web_search_model: None, @@ -10872,12 +10871,13 @@ default = "grok-4.5" #[test] fn resolve_model_list_inherits_context_window_from_default_when_prefetched_has_fallback() { let cfg = Config::default(); + let dm = crate::models::default_model(); let default_cw = DEFAULT_CONTEXT_WINDOW; - let entry = prefetch_model_entry("grok-build", default_cw, ApiBackend::default()); + let entry = prefetch_model_entry(dm, default_cw, ApiBackend::default()); let mut prefetched = IndexMap::new(); - prefetched.insert("grok-build".to_owned(), entry); + prefetched.insert(dm.to_owned(), entry); let resolved = resolve_model_list(&cfg, Some(prefetched)); - let entry = resolved.get("grok-build").expect("model must exist"); + let entry = resolved.get(dm).expect("model must exist"); assert_ne!( entry.info.context_window.get(), default_cw, @@ -10887,12 +10887,13 @@ default = "grok-4.5" #[test] fn resolve_model_list_does_not_override_explicitly_set_context_window() { let cfg = Config::default(); + let dm = crate::models::default_model(); let explicit_cw = 65_536; - let entry = prefetch_model_entry("grok-build", explicit_cw, ApiBackend::default()); + let entry = prefetch_model_entry(dm, explicit_cw, ApiBackend::default()); let mut prefetched = IndexMap::new(); - prefetched.insert("grok-build".to_owned(), entry); + prefetched.insert(dm.to_owned(), entry); let resolved = resolve_model_list(&cfg, Some(prefetched)); - let entry = resolved.get("grok-build").expect("model must exist"); + let entry = resolved.get(dm).expect("model must exist"); assert_eq!( entry.info.context_window.get(), explicit_cw, @@ -10902,14 +10903,15 @@ default = "grok-4.5" #[test] fn resolve_model_list_inherits_agent_type_and_api_backend() { let cfg = Config::default(); + let dm = crate::models::default_model(); let default_cw = DEFAULT_CONTEXT_WINDOW; - let entry = prefetch_model_entry("grok-build", default_cw, ApiBackend::default()); + let entry = prefetch_model_entry(dm, default_cw, ApiBackend::default()); let mut prefetched = IndexMap::new(); - prefetched.insert("grok-build".to_owned(), entry); + prefetched.insert(dm.to_owned(), entry); let resolved = resolve_model_list(&cfg, Some(prefetched)); - let entry = resolved.get("grok-build").expect("model must exist"); + let entry = resolved.get(dm).expect("model must exist"); let defaults = default_model_entries(&EndpointsConfig::default()); - if let Some(default) = defaults.get("grok-build") { + if let Some(default) = defaults.get(dm) { if default.info.agent_type != DEFAULT_AGENT_TYPE { assert_eq!( entry.info.agent_type, default.info.agent_type, @@ -10947,23 +10949,25 @@ default = "grok-4.5" #[test] fn resolve_model_list_prunes_bundled_entries_not_in_prefetch() { let cfg = Config::default(); + let dm = crate::models::default_model(); let mut defs = default_model_entries(&EndpointsConfig::default()); let mut p = IndexMap::new(); - if let Some(e) = defs.shift_remove("grok-build") { - p.insert("grok-build".to_string(), e); + if let Some(e) = defs.shift_remove(dm) { + p.insert(dm.to_string(), e); } let resolved = resolve_model_list(&cfg, Some(p)); - assert!(resolved.contains_key("grok-build")); + assert!(resolved.contains_key(dm)); let no_p = resolve_model_list(&cfg, None); - assert!(no_p.contains_key("grok-build")); + assert!(no_p.contains_key(dm)); } #[test] fn resolve_model_list_prefetch_visibility_matches_auth_and_server_list() { let cfg = Config::default(); + let dm = crate::models::default_model(); let mut defs = default_model_entries(&EndpointsConfig::default()); let mut p = IndexMap::new(); - if let Some(e) = defs.shift_remove("grok-build") { - p.insert("grok-build".to_string(), e); + if let Some(e) = defs.shift_remove(dm) { + p.insert(dm.to_string(), e); } let resolved = resolve_model_list(&cfg, Some(p)); let sess: Vec<_> = resolved @@ -10975,27 +10979,29 @@ default = "grok-4.5" .filter(|e| e.visible_for_auth(false)) .collect(); assert_eq!(sess.len(), 1); - assert!(api.is_empty()); + assert_eq!(api.len(), 1); } #[test] fn resolve_model_list_keeps_prefetch_only_entries_and_prunes_defaults() { let cfg = Config::default(); + let dm = crate::models::default_model(); let mut p = IndexMap::new(); let e = prefetch_model_entry("secret-xyz", 200000, ApiBackend::default()); p.insert("secret-xyz".to_string(), e); let resolved = resolve_model_list(&cfg, Some(p)); assert!(resolved.contains_key("secret-xyz")); - assert!(!resolved.contains_key("grok-build")); + assert!(!resolved.contains_key(dm)); } #[test] fn resolve_model_list_prefetch_replaces_bundled_entirely() { let cfg = Config::default(); + let dm = crate::models::default_model(); let mut p = IndexMap::new(); - let e = prefetch_model_entry("grok-4.5", 500_000, ApiBackend::Responses); - p.insert("grok-4.5".to_string(), e); + let e = prefetch_model_entry("other-model", 500_000, ApiBackend::Responses); + p.insert("other-model".to_string(), e); let resolved = resolve_model_list(&cfg, Some(p)); - assert!(resolved.contains_key("grok-4.5")); - assert!(!resolved.contains_key("grok-build")); + assert!(resolved.contains_key("other-model")); + assert!(!resolved.contains_key(dm)); } #[test] fn resolve_model_list_empty_prefetch_yields_empty_base() { @@ -11003,14 +11009,14 @@ default = "grok-4.5" let resolved = resolve_model_list(&cfg, Some(IndexMap::new())); assert!(resolved.is_empty()); } - /// Regression: enterprise managed config aliases grok-build to their own - /// endpoint with env_key. The bundled grok-build has supported_in_api=false. - /// The config overlay must be visible to API-key users (env_key = BYOK). + /// Regression: enterprise managed config overlays env_key on an oauth-only + /// catalog entry. BYOK must force visibility for API-key users so a + /// base `supported_in_api: false` does not leak into the overlay. #[test] fn byok_config_overlay_visible_to_api_key_users() { let raw: toml::Value = toml::from_str( r#" - [model.grok-build] + [model.enterprise-alias] model = "grok-4.5" base_url = "https://inference.company.com/v1" env_key = "COMPANY_TOKEN" @@ -11018,31 +11024,48 @@ default = "grok-4.5" ) .unwrap(); let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse"); - let resolved = resolve_model_list(&cfg, None); - let entry = resolved.get("grok-build").expect("grok-build must exist"); + let mut base = prefetch_model_entry("enterprise-alias", 200_000, ApiBackend::default()); + base.info.supported_in_api = false; + let mut prefetched = IndexMap::new(); + prefetched.insert("enterprise-alias".to_owned(), base); + let resolved = resolve_model_list(&cfg, Some(prefetched)); + let entry = resolved + .get("enterprise-alias") + .expect("enterprise-alias must exist"); assert!( entry.visible_for_auth(false), "BYOK config entry must be visible to API-key users — \ - bundled supported_in_api=false must not leak into credentialed overlays" + env_key must override base supported_in_api=false" ); } - /// Guard: config overlay WITHOUT credentials must NOT override the - /// bundled supported_in_api flag. Only BYOK triggers the override. + /// Guard: config overlay WITHOUT credentials must NOT flip the + /// bundled supported_in_api flag. Only BYOK triggers that override. #[test] fn plain_config_overlay_preserves_bundled_visibility() { - let raw: toml::Value = toml::from_str( + let dm = crate::models::default_model(); + let bundled = default_model_entries(&EndpointsConfig::default()) + .get(dm) + .expect("bundled default must exist") + .clone(); + let raw: toml::Value = toml::from_str(&format!( r#" - [model.grok-build] + [model."{dm}"] context_window = 300000 - "#, - ) + "# + )) .unwrap(); let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse"); let resolved = resolve_model_list(&cfg, None); - let entry = resolved.get("grok-build").expect("grok-build must exist"); - assert!( - !entry.visible_for_auth(false), - "non-BYOK config overlay must preserve bundled supported_in_api=false" + let entry = resolved.get(dm).expect("bundled default must exist"); + assert_eq!( + entry.visible_for_auth(false), + bundled.visible_for_auth(false), + "non-BYOK config overlay must preserve bundled supported_in_api" + ); + assert_eq!( + entry.visible_for_auth(true), + bundled.visible_for_auth(true), + "non-BYOK config overlay must preserve bundled OAuth visibility" ); } #[test] diff --git a/crates/codegen/xai-grok-shell/src/agent/init.rs b/crates/codegen/xai-grok-shell/src/agent/init.rs index 3b2e1f7..a049126 100644 --- a/crates/codegen/xai-grok-shell/src/agent/init.rs +++ b/crates/codegen/xai-grok-shell/src/agent/init.rs @@ -137,6 +137,8 @@ fn init_process(cfg: &AgentConfig, auth_manager: &AuthManager) { let grok_home = crate::util::grok_home::grok_home(); crate::builtin::extract_bundled_files(&grok_home); + crate::extensions::marketplace::purge_default_skills_installs(&grok_home); + // Auto-register is gated (default off; env/remote settings enables). Kept out // of extract_bundled_files so the gate can read the resolved // remote_settings, which resolve_config has populated by now. diff --git a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/acp_agent.rs b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/acp_agent.rs index 275b9cb..c76f1b3 100644 --- a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/acp_agent.rs +++ b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/acp_agent.rs @@ -2314,6 +2314,7 @@ impl acp::Agent for MvpAgent { traceparent: xai_file_utils::trace_context::current_traceparent(), json_schema, send_now, + admission: None, respond_to: tx, persist_ack: None, parsed_prompt_tx, diff --git a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/agent_ops.rs b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/agent_ops.rs index 57f2da3..b10c454 100644 --- a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/agent_ops.rs +++ b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/agent_ops.rs @@ -873,7 +873,6 @@ impl MvpAgent { auth: &crate::auth::GrokAuth, ) { self.refresh_remote_settings(auth).await; - let cwd = std::env::current_dir().ok(); { let mut cfg = self.cfg.borrow_mut(); crate::util::config::sync_campaign_fields(&mut cfg); @@ -884,7 +883,7 @@ impl MvpAgent { ); toml::Value::Table(toml::map::Map::new()) }); - cfg.re_resolve_runtime_fields(&raw_config, cwd.as_deref()); + cfg.re_resolve_runtime_fields(&raw_config); } self.sync_collection_config_gate(); self.emit_settings_update_notification(); @@ -1401,6 +1400,7 @@ impl MvpAgent { /// Params resolution (TOML > env > remote settings > default): /// - `proxy_endpoint`: `[toolset.web_fetch] proxy_endpoint` > `GROK_WEB_FETCH_PROXY` > remote settings > None /// - `allowed_domains`: `[toolset.web_fetch] allowed_domains` > remote settings > built-in defaults + /// - `allow_local`: `[toolset.web_fetch] allow_local` > `GROK_WEB_FETCH_ALLOW_LOCAL` > false pub(super) fn prepare_web_fetch_config( &self, ) -> xai_grok_tools::implementations::grok_build::web_fetch::WebFetchConfig { @@ -1507,10 +1507,6 @@ impl MvpAgent { resident_roster_titles: RefCell::new(HashMap::new()), initialize_request: OnceLock::new(), gateway, - subagent_model_overrides: cfg.subagent_model_overrides.clone(), - subagent_toggle: cfg.subagent_toggle.clone(), - subagent_roles: cfg.subagent_roles.clone(), - subagent_personas: cfg.subagent_personas.clone(), launch_cwd: std::env::current_dir() .unwrap_or_else(|_| std::path::PathBuf::from(".")), launch_dir_trust: std::cell::OnceCell::new(), @@ -1519,11 +1515,6 @@ impl MvpAgent { cfg.plugins.cli_plugin_dirs.clone(), ), plugin_registry_initialized: std::cell::Cell::new(false), - persona_io_summaries: cfg - .subagent_personas - .iter() - .map(|(name, p)| p.render_io_summary(name)) - .collect(), models_manager, chat_modes: { let chat_modes = crate::agent::chat_modes::ChatModesManager::new( @@ -3182,6 +3173,7 @@ impl MvpAgent { .cfg .borrow() .resolve_compaction_verbatim_input(); + let compaction_tool_choice = self.cfg.borrow().resolve_compaction_tool_choice(); let two_pass_enabled = self.cfg.borrow().is_two_pass_compaction_enabled(); let auto_update = self.cfg.borrow().cli.auto_update; let client_type = *self.client_type.borrow(); @@ -3384,7 +3376,7 @@ impl MvpAgent { let laziness_debug_log_for_spawn = self.cfg.borrow().laziness_debug_log.clone(); let respect_gitignore = self.cfg.borrow().respect_gitignore; let path_not_found_hints = self.cfg.borrow().path_not_found_hints; - let subagent_toggle = self.subagent_toggle.clone(); + let subagent_toggle = self.cfg.borrow().subagent_toggle.clone(); let handle_display_cwd = prompt_display_cwd.clone(); let auth_manager = Some(self.auth_manager.clone()); let bash_params_json = { @@ -3527,6 +3519,7 @@ impl MvpAgent { .as_ref() .and_then(|m| m.get("x.ai/gitHeadChanged")) .and_then(|v| v.as_bool()); + let session_cwd = std::path::Path::new(&session_info.cwd); let fs_watch_caps = crate::session::fs_watch::FsWatchCapabilities::resolve(crate::session::fs_watch::CapabilityInputs { client_notify: fs_notify_config.is_some(), hunk_tracking: hunk_plan.enabled(), @@ -3561,6 +3554,7 @@ impl MvpAgent { system_prompt_label, compaction_mode, compaction_verbatim_input, + compaction_tool_choice, two_pass_enabled, buffering_settings, origin_client.clone(), @@ -3608,7 +3602,7 @@ impl MvpAgent { client_hooks, prompt_display_cwd, subagent_toggle, - self.persona_io_summaries.clone(), + Vec::new(), xai_grok_agent::prompt::context::PromptAudience::Primary, None, None, @@ -3618,7 +3612,6 @@ impl MvpAgent { path_not_found_hints, tool_params_json, { - let session_cwd = std::path::Path::new(&session_info.cwd); let disk_cfg = crate::config::resolve_effective_plugins_config( session_cwd, ) diff --git a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/mod.rs b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/mod.rs index 50336f9..efe40ea 100644 --- a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/mod.rs +++ b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/mod.rs @@ -794,20 +794,6 @@ pub struct MvpAgent { /// notification has `Next` priority. Drained by the session turn loop /// (`inject_pending_monitor_events`) into a hidden synthetic user message. monitor_event_buffer: xai_grok_tools::implementations::grok_build::task::types::MonitorEventBuffer, - /// Per-subagent model ID overrides from config.toml `[subagents.models]`. - /// Populated from `SubagentsConfig.models` during `with_models()`. - subagent_model_overrides: std::collections::HashMap, - /// Per-subagent enable/disable toggles from config.toml `[subagents.toggle]`. - /// Populated from `SubagentsConfig.toggle` during `with_models()`. - subagent_toggle: std::collections::HashMap, - subagent_roles: std::collections::HashMap< - String, - xai_grok_subagent_resolution::config::SubagentRole, - >, - subagent_personas: std::collections::HashMap< - String, - xai_grok_subagent_resolution::config::SubagentPersona, - >, /// The process launch directory, captured once at construction so the /// deferred launch-dir init paths share one source of truth instead of each /// re-calling `std::env::current_dir()` (which could drift if the process @@ -827,7 +813,6 @@ pub struct MvpAgent { /// the first session-creating call via [`Self::ensure_plugin_registry`]; /// this flag keeps that to a single discovery walk. plugin_registry_initialized: std::cell::Cell, - persona_io_summaries: Vec, /// Single-flight guard for the proactive bundle sync background task. /// /// `maybe_sync_bundle_in_background` is invoked from each post-auth path @@ -1176,6 +1161,9 @@ fn inject_proxy_headers( .map(String::from) .unwrap_or_else(|| xai_grok_version::VERSION.to_string()) }); + headers + .entry("x-grok-client-identifier".to_string()) + .or_insert_with(crate::http::process_client_identifier); if crate::util::is_cli_chat_proxy_url(base_url) { headers .entry("X-XAI-Token-Auth".to_string()) @@ -1264,6 +1252,7 @@ mod session_lifecycle; mod subagent_coordinator; mod agent_ops; mod acp_agent; +pub(crate) use session_lifecycle::RegistrySnapshot; pub(super) use super::ext_parsers; /// Emit the `auth.lifecycle` login span with optional user id and error /// category. Named `auth.lifecycle` (not `auth`) to avoid colliding with the diff --git a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/session_lifecycle.rs b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/session_lifecycle.rs index 544ded6..9af1944 100644 --- a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/session_lifecycle.rs +++ b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/session_lifecycle.rs @@ -402,4 +402,47 @@ impl MvpAgent { .await .unwrap_or(true) } + /// Entry counts for every collection [`Self::remove_session`] drains, + /// plus the workspace binding and subagent maps. + pub(crate) fn registry_snapshot(&self) -> RegistrySnapshot { + let (subagent_pending, subagent_active, subagent_completed) = + self.subagent_coordinator.borrow().registry_snapshot(); + RegistrySnapshot { + sessions: self.sessions.borrow().len(), + session_threads: self.session_threads.borrow().len(), + dispatch_locks: self.dispatch_locks.borrow().len(), + session_turn_numbers: self.session_turn_numbers.borrow().len(), + permission_event_receivers: self.permission_event_receivers.borrow().len(), + model_unavailable_sessions: self.model_unavailable_sessions.borrow().len(), + session_live_state: self.session_live_state.borrow().len(), + session_index_claims: self.session_index_claims.borrow().len(), + require_gateway_sessions: self.require_gateway_sessions.borrow().len(), + subagent_pending, + subagent_active, + subagent_completed, + workspace_bindings: self + .workspace_ops + .borrow() + .as_ref() + .and_then(|ops| ops.workspace_handle().map(|h| h.session_count())), + } + } +} +/// Field names are the wire contract of `x.ai/debug/agent`'s `registries` +/// object; each maps to the same-named registry. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct RegistrySnapshot { + pub sessions: usize, + pub session_threads: usize, + pub dispatch_locks: usize, + pub session_turn_numbers: usize, + pub permission_event_receivers: usize, + pub model_unavailable_sessions: usize, + pub session_live_state: usize, + pub session_index_claims: usize, + pub require_gateway_sessions: usize, + pub subagent_pending: usize, + pub subagent_active: usize, + pub subagent_completed: usize, + pub workspace_bindings: Option, } diff --git a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/subagent_coordinator.rs b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/subagent_coordinator.rs index 24aeb4b..e17a72b 100644 --- a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/subagent_coordinator.rs +++ b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/subagent_coordinator.rs @@ -268,14 +268,17 @@ impl MvpAgent { ps.and_then(|h| h.allowed_subagent_types.clone()), ) }; - let cli_agent_names: Vec = { + let (cli_agent_names, subagent_toggle) = { let cfg = self.cfg.borrow(); - cfg.cli_agents.iter().map(|d| d.name.clone()).collect() + ( + cfg.cli_agents.iter().map(|d| d.name.clone()).collect(), + cfg.subagent_toggle.clone(), + ) }; crate::agent::subagent::SubagentValidationContext { parent_cwd, plugin_registry: self.plugin_registry_handle.snapshot(), - subagent_toggle: self.subagent_toggle.clone(), + subagent_toggle, allowed_subagent_types, cli_agent_names, } @@ -432,6 +435,23 @@ impl MvpAgent { } None => (None, None), }; + let project_trusted = crate::agent::folder_trust::project_scope_allowed(&parent_cwd); + let (base_roles, base_personas, subagent_model_overrides, subagent_toggle) = { + let cfg = self.cfg.borrow(); + ( + cfg.subagent_roles.clone(), + cfg.subagent_personas.clone(), + cfg.subagent_model_overrides.clone(), + cfg.subagent_toggle.clone(), + ) + }; + let (subagent_roles, subagent_personas) = + crate::config::SubagentsConfig::effective_definition_maps( + &base_roles, + &base_personas, + &parent_cwd, + project_trusted, + ); Some(crate::agent::subagent::SubagentSpawnContext { lsp: parent_lsp, gateway: self.gateway.clone(), @@ -484,11 +504,10 @@ impl MvpAgent { parent_chat_state, parent_max_turns, available_models, - subagent_model_overrides: self.subagent_model_overrides.clone(), - subagent_toggle: self.subagent_toggle.clone(), - subagent_roles: self.subagent_roles.clone(), - subagent_personas: self.subagent_personas.clone(), - persona_io_summaries: self.persona_io_summaries.clone(), + subagent_model_overrides, + subagent_toggle, + subagent_roles, + subagent_personas, disable_web_search: self.cfg.borrow().disable_web_search, todo_gate: self.cfg.borrow().todo_gate, remote_settings: self.cfg.borrow().remote_settings.clone(), @@ -549,11 +568,11 @@ impl MvpAgent { parent_skills: None, parent_skills_config: self.cfg.borrow().skills.clone(), parent_compat: self.cfg.borrow().compat_resolved, - auto_wake_delivered: { + task_completion_reservations: { let sessions = self.sessions.borrow(); sessions .get(&parent_sid) - .and_then(|h| h.tool_context.auto_wake_delivered.clone()) + .and_then(|h| h.tool_context.task_completion_reservations.clone()) }, synthetic_trace_tx: { let sessions = self.sessions.borrow(); diff --git a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/tests.rs b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/tests.rs index db2226b..91324ed 100644 --- a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/tests.rs +++ b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/tests.rs @@ -2517,6 +2517,45 @@ async fn prepare_image_gen_config_fails_open_without_auth() { "no resolved auth ⇒ fail open (tools not tier-restricted)" ); } +/// The imagine tools bypass cli-chat-proxy (direct API calls), so the server +/// can only scope the coding data-retention opt-out (`/privacy opt-out`) to +/// Build traffic via the `x-grok-client-identifier` header. If this header is +/// dropped, opted-out users' imagine prompts are logged/retained server-side. +#[tokio::test(flavor = "current_thread")] +async fn prepare_image_gen_config_sends_client_identifier_header() { + use xai_grok_tools::implementations::grok_build::image_gen::ImageGenConfig; + let agent = build_minimal_agent_for_tests(); + agent.sampling_config.borrow_mut().api_key = Some("test-key".to_string()); + let ImageGenConfig::Enabled { extra_headers, .. } = agent.prepare_image_gen_config() else { + panic!("expected Enabled"); + }; + assert_eq!( + extra_headers + .get("x-grok-client-identifier") + .map(String::as_str), + Some(crate::http::process_client_identifier().as_str()), + "imagine API calls must carry the client identifier so the server \ + applies the coding ZDR opt-out to Build traffic" + ); +} +/// Same contract for video generation (also a direct API call). +#[tokio::test(flavor = "current_thread")] +async fn prepare_video_gen_config_sends_client_identifier_header() { + use xai_grok_tools::implementations::grok_build::video_gen::VideoGenConfig; + let agent = build_minimal_agent_for_tests(); + agent.sampling_config.borrow_mut().api_key = Some("test-key".to_string()); + let VideoGenConfig::Enabled { extra_headers, .. } = agent.prepare_video_gen_config() else { + panic!("expected Enabled"); + }; + assert_eq!( + extra_headers + .get("x-grok-client-identifier") + .map(String::as_str), + Some(crate::http::process_client_identifier().as_str()), + "video gen API calls must carry the client identifier so the server \ + applies the coding ZDR opt-out to Build traffic" + ); +} #[tokio::test] async fn data_collection_enabled_for_normal_user() { let agent = build_agent_with_auth(crate::auth::GrokAuth::test_default()); @@ -3642,12 +3681,148 @@ fn repo_with_project_mcp_server() -> tempfile::TempDir { .unwrap(); tmp } +fn write_project_subagent_definitions(cwd: &std::path::Path) { + let roles = cwd.join(".grok/roles"); + let personas = cwd.join(".grok/personas"); + std::fs::create_dir_all(&roles).unwrap(); + std::fs::create_dir_all(&personas).unwrap(); + std::fs::write(roles.join("probe.toml"), "description = \"Project role\"").unwrap(); + std::fs::write( + personas.join("probe.toml"), + "instructions = \"Project persona\"", + ) + .unwrap(); +} fn folder_trust_on() -> crate::util::config::RemoteSettings { crate::util::config::RemoteSettings { folder_trust_enabled: Some(true), ..Default::default() } } +#[test] +#[serial_test::serial] +fn subagent_spawn_context_reloads_project_definitions_after_trust_changes() { + let repo = tempfile::tempdir().unwrap(); + git2::Repository::init(repo.path()).unwrap(); + write_project_subagent_definitions(repo.path()); + run_local_for_bridge_test(|| async { + let (agent, _rx) = build_agent_with_gateway_rx(); + let sid = acp::SessionId::new("roles-personas-trust-transition"); + let (mut handle, _tx, _cmd_rx) = make_live_session_handle(&sid, None); + handle.info.cwd = repo.path().display().to_string(); + agent.sessions.borrow_mut().insert(sid.clone(), handle); + { + let mut cfg = agent.cfg.borrow_mut(); + cfg.subagent_roles.insert( + "refreshed".into(), + xai_grok_subagent_resolution::config::SubagentRole { + description: "Refreshed user role".into(), + source_dir: Some(repo.path().join("user-roles")), + ..Default::default() + }, + ); + cfg.subagent_model_overrides + .insert("probe".into(), "refreshed-model".into()); + cfg.subagent_toggle.insert("probe".into(), false); + } + crate::agent::folder_trust::record_for_test(repo.path(), false); + let untrusted = agent.build_subagent_spawn_context(sid.0.as_ref()); + assert!(!untrusted.subagent_roles.contains_key("probe")); + assert!(!untrusted.subagent_personas.contains_key("probe")); + assert_eq!( + untrusted + .subagent_roles + .get("refreshed") + .map(|role| role.description.as_str()), + Some("Refreshed user role") + ); + assert_eq!( + untrusted + .subagent_model_overrides + .get("probe") + .map(String::as_str), + Some("refreshed-model") + ); + assert_eq!(untrusted.subagent_toggle.get("probe"), Some(&false)); + crate::agent::folder_trust::record_for_test(repo.path(), true); + let trusted = agent.build_subagent_spawn_context(sid.0.as_ref()); + assert_eq!( + trusted + .subagent_roles + .get("probe") + .map(|role| role.description.as_str()), + Some("Project role") + ); + assert!(trusted.subagent_personas.contains_key("probe")); + crate::agent::folder_trust::record_for_test(repo.path(), false); + let revoked = agent.build_subagent_spawn_context(sid.0.as_ref()); + assert!(!revoked.subagent_roles.contains_key("probe")); + assert!(!revoked.subagent_personas.contains_key("probe")); + }); +} +/// End-to-end gate wiring: project `.grok/roles` / `personas` alone must drive +/// real `resolve_and_record` untrusted (not a forced `record_for_test` verdict), +/// keep project defs out of Task spawn context, then re-admit them after grant. +#[test] +#[serial_test::serial] +fn project_roles_personas_gated_via_resolve_and_record_chain() { + use xai_grok_test_support::EnvGuard; + let home = tempfile::tempdir().unwrap(); + let _env = EnvGuard::set("GROK_HOME", home.path()); + let _sim = EnvGuard::set(xai_grok_version::TEST_VERSION_ENV, "0.0-sim"); + let _flag = EnvGuard::unset("GROK_FOLDER_TRUST"); + let repo = tempfile::tempdir().unwrap(); + git2::Repository::init(repo.path()).unwrap(); + write_project_subagent_definitions(repo.path()); + run_local_for_bridge_test(|| async { + let (agent, _rx) = build_agent_with_gateway_rx(); + let sid = acp::SessionId::new("roles-personas-resolve-chain"); + let (mut handle, _tx, _cmd_rx) = make_live_session_handle(&sid, None); + handle.info.cwd = repo.path().display().to_string(); + agent.sessions.borrow_mut().insert(sid.clone(), handle); + let allowed = crate::agent::folder_trust::resolve_and_record( + repo.path(), + Some(&folder_trust_on()), + false, + ); + assert!( + !allowed, + "roles/personas markers alone must resolve untrusted without a grant" + ); + assert!( + !crate::agent::folder_trust::project_scope_allowed(repo.path()), + "cached verdict after resolve_and_record must stay untrusted" + ); + let untrusted = agent.build_subagent_spawn_context(sid.0.as_ref()); + assert!( + !untrusted.subagent_roles.contains_key("probe"), + "untrusted: project role must stay out of spawn context" + ); + assert!( + !untrusted.subagent_personas.contains_key("probe"), + "untrusted: project persona must stay out of spawn context" + ); + crate::agent::folder_trust::grant_folder_trust(repo.path()); + let allowed = crate::agent::folder_trust::resolve_and_record( + repo.path(), + Some(&folder_trust_on()), + false, + ); + assert!(allowed, "store-granted folder must resolve trusted"); + let trusted = agent.build_subagent_spawn_context(sid.0.as_ref()); + assert_eq!( + trusted + .subagent_roles + .get("probe") + .map(|role| role.description.as_str()), + Some("Project role") + ); + assert!( + trusted.subagent_personas.contains_key("probe"), + "trusted: project persona must enter spawn context after grant" + ); + }); +} /// Pull the next `x.ai/folder_trust/request` reverse-request off the gateway and /// answer it with `outcome`. Returns the request's decoded params. async fn answer_folder_trust_request( @@ -4559,3 +4734,5 @@ mod soft_default_settings_emit { .await; } } +#[cfg(feature = "dhat-heap")] +mod dhat_soak; diff --git a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/tests/dhat_soak.rs b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/tests/dhat_soak.rs new file mode 100644 index 0000000..4de09a7 --- /dev/null +++ b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/tests/dhat_soak.rs @@ -0,0 +1,101 @@ +//! Heap-leak test for the session lifecycle: create and remove many sessions, +//! then fail if heap memory grows per session. Run: +//! leader_session_lifecycle_heap_steady_state -- --ignored --nocapture +use super::*; +use xai_grok_workspace::permission::PermissionEvent; +const MAX_BLOCKS_PER_SESSION: f64 = 0.5; +const MAX_BYTES_PER_SESSION: f64 = 1024.0; +/// Creates the per-session state that `remove_session` must clean up, then +/// removes the session. A full `SessionHandle` would allocate so much +/// unrelated memory that a small leak would be lost in the noise. +fn populate_and_evict(agent: &MvpAgent, i: usize) { + let sid = acp::SessionId::new(format!("soak-{i}")); + { + let ops = agent.workspace_ops.borrow(); + let ops = ops.as_ref().expect("test installs workspace ops"); + let toolset = std::sync::Arc::new( + xai_grok_tools::registry::types::FinalizedToolset::empty_for_test(), + ); + ops.bind_local_session( + sid.0.as_ref(), + std::env::temp_dir(), + xai_hunk_tracker::HunkTrackerHandle::noop(), + toolset, + None, + ) + .expect("bind_local_session must succeed"); + } + let (_ptx, prx) = tokio::sync::mpsc::unbounded_channel::(); + agent + .permission_event_receivers + .borrow_mut() + .insert(sid.clone(), prx); + agent + .session_turn_numbers + .borrow_mut() + .insert(sid.clone(), i as u64); + agent.model_unavailable_sessions.borrow_mut().insert( + sid.0.to_string(), + acp::ModelId::new(std::sync::Arc::from("gone-model")), + ); + agent.remove_session(&sid); +} +/// Waits for background tasks to finish before reading heap stats. +async fn quiesce() { + const YIELD_ROUNDS: usize = 50; + const SETTLE: std::time::Duration = std::time::Duration::from_millis(50); + for _ in 0..YIELD_ROUNDS { + tokio::task::yield_now().await; + } + tokio::time::sleep(SETTLE).await; + for _ in 0..YIELD_ROUNDS { + tokio::task::yield_now().await; + } +} +/// Creating and removing N sessions must not grow the heap. +/// +/// Only one `dhat::Profiler` can exist at a time, and the test harness runs +/// tests in parallel, so keep this the only test that creates one. +#[test] +fn leader_session_lifecycle_heap_steady_state() { + run_local_for_bridge_test(|| async { + let agent = build_minimal_agent_for_tests(); + *agent.workspace_ops.borrow_mut() = Some(xai_grok_workspace::WorkspaceOps::for_test()); + let _profiler = dhat::Profiler::builder().testing().build(); + const WARMUP: usize = 16; + const MEASURE: usize = 256; + for i in 0..WARMUP { + populate_and_evict(&agent, i); + } + quiesce().await; + let before = dhat::HeapStats::get(); + for i in WARMUP..(WARMUP + MEASURE) { + populate_and_evict(&agent, i); + } + quiesce().await; + let after = dhat::HeapStats::get(); + let d_blocks = after.curr_blocks as i64 - before.curr_blocks as i64; + let d_bytes = after.curr_bytes as i64 - before.curr_bytes as i64; + let blocks_per = d_blocks as f64 / MEASURE as f64; + let bytes_per = d_bytes as f64 / MEASURE as f64; + eprintln!( + "DHAT_SOAK_SUMMARY {}", + serde_json::json!({ "warmup_sessions" : WARMUP, + "measured_sessions" : MEASURE, "before_blocks" : before.curr_blocks, + "before_bytes" : before.curr_bytes, "after_blocks" : after.curr_blocks, + "after_bytes" : after.curr_bytes, "blocks_per_session" : blocks_per, + "bytes_per_session" : bytes_per, "max_blocks_per_session" : + MAX_BLOCKS_PER_SESSION, "max_bytes_per_session" : MAX_BYTES_PER_SESSION, + "pass" : blocks_per < MAX_BLOCKS_PER_SESSION && bytes_per < + MAX_BYTES_PER_SESSION }) + ); + assert!( + blocks_per < MAX_BLOCKS_PER_SESSION, + "block-count leak: {blocks_per:.3} blocks/session retained ({d_blocks} over {MEASURE} cycles) exceeds the {MAX_BLOCKS_PER_SESSION} gate" + ); + assert!( + bytes_per < MAX_BYTES_PER_SESSION, + "byte leak: {bytes_per:.1} bytes/session retained ({d_bytes} over {MEASURE} cycles) exceeds the {MAX_BYTES_PER_SESSION} gate" + ); + }); +} diff --git a/crates/codegen/xai-grok-shell/src/agent/subagent/coordinator_query.rs b/crates/codegen/xai-grok-shell/src/agent/subagent/coordinator_query.rs index a9ed648..d12fde5 100644 --- a/crates/codegen/xai-grok-shell/src/agent/subagent/coordinator_query.rs +++ b/crates/codegen/xai-grok-shell/src/agent/subagent/coordinator_query.rs @@ -302,6 +302,10 @@ impl SubagentCoordinator { will_wake: false, }) } + /// Lifecycle-map entry counts as `(pending, active, completed)`. + pub(crate) fn registry_snapshot(&self) -> (usize, usize, usize) { + (self.pending.len(), self.active.len(), self.completed.len()) + } /// Oldest completions are evicted first; their `output.json` stays on disk. pub fn enforce_completed_cap(&mut self) { if self.completed.len() <= MAX_COMPLETED_ENTRIES { diff --git a/crates/codegen/xai-grok-shell/src/agent/subagent/handle_request.rs b/crates/codegen/xai-grok-shell/src/agent/subagent/handle_request.rs index 39fbccd..b47aa73 100644 --- a/crates/codegen/xai-grok-shell/src/agent/subagent/handle_request.rs +++ b/crates/codegen/xai-grok-shell/src/agent/subagent/handle_request.rs @@ -1081,6 +1081,7 @@ pub(crate) async fn handle_subagent_request( xai_grok_agent::DEFAULT_SYSTEM_PROMPT_LABEL.to_string(), xai_chat_state::CompactionMode::Summary, ctx.resolve_compaction_verbatim_input(), + ctx.resolve_compaction_tool_choice(), false, None, None, @@ -1156,7 +1157,7 @@ pub(crate) async fn handle_subagent_request( ctx.client_hooks.clone(), None, std::collections::HashMap::new(), - ctx.persona_io_summaries.clone(), + Vec::new(), xai_grok_agent::prompt::context::PromptAudience::Subagent, effective_runtime.role_prompt.clone(), None, @@ -1304,6 +1305,7 @@ pub(crate) async fn handle_subagent_request( traceparent: xai_file_utils::trace_context::current_traceparent(), json_schema: None, send_now: false, + admission: None, respond_to: prompt_tx, persist_ack: None, parsed_prompt_tx: None, @@ -2005,7 +2007,7 @@ pub(crate) async fn handle_subagent_request( &request.id, &result, &request, - &ctx.auto_wake_delivered, + &ctx.task_completion_reservations, ctx.parent_cmd_tx.as_ref(), &ctx.task_output_tool_name, &ctx.synthetic_trace_tx, diff --git a/crates/codegen/xai-grok-shell/src/agent/subagent/mod.rs b/crates/codegen/xai-grok-shell/src/agent/subagent/mod.rs index 62f2695..5585ad0 100644 --- a/crates/codegen/xai-grok-shell/src/agent/subagent/mod.rs +++ b/crates/codegen/xai-grok-shell/src/agent/subagent/mod.rs @@ -243,9 +243,6 @@ pub(crate) struct SubagentSpawnContext { /// Subagent personas config for persona/SOUL layering. pub subagent_personas: std::collections::HashMap, - /// Pre-rendered persona IO summaries for the task tool description. - /// Threaded through to child sessions for recursive persona discovery. - pub persona_io_summaries: Vec, /// Parent session's ChatStateHandle — used to read the actual live /// sampling config and credentials from the parent session actor (async). /// Cheap Clone (mpsc sender). `None` when parent SessionHandle not found. @@ -348,9 +345,9 @@ pub(crate) struct SubagentSpawnContext { /// Parent's resolved vendor-compat config, inherited by the child so its /// skills / rules / AGENTS.md discovery honors the same vendor toggles. pub parent_compat: xai_grok_tools::types::compat::CompatConfig, - /// Shared set of IDs delivered via auto-wake synthetic prompts. - pub auto_wake_delivered: - Option, + /// Shared completion reservations held by auto-wake prompts. + pub task_completion_reservations: + Option, /// Channel for requesting trace uploads for synthetic auto-wake turns. pub synthetic_trace_tx: Option>, @@ -422,6 +419,18 @@ impl SubagentSpawnContext { .resolve() .value } + pub fn resolve_compaction_tool_choice(&self) -> crate::util::config::CompactionToolChoice { + crate::util::config::resolve_compaction_tool_choice_from( + crate::agent::config::env_string(crate::util::config::ENV_COMPACTION_TOOL_CHOICE) + .as_deref(), + self.agent_config + .as_ref() + .and_then(|c| c.features.compaction_tool_choice.as_deref()), + self.remote_settings + .as_ref() + .and_then(|r| r.compaction_tool_choice.as_deref()), + ) + } /// Whether a completed subagent's worktree is snapshotted into a durable ref /// and its directory deleted. Resolution mirrors the other subagent gates /// (env > config > remote settings > default). Default `false` so it ships dark; @@ -1955,7 +1964,7 @@ fn cancellation_error_message( /// result has not already been consumed (via block-wait or explicit kill). /// Also suppressed while the parent's goal loop is active (mirrors the bash /// gate in `notification_bridge`); skipping the inject also skips the -/// `auto_wake_delivered.insert`, leaving surfaces 2/3 free to drain it. +/// the completion reservation, leaving surfaces 2/3 free to drain it. /// `parent_channel_open` folds `inject_subagent_completed_prompt`'s own /// no-channel bail into the decision, so the `will_wake` stamped on the /// completion notification can never promise a wake the inject won't do. @@ -1992,7 +2001,9 @@ fn inject_subagent_completed_prompt( subagent_id: &str, result: &SubagentResult, request: &SubagentRequest, - auto_wake_delivered: &Option, + task_completion_reservations: &Option< + xai_grok_tools::reminders::task_completion::TaskCompletionReservations, + >, parent_cmd_tx: Option<&mpsc::UnboundedSender>, task_output_tool_name: &str, synthetic_trace_tx: &Option< @@ -2002,8 +2013,8 @@ fn inject_subagent_completed_prompt( let Some(cmd_tx) = parent_cmd_tx else { return; }; - if let Some(auto_wake) = auto_wake_delivered { - auto_wake.insert(subagent_id.to_string()); + if let Some(reservations) = task_completion_reservations { + reservations.reserve(subagent_id.to_string()); } let summary = SubagentCompletionSummary { subagent_id: subagent_id.to_string(), @@ -2032,21 +2043,30 @@ fn inject_subagent_completed_prompt( }; let (respond_to, completion_rx) = tokio::sync::oneshot::channel(); let prompt_blocks = vec![acp::ContentBlock::Text(acp::TextContent::new(wrapped))]; - let _ = cmd_tx.send(SessionCommand::Prompt { - prompt_id: prompt_id.clone(), - prompt_blocks, - prompt_mode: crate::session::plan_mode::PromptMode::Agent, - artifact_upload_ctx: None, - client_identifier: None, - screen_mode: None, - verbatim: true, - traceparent: None, - json_schema: None, - send_now: false, - respond_to, - persist_ack: None, - parsed_prompt_tx: None, - }); + if cmd_tx + .send(SessionCommand::Prompt { + prompt_id: prompt_id.clone(), + prompt_blocks, + prompt_mode: crate::session::plan_mode::PromptMode::Agent, + artifact_upload_ctx: None, + client_identifier: None, + screen_mode: None, + verbatim: true, + traceparent: None, + json_schema: None, + send_now: false, + admission: None, + respond_to, + persist_ack: None, + parsed_prompt_tx: None, + }) + .is_err() + { + if let Some(reservations) = task_completion_reservations { + reservations.release(subagent_id); + } + return; + } if let Some(trace_tx) = synthetic_trace_tx { let _ = trace_tx.send(crate::upload::turn::SyntheticTurnTraceRequest { session_id: acp::SessionId::new(request.parent_session_id.clone()), diff --git a/crates/codegen/xai-grok-shell/src/agent/subagent/tests/mod.rs b/crates/codegen/xai-grok-shell/src/agent/subagent/tests/mod.rs index 6d19a64..2027d63 100644 --- a/crates/codegen/xai-grok-shell/src/agent/subagent/tests/mod.rs +++ b/crates/codegen/xai-grok-shell/src/agent/subagent/tests/mod.rs @@ -556,7 +556,7 @@ fn should_auto_wake_subagent_suppressed_by_block_waited_or_killed() { } /// A goal loop active in the parent suppresses the subagent /// auto-wake synthetic prompt — the structural sibling of the bash gate. -/// Skipping the inject here also skips `auto_wake_delivered.insert`, so the +/// Skipping the inject here also skips its completion reservation, so the /// per-tool-call / between-turn surfaces stay free to drain the completion. #[test] fn should_auto_wake_subagent_suppressed_by_goal_loop() { @@ -587,7 +587,7 @@ fn auto_wake_test_request(id: &str) -> SubagentRequest { } /// Behavior-level: the action half of the subagent auto-wake. /// When the gate lets it run, `inject_subagent_completed_prompt` sends the -/// synthetic `Prompt` to the parent AND marks the id auto-wake-delivered. +/// synthetic `Prompt` to the parent and reserves its completion ID. /// Paired with `should_auto_wake_subagent_suppressed_by_goal_loop`, this /// proves the full Gap-1 contract on the subagent surface: goal active → /// gate false → this never runs (no prompt, not marked, so surfaces 2/3 @@ -595,7 +595,7 @@ fn auto_wake_test_request(id: &str) -> SubagentRequest { #[test] fn inject_subagent_completed_prompt_sends_prompt_and_marks_delivered() { let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::(); - let auto_wake = xai_grok_tools::reminders::task_completion::AutoWakeDeliveredIds::default(); + let reservations = xai_grok_tools::reminders::task_completion::TaskCompletionReservations::default(); let request = auto_wake_test_request("sa-1"); let result = SubagentResult { success: true, @@ -607,7 +607,7 @@ fn inject_subagent_completed_prompt_sends_prompt_and_marks_delivered() { "sa-1", &result, &request, - &Some(auto_wake.clone()), + &Some(reservations.clone()), Some(&cmd_tx), "get_command_or_subagent_output", &None, @@ -619,7 +619,36 @@ fn inject_subagent_completed_prompt_sends_prompt_and_marks_delivered() { } _ => panic!("expected SessionCommand::Prompt"), } - assert_eq!(auto_wake.snapshot(), vec!["sa-1".to_string()]); + assert_eq!(reservations.snapshot(), vec!["sa-1".to_string()]); +} +#[test] +fn inject_subagent_completed_prompt_releases_reservation_when_parent_closed() { + let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::(); + drop(cmd_rx); + let reservations = xai_grok_tools::reminders::task_completion::TaskCompletionReservations::default(); + reservations.reserve("sa-closed".into()); + let (trace_tx, mut trace_rx) = mpsc::unbounded_channel(); + inject_subagent_completed_prompt( + "sa-closed", + &SubagentResult { + success: true, + subagent_id: "sa-closed".into(), + child_session_id: "sa-closed".into(), + ..Default::default() + }, + &auto_wake_test_request("sa-closed"), + &Some(reservations.clone()), + Some(&cmd_tx), + "get_command_or_subagent_output", + &Some(trace_tx), + ); + assert!( + reservations.contains("sa-closed"), + "send failure must release only the reservation acquired by this attempt" + ); + reservations.release("sa-closed"); + assert!(! reservations.contains("sa-closed")); + assert!(trace_rx.try_recv().is_err()); } #[test] fn mark_explicitly_killed_sets_flag_on_completed() { diff --git a/crates/codegen/xai-grok-shell/src/auth/device_code.rs b/crates/codegen/xai-grok-shell/src/auth/device_code.rs index 88876c1..8d21e3a 100644 --- a/crates/codegen/xai-grok-shell/src/auth/device_code.rs +++ b/crates/codegen/xai-grok-shell/src/auth/device_code.rs @@ -482,7 +482,7 @@ async fn build_auth( team_role: None, user_blocked_reason: None, team_blocked_reasons: vec![], - coding_data_retention_opt_out: false, + coding_data_retention_opt_out: crate::auth::default_coding_data_retention_opt_out(), has_grok_code_access: None, refresh_token: tokens.refresh_token.clone(), expires_at: tokens.expires_in.map(|s| now + Duration::seconds(s)), diff --git a/crates/codegen/xai-grok-shell/src/auth/external_auth.rs b/crates/codegen/xai-grok-shell/src/auth/external_auth.rs index 275fc7f..f7d1704 100644 --- a/crates/codegen/xai-grok-shell/src/auth/external_auth.rs +++ b/crates/codegen/xai-grok-shell/src/auth/external_auth.rs @@ -72,7 +72,7 @@ pub(crate) fn parse_output(output: &std::process::Output) -> anyhow::Result, @@ -26,7 +26,8 @@ pub struct AuthMeta { pub is_zdr: bool, #[serde(default)] pub team_role: Option, - #[serde(default)] + /// Defaults to opted-out (safer) until auth meta is populated. + #[serde(default = "crate::auth::default_coding_data_retention_opt_out")] pub coding_data_retention_opt_out: bool, #[serde(default)] pub show_resolved_model: Option, @@ -38,3 +39,20 @@ pub struct AuthMeta { #[serde(default)] pub subscription_tier: Option, } + +impl Default for AuthMeta { + fn default() -> Self { + Self { + email: None, + auth_mode: None, + team_id: None, + team_name: None, + is_zdr: false, + team_role: None, + coding_data_retention_opt_out: crate::auth::default_coding_data_retention_opt_out(), + show_resolved_model: None, + gate: None, + subscription_tier: None, + } + } +} diff --git a/crates/codegen/xai-grok-shell/src/auth/mod.rs b/crates/codegen/xai-grok-shell/src/auth/mod.rs index 5d8e262..16be304 100644 --- a/crates/codegen/xai-grok-shell/src/auth/mod.rs +++ b/crates/codegen/xai-grok-shell/src/auth/mod.rs @@ -37,7 +37,9 @@ pub use error::{AuthError, RefreshTokenError, RefreshTokenFailedReason}; pub use manager::{AuthManager, shared_api_key_provider}; pub use meta::{AuthMeta, GateInfo}; pub use model::{AuthMode, GrokAuth, lookup_auth}; -pub(crate) use model::{TOKEN_TTL, UserInfo, is_expired, token_suffix}; +pub(crate) use model::{ + TOKEN_TTL, UserInfo, default_coding_data_retention_opt_out, is_expired, token_suffix, +}; pub(crate) use refresh::DiagnosticUploader; pub use storage::{ clear_api_key, read_api_key, read_auth_json, read_token_by_scope, store_api_key, diff --git a/crates/codegen/xai-grok-shell/src/auth/model.rs b/crates/codegen/xai-grok-shell/src/auth/model.rs index 0e4b3d6..9f84bf4 100644 --- a/crates/codegen/xai-grok-shell/src/auth/model.rs +++ b/crates/codegen/xai-grok-shell/src/auth/model.rs @@ -16,6 +16,13 @@ pub const API_KEY_SCOPE: &str = "xai::api_key"; const BLOCKED_REASON_NO_LOGS: &str = "BLOCKED_REASON_NO_LOGS"; const BLOCKED_REASON_NO_LOGS_MODERATED: &str = "BLOCKED_REASON_NO_LOGS_MODERATED"; +/// Fresh-credential / missing-field default: opted out until the user or +/// server enrichment opts in. Single source for `GrokAuth`, `AuthMeta`, and +/// every login-path constructor so the sides cannot drift. +pub(crate) fn default_coding_data_retention_opt_out() -> bool { + true +} + /// Token provenance (debugging/auth.json only -- no code branches on this). #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] @@ -69,7 +76,9 @@ pub struct GrokAuth { pub user_blocked_reason: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub team_blocked_reasons: Vec, - #[serde(default)] + /// Defaults to `true` (opted out) for safer consumer privacy until the + /// user explicitly shares or server enrichment sets the team preference. + #[serde(default = "default_coding_data_retention_opt_out")] pub coding_data_retention_opt_out: bool, /// Deprecated. Kept for deserializing existing auth.json files. @@ -218,7 +227,7 @@ impl Default for GrokAuth { organization_role: None, user_blocked_reason: None, team_blocked_reasons: vec![], - coding_data_retention_opt_out: false, + coding_data_retention_opt_out: default_coding_data_retention_opt_out(), has_grok_code_access: None, refresh_token: None, expires_at: None, @@ -239,6 +248,9 @@ impl GrokAuth { Self { key: "test-key".into(), user_id: "test-user".into(), + // Tests that exercise collection gates need sharing enabled by + // default; opt out explicitly when asserting the privacy path. + coding_data_retention_opt_out: false, ..Default::default() } } @@ -486,4 +498,23 @@ mod tests { let info: UserInfo = serde_json::from_str(json).unwrap(); assert_eq!(info.subscription_tier.as_deref(), Some("")); } + + /// Pre-default auth.json (no coding_data_retention_opt_out key) must + /// deserialize as opted-out, not the old fail-open false. + #[test] + fn missing_coding_data_retention_opt_out_deserializes_opted_out() { + let json = r#"{ + "key": "k", + "auth_mode": "oidc", + "create_time": "2020-01-01T00:00:00Z", + "user_id": "u" + }"#; + let auth: GrokAuth = serde_json::from_str(json).unwrap(); + assert!( + auth.coding_data_retention_opt_out, + "missing field must default to opted-out" + ); + assert!(default_coding_data_retention_opt_out()); + assert!(GrokAuth::default().coding_data_retention_opt_out); + } } diff --git a/crates/codegen/xai-grok-shell/src/auth/oidc/protocol.rs b/crates/codegen/xai-grok-shell/src/auth/oidc/protocol.rs index bb3de1f..1d18737 100644 --- a/crates/codegen/xai-grok-shell/src/auth/oidc/protocol.rs +++ b/crates/codegen/xai-grok-shell/src/auth/oidc/protocol.rs @@ -689,7 +689,7 @@ pub(super) async fn validate_and_extract_user_info( organization_role: None, user_blocked_reason: None, team_blocked_reasons: vec![], - coding_data_retention_opt_out: false, + coding_data_retention_opt_out: crate::auth::default_coding_data_retention_opt_out(), }) } pub(super) async fn extract_user_info( @@ -720,7 +720,7 @@ pub(super) async fn extract_user_info( organization_role: None, user_blocked_reason: None, team_blocked_reasons: vec![], - coding_data_retention_opt_out: false, + coding_data_retention_opt_out: crate::auth::default_coding_data_retention_opt_out(), }); } let token = id_token.ok_or_else(|| anyhow::Error::new(OidcError::MissingIdToken))?; diff --git a/crates/codegen/xai-grok-shell/src/auth/refresh/external_refresher.rs b/crates/codegen/xai-grok-shell/src/auth/refresh/external_refresher.rs index 4457845..ade01d8 100644 --- a/crates/codegen/xai-grok-shell/src/auth/refresh/external_refresher.rs +++ b/crates/codegen/xai-grok-shell/src/auth/refresh/external_refresher.rs @@ -61,6 +61,9 @@ impl TokenRefresher for ExternalBinaryRefresher { .await { Err(_elapsed) => { + // Transient: a hard-expired access token after idle must still + // allow 401 / pre-flight retry. Mapping timeout to permanent + // failure poisoned recovery for PERMANENT_FAILURE_TTL. tracing::warn!( timeout_ms, "auth: external binary refresh timed out (thread leaked)" @@ -70,7 +73,7 @@ impl TokenRefresher for ExternalBinaryRefresher { None, Some(serde_json::json!({ "timeout_ms": timeout_ms })), ); - self.record_failure(format!("external binary timed out after {timeout_ms}ms")) + RefreshOutcome::transient(format!("external binary timed out after {timeout_ms}ms")) } Ok(Ok(Some(auth))) => { crate::unified_log::info("auth: external binary refresh succeeded", None, None); @@ -131,10 +134,8 @@ mod tests { } } - /// A binary that outlives the (test-shortened) timeout hits the `Elapsed` - /// arm and maps to the same non-sticky `Other` permanent failure. #[tokio::test] - async fn external_binary_timeout_is_non_sticky_permanent() { + async fn external_binary_timeout_is_transient() { struct SlowRunner; impl ExternalCommandRunner for SlowRunner { fn run_external_command(&self, _command: &str) -> Option { @@ -145,14 +146,13 @@ mod tests { let refresher = ExternalBinaryRefresher::new(Arc::new(SlowRunner), "auth-binary".into()) .with_timeout(std::time::Duration::from_millis(5)); match refresher.refresh(RefreshReason::ServerRejected).await { - RefreshOutcome::PermanentFailure { error, .. } => { - assert_eq!(error.reason, RefreshTokenFailedReason::Other); + RefreshOutcome::TransientFailure { message } => { assert!( - !error.reason.is_sticky(), - "timeout must age out, not strand" + message.contains("timed out"), + "timeout message must be greppable, got {message}" ); } - other => panic!("a timed-out binary must be a permanent Other failure, got {other:?}"), + other => panic!("a timed-out binary must be TransientFailure, got {other:?}"), } } diff --git a/crates/codegen/xai-grok-shell/src/bundle.rs b/crates/codegen/xai-grok-shell/src/bundle.rs index fb91ec8..0ec2638 100644 --- a/crates/codegen/xai-grok-shell/src/bundle.rs +++ b/crates/codegen/xai-grok-shell/src/bundle.rs @@ -84,10 +84,7 @@ struct BundleFile<'a> { } pub fn bundled_root() -> PathBuf { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".grok") - .join(BUNDLED_DIR_NAME) + xai_grok_config::grok_home().join(BUNDLED_DIR_NAME) } pub fn read_cached_manifest(root: &Path) -> Result> { diff --git a/crates/codegen/xai-grok-shell/src/config/mod.rs b/crates/codegen/xai-grok-shell/src/config/mod.rs index ac7dd18..b6ec42a 100644 --- a/crates/codegen/xai-grok-shell/src/config/mod.rs +++ b/crates/codegen/xai-grok-shell/src/config/mod.rs @@ -444,9 +444,23 @@ impl SubagentsConfig { /// intent (CLI flag, `GROK_SUBAGENTS`, `[subagents] enabled`) changes /// the default. /// - /// When `cwd` is provided, file-based roles are discovered from - /// `{cwd}/.grok/roles/*.toml` and merged (inline config takes precedence). - pub fn resolve(cli_flag: bool, config: &toml::Value, cwd: Option<&std::path::Path>) -> Self { + /// Project files are excluded from this trust-independent base; Task + /// boundaries overlay them using the parent cwd's authoritative trust verdict. + pub fn resolve(cli_flag: bool, config: &toml::Value) -> Self { + let user_grok_root = xai_grok_config::user_grok_home(); + Self::resolve_base_with_sources( + cli_flag, + config, + user_grok_root.as_deref(), + &bundle::bundled_root(), + ) + } + pub(crate) fn resolve_base_with_sources( + cli_flag: bool, + config: &toml::Value, + user_grok_root: Option<&std::path::Path>, + bundled_root: &std::path::Path, + ) -> Self { let mut result: Self = config .get("subagents") .and_then(|v| v.clone().try_into().ok()) @@ -460,19 +474,40 @@ impl SubagentsConfig { true, ); result.enabled = resolved.value; - if let Some(cwd) = cwd { - result.discover_roles(cwd); - result.discover_personas(cwd); + if let Some(root) = user_grok_root { + result.discover_roles_in_dir(&root.join("roles")); + result.discover_personas_in_dir(&root.join("personas")); } - if let Some(home) = dirs::home_dir() { - result.discover_roles(&home); - result.discover_personas(&home); - } - let bundled_root = bundle::bundled_root(); result.discover_roles_in_dir(&bundled_root.join("roles")); result.discover_personas_in_dir(&bundled_root.join("personas")); result } + pub(crate) fn effective_definition_maps( + roles: &std::collections::HashMap, + personas: &std::collections::HashMap, + cwd: &std::path::Path, + project_trusted: bool, + ) -> ( + std::collections::HashMap, + std::collections::HashMap, + ) { + let mut project = Self::default(); + if project_trusted { + project.discover_roles(cwd); + project.discover_personas(cwd); + } + for (name, role) in roles { + if role.source_dir.is_none() || !project.roles.contains_key(name) { + project.roles.insert(name.clone(), role.clone()); + } + } + for (name, persona) in personas { + if persona.source_path.is_none() || !project.personas.contains_key(name) { + project.personas.insert(name.clone(), persona.clone()); + } + } + (project.roles, project.personas) + } } /// Managed MCP connector fetching config (`[managed_mcps]` in config.toml). /// diff --git a/crates/codegen/xai-grok-shell/src/config/tests.rs b/crates/codegen/xai-grok-shell/src/config/tests.rs index ae7b443..2da2071 100644 --- a/crates/codegen/xai-grok-shell/src/config/tests.rs +++ b/crates/codegen/xai-grok-shell/src/config/tests.rs @@ -975,7 +975,7 @@ fn with_grok_subagents(value: &str, f: impl FnOnce() -> T) -> T { fn subagents_config_default_enabled() { without_grok_subagents(|| { let config = toml::Value::Table(toml::map::Map::new()); - let sa = SubagentsConfig::resolve(false, &config, None); + let sa = SubagentsConfig::resolve(false, &config); assert!(sa.enabled); }); } @@ -983,7 +983,7 @@ fn subagents_config_default_enabled() { fn subagents_config_cli_flag_enables() { without_grok_subagents(|| { let config = toml::Value::Table(toml::map::Map::new()); - let sa = SubagentsConfig::resolve(true, &config, None); + let sa = SubagentsConfig::resolve(true, &config); assert!(sa.enabled); }); } @@ -993,7 +993,7 @@ fn subagents_config_env_var_enables() { "1", || { let config = toml::Value::Table(toml::map::Map::new()); - let sa = SubagentsConfig::resolve(false, &config, None); + let sa = SubagentsConfig::resolve(false, &config); assert!(sa.enabled); }, ); @@ -1005,7 +1005,7 @@ fn subagents_config_env_var_disables() { || { let config: toml::Value = toml::from_str("[subagents]\nenabled = true") .unwrap(); - let sa = SubagentsConfig::resolve(false, &config, None); + let sa = SubagentsConfig::resolve(false, &config); assert!(! sa.enabled, "GROK_SUBAGENTS=0 should override config file"); }, ); @@ -1014,7 +1014,7 @@ fn subagents_config_env_var_disables() { fn subagents_config_toml_enables() { without_grok_subagents(|| { let config: toml::Value = toml::from_str("[subagents]\nenabled = true").unwrap(); - let sa = SubagentsConfig::resolve(false, &config, None); + let sa = SubagentsConfig::resolve(false, &config); assert!(sa.enabled); }); } @@ -1023,7 +1023,7 @@ fn subagents_config_local_disabled_wins() { without_grok_subagents(|| { let config: toml::Value = toml::from_str("[subagents]\nenabled = false") .unwrap(); - let sa = SubagentsConfig::resolve(false, &config, None); + let sa = SubagentsConfig::resolve(false, &config); assert!(! sa.enabled, "local [subagents] enabled=false should win"); }); } @@ -1033,7 +1033,7 @@ fn subagents_config_env_var_disables_default() { "0", || { let config = toml::Value::Table(toml::map::Map::new()); - let sa = SubagentsConfig::resolve(false, &config, None); + let sa = SubagentsConfig::resolve(false, &config); assert!( ! sa.enabled, "GROK_SUBAGENTS=0 should override the enabled default" ); @@ -1050,7 +1050,7 @@ fn subagents_config_remote_settings_key_is_ignored() { ) .expect("unknown subagents_enabled key must not break parsing"); let config = toml::Value::Table(toml::map::Map::new()); - let sa = SubagentsConfig::resolve(false, &config, None); + let sa = SubagentsConfig::resolve(false, &config); assert!(sa.enabled); }); } @@ -1060,7 +1060,7 @@ fn subagents_config_cli_flag_overrides_env_var() { "0", || { let config = toml::Value::Table(toml::map::Map::new()); - let sa = SubagentsConfig::resolve(true, &config, None); + let sa = SubagentsConfig::resolve(true, &config); assert!(sa.enabled, "--subagents CLI flag should override GROK_SUBAGENTS=0"); }, ); @@ -1079,7 +1079,7 @@ fn subagents_config_models_parsed() { "#, ) .unwrap(); - let sa = SubagentsConfig::resolve(false, &config, None); + let sa = SubagentsConfig::resolve(false, &config); assert!(sa.enabled); assert_eq!(sa.models.len(), 2); assert_eq!(sa.models.get("explore").unwrap(), "grok-3-fast"); @@ -1090,7 +1090,7 @@ fn subagents_config_models_parsed() { fn subagents_config_models_empty_when_missing() { without_grok_subagents(|| { let config: toml::Value = toml::from_str("[subagents]\nenabled = true").unwrap(); - let sa = SubagentsConfig::resolve(false, &config, None); + let sa = SubagentsConfig::resolve(false, &config); assert!(sa.enabled); assert!(sa.models.is_empty()); }); @@ -1105,7 +1105,7 @@ fn subagents_config_models_without_enabled() { "#, ) .unwrap(); - let sa = SubagentsConfig::resolve(false, &config, None); + let sa = SubagentsConfig::resolve(false, &config); assert!( ! sa.enabled, "explicit [subagents] section without enabled should be false" ); @@ -1125,7 +1125,7 @@ fn subagents_config_models_with_env_var_enables() { "#, ) .unwrap(); - let sa = SubagentsConfig::resolve(false, &config, None); + let sa = SubagentsConfig::resolve(false, &config); assert!(sa.enabled, "GROK_SUBAGENTS=1 should enable"); assert_eq!(sa.models.get("explore").unwrap(), "grok-3-fast"); }, @@ -1147,7 +1147,7 @@ fn subagents_config_toggle_mixed_values() { "#, ) .unwrap(); - let sa = SubagentsConfig::resolve(false, &config, None); + let sa = SubagentsConfig::resolve(false, &config); assert!(sa.enabled); assert_eq!(sa.toggle.len(), 4); assert_eq!(sa.toggle.get("explore").copied(), Some(true)); @@ -1160,7 +1160,7 @@ fn subagents_config_toggle_mixed_values() { fn subagents_config_toggle_missing_defaults_to_empty() { without_grok_subagents(|| { let config: toml::Value = toml::from_str("[subagents]\nenabled = true").unwrap(); - let sa = SubagentsConfig::resolve(false, &config, None); + let sa = SubagentsConfig::resolve(false, &config); assert!(sa.enabled); assert!( sa.toggle.is_empty(), @@ -2254,6 +2254,122 @@ fn discover_personas_inline_takes_precedence() { Some("Inline strict"), ); } +fn write_subagent_definitions(root: &std::path::Path, definitions: &[(&str, &str)]) { + let roles = root.join("roles"); + let personas = root.join("personas"); + std::fs::create_dir_all(&roles).unwrap(); + std::fs::create_dir_all(&personas).unwrap(); + for (name, source) in definitions { + std::fs::write( + roles.join(format!("{name}.toml")), + format!("description = \"{source} role\""), + ) + .unwrap(); + std::fs::write( + personas.join(format!("{name}.toml")), + format!("instructions = \"{source} persona\""), + ) + .unwrap(); + } +} +#[test] +fn project_overlay_preserves_source_precedence() { + let tmp = tempfile::TempDir::new().unwrap(); + let project = tmp.path().join("project"); + let home = tmp.path().join("home"); + let bundled = tmp.path().join("bundled"); + write_subagent_definitions( + &project.join(".grok"), + &[ + ("shadowed", "Project"), + ("bundled-shadowed", "Project"), + ("inline", "Project"), + ("project-only", "Project"), + ], + ); + write_subagent_definitions( + &home.join(".grok"), + &[("shadowed", "User"), ("user-only", "User")], + ); + write_subagent_definitions( + &bundled, + &[("bundled-shadowed", "Bundled"), ("bundled-only", "Bundled")], + ); + let config = toml::from_str::< + toml::Value, + >( + r#" + [subagents] + enabled = true + + [subagents.roles.inline] + description = "Inline role" + + [subagents.personas.inline] + instructions = "Inline persona" + "#, + ) + .unwrap(); + let base = SubagentsConfig::resolve_base_with_sources( + false, + &config, + Some(&home.join(".grok")), + &bundled, + ); + let resolve = |project_trusted| { + let (roles, personas) = SubagentsConfig::effective_definition_maps( + &base.roles, + &base.personas, + &project, + project_trusted, + ); + SubagentsConfig { + roles, + personas, + ..Default::default() + } + }; + let untrusted = resolve(false); + assert_eq!(untrusted.get_role("shadowed").unwrap().description, "User role"); + assert_eq!( + untrusted.get_persona("shadowed").and_then(| persona | persona.instructions + .as_deref()), Some("User persona") + ); + assert!(untrusted.get_role("project-only").is_none()); + assert!(untrusted.get_persona("project-only").is_none()); + assert!(untrusted.get_role("user-only").is_some()); + assert!(untrusted.get_persona("user-only").is_some()); + assert!(untrusted.get_role("bundled-only").is_some()); + assert!(untrusted.get_persona("bundled-only").is_some()); + assert_eq!( + untrusted.get_role("bundled-shadowed").unwrap().description, "Bundled role" + ); + assert_eq!( + untrusted.get_persona("bundled-shadowed").and_then(| persona | persona + .instructions.as_deref()), Some("Bundled persona") + ); + let trusted = resolve(true); + assert_eq!(trusted.get_role("shadowed").unwrap().description, "Project role"); + assert_eq!( + trusted.get_persona("shadowed").and_then(| persona | persona.instructions + .as_deref()), Some("Project persona") + ); + assert_eq!( + trusted.get_role("bundled-shadowed").unwrap().description, "Project role" + ); + assert_eq!( + trusted.get_persona("bundled-shadowed").and_then(| persona | persona.instructions + .as_deref()), Some("Project persona") + ); + assert_eq!(trusted.get_role("inline").unwrap().description, "Inline role"); + assert_eq!( + trusted.get_persona("inline").and_then(| persona | persona.instructions + .as_deref()), Some("Inline persona") + ); + let denied_again = resolve(false); + assert_eq!(denied_again.get_role("shadowed").unwrap().description, "User role"); + assert!(denied_again.get_role("project-only").is_none()); +} #[test] fn bundled_personas_and_roles_have_lowest_priority_in_resolve_order() { let tmp = tempfile::TempDir::new().unwrap(); @@ -2296,84 +2412,104 @@ fn bundled_personas_and_roles_have_lowest_priority_in_resolve_order() { r#"instructions = "Project persona""#, ) .unwrap(); - with_env_var( - "HOME", - home.to_str().unwrap(), - || { - let config = toml::from_str::< - toml::Value, - >( - r#" - [subagents] - enabled = true + let config = toml::from_str::< + toml::Value, + >( + r#" + [subagents] + enabled = true - [subagents.roles.reviewer] - description = "Inline reviewer" + [subagents.roles.reviewer] + description = "Inline reviewer" - [subagents.personas.reviewer] - instructions = "Inline persona" - "#, - ) - .unwrap(); - let resolved = SubagentsConfig::resolve(true, &config, Some(&workspace)); - assert_eq!( - resolved.get_role("reviewer").unwrap().description, "Inline reviewer" - ); - assert_eq!( - resolved.get_persona("reviewer").unwrap().instructions.as_deref(), - Some("Inline persona") - ); - }, + [subagents.personas.reviewer] + instructions = "Inline persona" + "#, + ) + .unwrap(); + let base = SubagentsConfig::resolve_base_with_sources( + true, + &config, + Some(&home.join(".grok")), + &bundled, + ); + let (roles, personas) = SubagentsConfig::effective_definition_maps( + &base.roles, + &base.personas, + &workspace, + true, + ); + let resolved = SubagentsConfig { + roles, + personas, + ..Default::default() + }; + assert_eq!(resolved.get_role("reviewer").unwrap().description, "Inline reviewer"); + assert_eq!( + resolved.get_persona("reviewer").unwrap().instructions.as_deref(), + Some("Inline persona") ); std::fs::remove_file(workspace.join(".grok/roles/reviewer.toml")).unwrap(); std::fs::remove_file(workspace.join(".grok/personas/reviewer.toml")).unwrap(); - with_env_var( - "HOME", - home.to_str().unwrap(), - || { - let config = toml::from_str::< - toml::Value, - >( - r#" - [subagents] - enabled = true - "#, - ) - .unwrap(); - let resolved = SubagentsConfig::resolve(true, &config, Some(&workspace)); - assert_eq!( - resolved.get_role("reviewer").unwrap().description, "User reviewer" - ); - assert_eq!( - resolved.get_persona("reviewer").unwrap().instructions.as_deref(), - Some("User persona") - ); - }, + let config = toml::from_str::< + toml::Value, + >(r#" + [subagents] + enabled = true + "#) + .unwrap(); + let base = SubagentsConfig::resolve_base_with_sources( + true, + &config, + Some(&home.join(".grok")), + &bundled, + ); + let (roles, personas) = SubagentsConfig::effective_definition_maps( + &base.roles, + &base.personas, + &workspace, + true, + ); + let resolved = SubagentsConfig { + roles, + personas, + ..Default::default() + }; + assert_eq!(resolved.get_role("reviewer").unwrap().description, "User reviewer"); + assert_eq!( + resolved.get_persona("reviewer").unwrap().instructions.as_deref(), + Some("User persona") ); std::fs::remove_file(home.join(".grok/roles/reviewer.toml")).unwrap(); std::fs::remove_file(home.join(".grok/personas/reviewer.toml")).unwrap(); - with_env_var( - "HOME", - home.to_str().unwrap(), - || { - let config = toml::from_str::< - toml::Value, - >( - r#" - [subagents] - enabled = true - "#, - ) - .unwrap(); - let resolved = SubagentsConfig::resolve(true, &config, Some(&workspace)); - assert_eq!( - resolved.get_role("reviewer").unwrap().description, "Bundled reviewer" - ); - assert_eq!( - resolved.get_persona("reviewer").unwrap().instructions.as_deref(), - Some("Bundled persona") - ); - }, + let config = toml::from_str::< + toml::Value, + >(r#" + [subagents] + enabled = true + "#) + .unwrap(); + let base = SubagentsConfig::resolve_base_with_sources( + true, + &config, + Some(&home.join(".grok")), + &bundled, + ); + let (roles, personas) = SubagentsConfig::effective_definition_maps( + &base.roles, + &base.personas, + &workspace, + true, + ); + let resolved = SubagentsConfig { + roles, + personas, + ..Default::default() + }; + assert_eq!(resolved.get_role("reviewer").unwrap().description, "Bundled reviewer"); + assert_eq!( + resolved.get_persona("reviewer").unwrap().instructions.as_deref(), + Some("Bundled persona") ); } #[test] @@ -2952,6 +3088,92 @@ fn managed_settings_does_not_override_user_yolo() { fn simulate_release_build() -> xai_grok_test_support::EnvGuard { xai_grok_test_support::EnvGuard::set(xai_grok_version::TEST_VERSION_ENV, "0.0.0-sim") } +#[test] +fn project_overlay_tracks_authoritative_trust_transitions() { + let source_root = tempfile::tempdir().unwrap(); + let repo = tempfile::tempdir().unwrap(); + git2::Repository::init(repo.path()).unwrap(); + write_subagent_definitions( + &repo.path().join(".grok"), + &[("shared", "Project"), ("project-only", "Project")], + ); + let mut base = SubagentsConfig::default(); + base.roles + .insert( + "shared".into(), + SubagentRole { + description: "User role".into(), + source_dir: Some(source_root.path().join("roles")), + ..Default::default() + }, + ); + base.personas + .insert( + "shared".into(), + SubagentPersona { + instructions: Some("User persona".into()), + source_path: Some( + source_root.path().join("personas/shared.toml").display().to_string(), + ), + ..Default::default() + }, + ); + let (untrusted_roles, _) = SubagentsConfig::effective_definition_maps( + &base.roles, + &base.personas, + repo.path(), + false, + ); + assert_eq!(untrusted_roles["shared"].description, "User role"); + assert!(! untrusted_roles.contains_key("project-only")); + let (trusted_roles, trusted_personas) = SubagentsConfig::effective_definition_maps( + &base.roles, + &base.personas, + repo.path(), + true, + ); + assert_eq!(trusted_roles["shared"].description, "Project role"); + assert!(trusted_personas.contains_key("project-only")); + let (revoked_roles, _) = SubagentsConfig::effective_definition_maps( + &base.roles, + &base.personas, + repo.path(), + false, + ); + assert_eq!(revoked_roles["shared"].description, "User role"); + assert!(! revoked_roles.contains_key("project-only")); +} +#[test] +fn base_resolver_without_project_cwd_keeps_project_files_out() { + let tmp = tempfile::tempdir().unwrap(); + write_subagent_definitions(&tmp.path().join(".grok"), &[("project", "Project")]); + let base = SubagentsConfig::resolve_base_with_sources( + false, + &toml::Value::Table(Default::default()), + None, + &tmp.path().join("bundled"), + ); + assert!(base.get_role("project").is_none()); + assert!(base.get_persona("project").is_none()); +} +#[test] +fn explicit_grok_root_is_the_only_user_source() { + let tmp = tempfile::tempdir().unwrap(); + let ambient = tmp.path().join("ambient-home/.grok"); + let configured = tmp.path().join("configured-grok-home"); + write_subagent_definitions(&ambient, &[("ambient", "Ambient")]); + write_subagent_definitions(&configured, &[("configured", "Configured")]); + let base = SubagentsConfig::resolve_base_with_sources( + false, + &toml::Value::Table(Default::default()), + Some(&configured), + &configured.join("bundled"), + ); + assert!(base.get_role("ambient").is_none()); + assert!(base.get_persona("ambient").is_none()); + assert!(base.get_role("configured").is_some()); + assert!(base.get_persona("configured").is_some()); +} /// SECURITY (plugin-RCE): a PROJECT-declared `[plugins].paths` loads as an /// auto-enabled, auto-trusted ConfigPath plugin, so it must merge into the /// effective config ONLY when the folder is trusted; project diff --git a/crates/codegen/xai-grok-shell/src/extensions/auth.rs b/crates/codegen/xai-grok-shell/src/extensions/auth.rs index 03bc648..6b72083 100644 --- a/crates/codegen/xai-grok-shell/src/extensions/auth.rs +++ b/crates/codegen/xai-grok-shell/src/extensions/auth.rs @@ -242,8 +242,11 @@ fn handle_info(agent: &MvpAgent) -> ExtResult { .as_ref() .map(|a| a.team_blocked_reasons.clone()) .unwrap_or_default(), + // No credential ⇒ unknown privacy state: report opted-out (fail closed), + // matching `AuthManager::allows_data_collection` / GrokAuth Default. coding_data_retention_opt_out: auth .as_ref() - .is_some_and(|a| a.coding_data_retention_opt_out), + .map(|a| a.coding_data_retention_opt_out) + .unwrap_or_else(crate::auth::default_coding_data_retention_opt_out), }) } diff --git a/crates/codegen/xai-grok-shell/src/extensions/bundle.rs b/crates/codegen/xai-grok-shell/src/extensions/bundle.rs index 1231e66..c55a4df 100644 --- a/crates/codegen/xai-grok-shell/src/extensions/bundle.rs +++ b/crates/codegen/xai-grok-shell/src/extensions/bundle.rs @@ -432,28 +432,6 @@ mod tests { use serial_test::serial; use std::sync::{Arc, Mutex}; use tempfile::TempDir; - struct HomeGuard { - previous: Option, - } - impl Drop for HomeGuard { - fn drop(&mut self) { - match self.previous.take() { - Some(previous) => unsafe { - std::env::set_var("HOME", previous); - }, - None => unsafe { - std::env::remove_var("HOME"); - }, - } - } - } - fn with_bundled_home(tmp: &TempDir) -> HomeGuard { - let previous = std::env::var_os("HOME"); - unsafe { - std::env::set_var("HOME", tmp.path()); - } - HomeGuard { previous } - } fn sample_bundle() -> SubagentBundle { let mut bundle = SubagentBundle::empty("bundle-v1"); bundle.personas.insert( @@ -588,8 +566,8 @@ mod tests { #[serial] fn status_reports_no_cache_when_manifest_missing() { let tmp = TempDir::new().unwrap(); - let _home = with_bundled_home(&tmp); - let status = status_bundle_at(&bundle::bundled_root()).unwrap(); + let root = tmp.path().join("bundled"); + let status = status_bundle_at(&root).unwrap(); assert_eq!( status, BundleStatusResult { @@ -608,15 +586,14 @@ mod tests { #[serial] fn status_reports_cached_entries_from_manifest_and_disk() { let tmp = TempDir::new().unwrap(); - let _home = with_bundled_home(&tmp); - let root = bundle::bundled_root(); + let root = tmp.path().join("bundled"); bundle::write_bundle_to_cache(&root, &sample_bundle()).unwrap(); std::fs::write( root.join("personas/local-only.toml"), "instructions = \"ignore\"", ) .unwrap(); - let status = status_bundle_at(&bundle::bundled_root()).unwrap(); + let status = status_bundle_at(&root).unwrap(); assert!(status.has_cache); assert_eq!(status.version.as_deref(), Some("bundle-v1")); assert_eq!(status.personas, vec!["researcher"]); @@ -628,14 +605,13 @@ mod tests { #[serial] async fn sync_success_writes_cache_and_returns_counts() { let tmp = TempDir::new().unwrap(); - let _home = with_bundled_home(&tmp); + let root = tmp.path().join("bundled"); let bundle = sample_bundle(); let (proxy_base_url, _seen_headers, server) = start_bundle_server( axum::http::StatusCode::OK, serde_json::to_value(&bundle).unwrap(), ) .await; - let root = bundle::bundled_root(); let am = test_auth_manager(); let result = sync_bundle_to_root(&root, &proxy_base_url, Some(&am), None, None, false) .await @@ -654,11 +630,10 @@ mod tests { #[serial] async fn sync_force_true_has_same_write_semantics() { let tmp = TempDir::new().unwrap(); - let _home = with_bundled_home(&tmp); + let root = tmp.path().join("bundled"); let bundle = sample_bundle(); let (proxy_base_url, _seen_headers, server) = start_bundle_server(StatusCode::OK, serde_json::to_value(&bundle).unwrap()).await; - let root = bundle::bundled_root(); let am = test_auth_manager(); let normal = sync_bundle_to_root(&root, &proxy_base_url, Some(&am), None, None, false) .await @@ -673,23 +648,16 @@ mod tests { #[serial] async fn sync_http_failure_surfaces_error() { let tmp = TempDir::new().unwrap(); - let _home = with_bundled_home(&tmp); + let root = tmp.path().join("bundled"); let (proxy_base_url, _seen_headers, server) = start_bundle_server( StatusCode::UNAUTHORIZED, serde_json::json!({ "error" : "unauthorized" }), ) .await; let am = test_auth_manager(); - let error = sync_bundle_to_root( - &bundle::bundled_root(), - &proxy_base_url, - Some(&am), - None, - None, - false, - ) - .await - .unwrap_err(); + let error = sync_bundle_to_root(&root, &proxy_base_url, Some(&am), None, None, false) + .await + .unwrap_err(); assert!(error.to_string().contains("401")); server.abort(); } @@ -697,13 +665,13 @@ mod tests { #[serial] async fn sync_uses_deployment_key_auth_mode() { let tmp = TempDir::new().unwrap(); - let _home = with_bundled_home(&tmp); + let root = tmp.path().join("bundled"); let bundle = sample_bundle(); let (proxy_base_url, seen_headers, server) = start_bundle_server(StatusCode::OK, serde_json::to_value(&bundle).unwrap()).await; let am = test_auth_manager(); let result = sync_bundle_to_root( - &bundle::bundled_root(), + &root, &proxy_base_url, Some(&am), Some("deploy-key"), @@ -726,8 +694,7 @@ mod tests { #[serial] fn status_only_reports_bundled_cache_not_higher_priority_sources() { let tmp = TempDir::new().unwrap(); - let _home = with_bundled_home(&tmp); - let root = bundle::bundled_root(); + let root = tmp.path().join("bundled"); bundle::write_bundle_to_cache(&root, &sample_bundle()).unwrap(); let project_root = tmp.path().join("workspace"); std::fs::create_dir_all(project_root.join(".grok/personas")).unwrap(); @@ -742,23 +709,26 @@ mod tests { "description = \"project role\"\n", ) .unwrap(); - let config = crate::config::SubagentsConfig::resolve( + let base = crate::config::SubagentsConfig::resolve_base_with_sources( false, &toml::Value::Table(Default::default()), - Some(&project_root), + None, + &root, + ); + let (roles, personas) = crate::config::SubagentsConfig::effective_definition_maps( + &base.roles, + &base.personas, + &project_root, + true, ); assert_eq!( - config - .personas + personas .get("researcher") .and_then(|persona| persona.instructions.as_deref()), Some("project persona") ); assert_eq!( - config - .roles - .get("reviewer") - .map(|role| role.description.as_str()), + roles.get("reviewer").map(|role| role.description.as_str()), Some("project role") ); let status = status_bundle_at(&root).unwrap(); @@ -771,9 +741,9 @@ mod tests { #[serial] fn sync_requires_auth_or_deployment_key() { let tmp = TempDir::new().unwrap(); - let _home = with_bundled_home(&tmp); + let root = tmp.path().join("bundled"); let error = futures::executor::block_on(sync_bundle_to_root( - &bundle::bundled_root(), + &root, "http://127.0.0.1:1/v1", None, None, @@ -790,8 +760,7 @@ mod tests { #[serial] fn get_entry_reads_persona_file() { let tmp = TempDir::new().unwrap(); - let _home = with_bundled_home(&tmp); - let root = bundle::bundled_root(); + let root = tmp.path().join("bundled"); bundle::write_bundle_to_cache(&root, &sample_bundle()).unwrap(); let result = get_entry_at(&root, "persona", "researcher").unwrap(); assert_eq!(result.kind, "persona"); @@ -802,8 +771,7 @@ mod tests { #[serial] fn get_entry_unknown_kind_returns_error() { let tmp = TempDir::new().unwrap(); - let _home = with_bundled_home(&tmp); - let root = bundle::bundled_root(); + let root = tmp.path().join("bundled"); let err = get_entry_at(&root, "widget", "foo").unwrap_err(); assert!(err.to_string().contains("unknown entry kind: widget")); } @@ -811,8 +779,7 @@ mod tests { #[serial] fn get_entry_missing_file_returns_error() { let tmp = TempDir::new().unwrap(); - let _home = with_bundled_home(&tmp); - let root = bundle::bundled_root(); + let root = tmp.path().join("bundled"); bundle::write_bundle_to_cache(&root, &sample_bundle()).unwrap(); let err = get_entry_at(&root, "persona", "nonexistent").unwrap_err(); assert!(err.to_string().contains("not found in bundle cache")); @@ -840,8 +807,7 @@ mod tests { #[serial] fn status_includes_persona_and_role_details() { let tmp = TempDir::new().unwrap(); - let _home = with_bundled_home(&tmp); - let root = bundle::bundled_root(); + let root = tmp.path().join("bundled"); bundle::write_bundle_to_cache(&root, &sample_bundle()).unwrap(); let status = status_bundle_at(&root).unwrap(); assert_eq!(status.persona_details.len(), 1); @@ -862,8 +828,7 @@ mod tests { #[serial] fn status_without_toml_files_returns_empty_details() { let tmp = TempDir::new().unwrap(); - let _home = with_bundled_home(&tmp); - let root = bundle::bundled_root(); + let root = tmp.path().join("bundled"); let mut bundle = SubagentBundle::empty("v1"); bundle .personas @@ -912,12 +877,12 @@ mod tests { #[serial] async fn sync_with_skills_reports_skills_count() { let tmp = TempDir::new().unwrap(); - let _home = with_bundled_home(&tmp); + let root = tmp.path().join("bundled"); let bundle = sample_bundle_with_skills(); let (proxy_base_url, _seen_headers, server) = start_bundle_server(StatusCode::OK, serde_json::to_value(&bundle).unwrap()).await; let result = sync_bundle_to_root( - &bundle::bundled_root(), + &root, &proxy_base_url, Some(&test_auth_manager()), None, @@ -936,8 +901,7 @@ mod tests { #[serial] fn status_lists_skill_names_from_manifest() { let tmp = TempDir::new().unwrap(); - let _home = with_bundled_home(&tmp); - let root = bundle::bundled_root(); + let root = tmp.path().join("bundled"); bundle::write_bundle_to_cache(&root, &sample_bundle_with_skills()).unwrap(); let status = status_bundle_at(&root).unwrap(); assert!(status.has_cache); @@ -948,8 +912,7 @@ mod tests { #[serial] fn status_skills_only_lists_files_present_on_disk() { let tmp = TempDir::new().unwrap(); - let _home = with_bundled_home(&tmp); - let root = bundle::bundled_root(); + let root = tmp.path().join("bundled"); bundle::write_bundle_to_cache(&root, &sample_bundle_with_skills()).unwrap(); std::fs::remove_file(root.join("skills/commit/SKILL.md")).unwrap(); let status = status_bundle_at(&root).unwrap(); @@ -981,7 +944,7 @@ mod tests { #[serial] async fn sync_with_archive_endpoint_extracts_and_reports_counts() { let tmp = TempDir::new().unwrap(); - let _home = with_bundled_home(&tmp); + let root = tmp.path().join("bundled"); let archive = make_test_archive(&[ ("bundle.json", br#"{"version":"archive-v1"}"#), ( @@ -993,7 +956,7 @@ mod tests { ]); let (proxy_base_url, server) = start_archive_bundle_server(archive).await; let result = sync_bundle_to_root( - &bundle::bundled_root(), + &root, &proxy_base_url, Some(&test_auth_manager()), None, @@ -1007,28 +970,20 @@ mod tests { assert_eq!(result.roles_count, 1); assert_eq!(result.agents_count, 0); assert_eq!(result.skills_count, 1); - assert!( - bundle::bundled_root() - .join("personas/researcher.toml") - .exists() - ); - assert!( - bundle::bundled_root() - .join("skills/commit/SKILL.md") - .exists() - ); + assert!(root.join("personas/researcher.toml").exists()); + assert!(root.join("skills/commit/SKILL.md").exists()); server.abort(); } #[tokio::test(flavor = "current_thread")] #[serial] async fn sync_falls_back_to_legacy_when_archive_unavailable() { let tmp = TempDir::new().unwrap(); - let _home = with_bundled_home(&tmp); + let root = tmp.path().join("bundled"); let bundle = sample_bundle_with_skills(); let (proxy_base_url, _seen_headers, server) = start_bundle_server(StatusCode::OK, serde_json::to_value(&bundle).unwrap()).await; let result = sync_bundle_to_root( - &bundle::bundled_root(), + &root, &proxy_base_url, Some(&test_auth_manager()), None, diff --git a/crates/codegen/xai-grok-shell/src/extensions/debug.rs b/crates/codegen/xai-grok-shell/src/extensions/debug.rs index 7f7c991..4196532 100644 --- a/crates/codegen/xai-grok-shell/src/extensions/debug.rs +++ b/crates/codegen/xai-grok-shell/src/extensions/debug.rs @@ -7,6 +7,7 @@ //! - `trigger_feedback`: fire a synthetic `FeedbackRequestNotification`. //! - `arm_auto_compact`: arm the next turn to unconditionally trigger //! auto-compaction, regardless of context window usage. +//! - `agent`: agent-process diagnostics (registry counts). use agent_client_protocol as acp; @@ -22,10 +23,17 @@ pub async fn handle(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult { handle_trigger_feedback(agent, args).await } "x.ai/debug/arm_auto_compact" => handle_arm_auto_compact(agent, args), + "x.ai/debug/agent" => handle_agent(agent), _ => Err(acp::Error::method_not_found()), } } +fn handle_agent(agent: &MvpAgent) -> ExtResult { + ExtMethodResult::success(serde_json::json!({ "registries": agent.registry_snapshot() })) + .to_ext_response() + .map_err(|e| acp::Error::internal_error().data(e.to_string())) +} + async fn handle_trigger_feedback(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult { use crate::session::feedback::{FeedbackMode, FeedbackTier}; diff --git a/crates/codegen/xai-grok-shell/src/extensions/marketplace.rs b/crates/codegen/xai-grok-shell/src/extensions/marketplace.rs index aad0cc0..9c1fed0 100644 --- a/crates/codegen/xai-grok-shell/src/extensions/marketplace.rs +++ b/crates/codegen/xai-grok-shell/src/extensions/marketplace.rs @@ -19,13 +19,13 @@ fn load_filtered_marketplace_sources() -> Vec ExtResult { match args.method.as_ref() { - "x.ai/marketplace/list" => handle_list(agent, args).await, + "x.ai/marketplace/list" => handle_list().await, "x.ai/marketplace/action" => handle_action(agent, args).await, _ => Err(acp::Error::method_not_found()), } } -async fn handle_list(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult { +async fn handle_list() -> ExtResult { let t0 = std::time::Instant::now(); let sources = load_filtered_marketplace_sources(); let source_names: Vec = sources @@ -94,15 +94,10 @@ async fn handle_list(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult { results.push(scan); } - // Auto-install default-skills entries that aren't already installed. - let t_auto = std::time::Instant::now(); - let session_id = super::parse_session_id(args); - auto_install_defaults(agent, &sources, &results, session_id.as_ref(), false).await; xai_grok_telemetry::unified_log::info( "marketplace handle_list: complete", None, Some(serde_json::json!({ - "auto_install_ms": t_auto.elapsed().as_millis() as u64, "total_ms": t0.elapsed().as_millis() as u64, })), ); @@ -146,10 +141,6 @@ async fn handle_action(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult { refreshed += 1; } - // Refresh default-skills from all sources. - let scan_results: Vec<_> = sources.iter().map(|source| scan_source(source).0).collect(); - auto_install_defaults(agent, &sources, &scan_results, Some(&sid), true).await; - let msg = if errors.is_empty() { format!("Refreshed {refreshed} source(s).") } else { @@ -635,115 +626,6 @@ async fn handle_uninstall( } } -/// Auto-install or refresh `default-skills` entries from marketplace sources. -/// -/// When `force_refresh` is false, only installs if not already present. -/// When `force_refresh` is true, removes and re-copies from source. -async fn auto_install_defaults( - agent: &MvpAgent, - sources: &[xai_grok_plugin_marketplace::MarketplaceSource], - results: &[MarketplaceScanResult], - session_id: Option<&acp::SessionId>, - force_refresh: bool, -) { - use xai_grok_plugin_marketplace::installer; - - let mut any_changed = false; - - for (source, scan) in sources.iter().zip(results.iter()) { - // Find the default-skills entry. - let default_entry = scan - .plugins - .iter() - .find(|p| p.relative_path == "default-skills"); - let Some(entry) = default_entry else { - continue; - }; - // Skip if no components. - if entry.skill_count == 0 && !entry.has_hooks && !entry.has_agents && !entry.has_mcp { - continue; - } - - // Resolve marketplace root. - let marketplace_lease; - let marketplace_root = match &source.kind { - xai_grok_plugin_marketplace::SourceKind::Local { path } => { - marketplace_lease = None; - path.clone() - } - xai_grok_plugin_marketplace::SourceKind::Git { url, branch } => { - let cache_root = xai_grok_plugin_marketplace::git::default_cache_root(); - match xai_grok_plugin_marketplace::git::sync_source_cache_with_mode( - url, - branch.as_deref(), - &cache_root, - if force_refresh { - xai_grok_plugin_marketplace::git::SyncMode::Force - } else { - xai_grok_plugin_marketplace::git::SyncMode::UseTtl - }, - ) { - Ok(lease) => { - let cached_path = lease.path.clone(); - marketplace_lease = Some(lease); - cached_path - } - Err(_) => continue, - } - } - }; - - // Check if already installed. - let mut reg = xai_grok_agent::plugins::install_registry::InstallRegistry::load(); - let existing = installer::find_installed_marketplace_plugin( - ®, - &scan.source_url_or_path, - "default-skills", - ); - if existing.is_some() && !force_refresh { - // Already installed and not forcing refresh — skip. - continue; - } - // Remove old copy if present (refresh or reinstall). - if let Some((existing_key, _)) = existing { - let old_dir = reg.install_dir().join(&existing_key); - let _ = std::fs::remove_dir_all(&old_dir); - reg.remove(&existing_key); - let _ = reg.save(); - reg = xai_grok_agent::plugins::install_registry::InstallRegistry::load(); - } - - let provenance = xai_grok_agent::plugins::install_registry::MarketplaceProvenance { - source_url_or_path: scan.source_url_or_path.clone(), - source_display_name: source.name.clone(), - plugin_subdir: "default-skills".to_string(), - }; - - let install_result = installer::install_from_marketplace( - &marketplace_root, - "default-skills", - provenance, - &mut reg, - ); - drop(marketplace_lease); - if let Ok(installer::MarketplaceInstallResult::Installed { repo_key }) = install_result { - tracing::info!( - source = %source.name, - repo_key = %repo_key, - "auto-installed/refreshed default-skills from marketplace" - ); - any_changed = true; - } - } - - // Trigger plugin reload if we auto-installed anything. - if any_changed && let Some(sid) = session_id { - let _ = agent - .execute_plugins_action(sid, xai_hooks_plugins_types::PluginsAction::Reload) - .await; - } -} - fn scan_source( source: &xai_grok_plugin_marketplace::MarketplaceSource, ) -> (MarketplaceScanResult, bool) { @@ -1209,9 +1091,7 @@ fn remove_source_locked(source_url_or_path: &str) -> xai_hooks_plugins_types::Ac } } -/// Set the official auto-installed flag on a TOML document string (pure, no I/O) -/// so callers can fold it into a single atomic write. Preserves formatting. -fn set_official_flag_in_toml(content: &str) -> std::io::Result { +fn set_marketplace_bool_flag_in_toml(content: &str, key: &str) -> std::io::Result { let mut doc = content.parse::().map_err(|e| { std::io::Error::new( std::io::ErrorKind::InvalidData, @@ -1229,23 +1109,21 @@ fn set_official_flag_in_toml(content: &str) -> std::io::Result { "[marketplace] is not a table", ) })?; - marketplace["official_marketplace_auto_installed"] = toml_edit::value(true); + marketplace[key] = toml_edit::value(true); Ok(doc.to_string()) } -/// Set the official auto-installed flag in `config.toml` (atomic write). -fn set_official_marketplace_auto_installed(config_path: &std::path::Path) -> std::io::Result<()> { +fn set_marketplace_bool_flag(config_path: &std::path::Path, key: &str) -> std::io::Result<()> { if let Some(parent) = config_path.parent() { let _ = std::fs::create_dir_all(parent); } let existing = crate::util::config::read_to_string_or_empty(config_path)?; - let updated = set_official_flag_in_toml(&existing)?; + let updated = set_marketplace_bool_flag_in_toml(&existing, key)?; crate::util::config::atomic_write_string(config_path, &updated) } -/// Read the `official_marketplace_auto_installed` flag; `false` on any failure. -fn read_official_marketplace_auto_installed(config_path: &std::path::Path) -> bool { +fn read_marketplace_bool_flag(config_path: &std::path::Path, key: &str) -> bool { let raw = match std::fs::read_to_string(config_path) { Ok(s) => s, Err(_) => return false, @@ -1256,11 +1134,23 @@ fn read_official_marketplace_auto_installed(config_path: &std::path::Path) -> bo }; parsed .get("marketplace") - .and_then(|m| m.get("official_marketplace_auto_installed")) + .and_then(|m| m.get(key)) .and_then(|v| v.as_bool()) .unwrap_or(false) } +fn set_official_flag_in_toml(content: &str) -> std::io::Result { + set_marketplace_bool_flag_in_toml(content, "official_marketplace_auto_installed") +} + +fn set_official_marketplace_auto_installed(config_path: &std::path::Path) -> std::io::Result<()> { + set_marketplace_bool_flag(config_path, "official_marketplace_auto_installed") +} + +fn read_official_marketplace_auto_installed(config_path: &std::path::Path) -> bool { + read_marketplace_bool_flag(config_path, "official_marketplace_auto_installed") +} + /// Acquire an advisory exclusive `flock` on `/.config-init.lock`, /// retrying briefly under contention, to serialize first-run auto-register /// across processes. Only `WouldBlock` retries; other I/O errors return early. @@ -1290,6 +1180,134 @@ fn acquire_init_lock(grok_home: &std::path::Path) -> std::io::Result bool { + plugin_subdir == "default-skills" +} + +fn default_skills_repo_keys<'a>( + repos: impl IntoIterator< + Item = ( + &'a str, + &'a xai_grok_agent::plugins::install_registry::InstalledRepo, + ), + >, +) -> Vec<&'a str> { + repos + .into_iter() + .filter_map(|(key, repo)| { + repo.marketplace + .as_ref() + .filter(|mp| is_default_skills_plugin_subdir(&mp.plugin_subdir)) + .map(|_| key) + }) + .collect() +} + +fn set_default_skills_installs_purged(config_path: &std::path::Path) -> std::io::Result<()> { + set_marketplace_bool_flag(config_path, "default_skills_installs_purged") +} + +fn read_default_skills_installs_purged(config_path: &std::path::Path) -> bool { + read_marketplace_bool_flag(config_path, "default_skills_installs_purged") +} + +/// One-shot purge of legacy marketplace `default-skills` installs. +/// +/// Gated by sticky `default_skills_installs_purged` in config.toml. Best-effort: +/// errors are logged and never block startup. +pub fn purge_default_skills_installs(grok_home: &std::path::Path) { + purge_default_skills_installs_impl(grok_home, || { + xai_grok_agent::plugins::install_registry::InstallRegistry::try_load_from( + xai_grok_agent::plugins::install_registry::InstallRegistry::resolve_install_dir(), + ) + }); +} + +fn purge_default_skills_installs_impl( + grok_home: &std::path::Path, + load_registry: impl FnOnce() -> Result< + xai_grok_agent::plugins::install_registry::InstallRegistry, + xai_grok_agent::plugins::install_registry::InstallError, + >, +) { + let config_path = grok_home.join("config.toml"); + + if read_default_skills_installs_purged(&config_path) { + return; + } + + let _lock = match acquire_init_lock(grok_home) { + Ok(f) => f, + Err(e) => { + tracing::warn!( + error = %e, + path = %grok_home.join(".config-init.lock").display(), + "skipping default-skills purge: failed to acquire init lock" + ); + return; + } + }; + + if read_default_skills_installs_purged(&config_path) { + return; + } + + let mut registry = match load_registry() { + Ok(reg) => reg, + Err(e) => { + tracing::warn!( + error = %e, + "skipping default-skills purge: failed to load install registry" + ); + return; + } + }; + let keys: Vec = default_skills_repo_keys(registry.list()) + .into_iter() + .map(|k| k.to_string()) + .collect(); + + for key in &keys { + let path = registry + .get_repo(key) + .map(|r| r.path.clone()) + .unwrap_or_else(|| registry.install_dir().join(key)); + if path.exists() + && let Err(e) = std::fs::remove_dir_all(&path) + { + let _ = std::fs::remove_file(&path); + if path.exists() { + tracing::warn!( + error = %e, + path = %path.display(), + repo_key = %key, + "failed to remove default-skills install dir" + ); + } + } + registry.remove(key); + } + + if !keys.is_empty() { + if let Err(e) = registry.save() { + tracing::warn!(error = %e, "failed to save registry after default-skills purge"); + return; + } + tracing::info!( + count = keys.len(), + "purged legacy default-skills marketplace installs" + ); + } + + if let Err(e) = set_default_skills_installs_purged(&config_path) { + tracing::warn!( + error = %e, + path = %config_path.display(), + "failed to set default_skills_installs_purged flag" + ); + } +} + /// Auto-register the official xAI marketplace source on first run. /// /// Gated by the caller (`init_process`); see @@ -1671,6 +1689,184 @@ mod official_source_tests { } } +#[cfg(test)] +mod default_skills_purge_tests { + use super::*; + use xai_grok_agent::plugins::install_registry::{ + InstallKind, InstallRegistry, InstalledRepo, MarketplaceProvenance, RepoPlugin, + }; + + fn repo_at(path: &std::path::Path, plugin_subdir: Option<&str>) -> InstalledRepo { + InstalledRepo { + kind: InstallKind::Local { + source_path: path.to_path_buf(), + subdir: None, + }, + installed_at: String::new(), + updated_at: String::new(), + path: path.to_path_buf(), + plugins: std::collections::HashMap::from([( + "p".into(), + RepoPlugin { + subdir: None, + version: None, + }, + )]), + marketplace: plugin_subdir.map(|subdir| MarketplaceProvenance { + source_url_or_path: "https://example.com/market.git".into(), + source_display_name: "Test".into(), + plugin_subdir: subdir.into(), + }), + } + } + + #[test] + fn match_is_exact_plugin_subdir_only() { + assert!(is_default_skills_plugin_subdir("default-skills")); + assert!(!is_default_skills_plugin_subdir("plugins/default-skills")); + assert!(!is_default_skills_plugin_subdir("default-skills/extra")); + assert!(!is_default_skills_plugin_subdir("defaults-skills")); + assert!(!is_default_skills_plugin_subdir("")); + } + + #[test] + fn collects_only_default_skills_repo_keys() { + let default_skills = repo_at(std::path::Path::new("/tmp/ds"), Some("default-skills")); + let other = repo_at(std::path::Path::new("/tmp/office"), Some("plugins/office")); + let no_marketplace = repo_at(std::path::Path::new("/tmp/local"), None); + + let keys = default_skills_repo_keys([ + ("ds-aaaa", &default_skills), + ("office-bbbb", &other), + ("local-cccc", &no_marketplace), + ]); + assert_eq!(keys, vec!["ds-aaaa"]); + } + + #[test] + fn purged_flag_toml_preserves_other_content() { + let content = + "[ui]\ntheme = \"dark\"\n[marketplace]\nofficial_marketplace_auto_installed = true\n"; + let out = + set_marketplace_bool_flag_in_toml(content, "default_skills_installs_purged").unwrap(); + assert!(out.contains("theme = \"dark\""), "{out}"); + assert!( + out.contains("official_marketplace_auto_installed = true"), + "{out}" + ); + assert!( + out.contains("default_skills_installs_purged = true"), + "{out}" + ); + } + + #[test] + fn read_purged_flag_false_when_missing_or_wrong_type() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("config.toml"); + assert!(!read_default_skills_installs_purged(&path)); + + std::fs::write( + &path, + "[marketplace]\ndefault_skills_installs_purged = \"yes\"\n", + ) + .unwrap(); + assert!(!read_default_skills_installs_purged(&path)); + + std::fs::write( + &path, + "[marketplace]\ndefault_skills_installs_purged = true\n", + ) + .unwrap(); + assert!(read_default_skills_installs_purged(&path)); + } + + #[test] + fn purge_sets_flag_when_nothing_to_delete() { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path(); + let install_dir = home.join("installed-plugins"); + purge_default_skills_installs_impl(home, || { + Ok(InstallRegistry::empty(install_dir.clone())) + }); + let config_path = home.join("config.toml"); + assert!(read_default_skills_installs_purged(&config_path)); + + let after_first = std::fs::read_to_string(&config_path).unwrap(); + purge_default_skills_installs_impl(home, || Ok(InstallRegistry::empty(install_dir))); + let after_second = std::fs::read_to_string(&config_path).unwrap(); + assert_eq!(after_first, after_second); + } + + #[test] + fn purge_skips_flag_when_registry_load_fails() { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path(); + let install_dir = home.join("installed-plugins"); + std::fs::create_dir_all(&install_dir).unwrap(); + std::fs::write(install_dir.join("registry.json"), "{not-json").unwrap(); + + purge_default_skills_installs_impl(home, || { + InstallRegistry::try_load_from(install_dir.clone()) + }); + + assert!(!read_default_skills_installs_purged( + &home.join("config.toml") + )); + } + + #[test] + fn purge_removes_default_skills_retains_others_and_sets_flag() { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path(); + let install_dir = home.join("installed-plugins"); + std::fs::create_dir_all(&install_dir).unwrap(); + + let ds_path = install_dir.join("ds-aaaa"); + std::fs::create_dir_all(&ds_path).unwrap(); + std::fs::write(ds_path.join("marker"), "ds").unwrap(); + + let other_path = install_dir.join("office-bbbb"); + std::fs::create_dir_all(&other_path).unwrap(); + std::fs::write(other_path.join("marker"), "office").unwrap(); + + let mut registry = InstallRegistry::empty(install_dir.clone()); + registry.insert("ds-aaaa".into(), repo_at(&ds_path, Some("default-skills"))); + registry.insert( + "office-bbbb".into(), + repo_at(&other_path, Some("plugins/office")), + ); + registry.save().unwrap(); + + let install_dir_for_load = install_dir.clone(); + purge_default_skills_installs_impl(home, move || { + InstallRegistry::try_load_from(install_dir_for_load) + }); + + assert!( + !ds_path.exists(), + "default-skills install dir must be removed" + ); + assert!(other_path.exists(), "non-matching install must be retained"); + + let reloaded = InstallRegistry::load_from(install_dir.clone()); + assert!(reloaded.get_repo("ds-aaaa").is_none()); + assert!(reloaded.get_repo("office-bbbb").is_some()); + + let config_path = home.join("config.toml"); + assert!(read_default_skills_installs_purged(&config_path)); + + let after_first = std::fs::read_to_string(&config_path).unwrap(); + let install_dir_for_reload = install_dir; + purge_default_skills_installs_impl(home, move || { + InstallRegistry::try_load_from(install_dir_for_reload) + }); + let after_second = std::fs::read_to_string(&config_path).unwrap(); + assert_eq!(after_first, after_second); + assert!(other_path.exists()); + } +} + #[cfg(test)] mod conversion_tests { use super::*; diff --git a/crates/codegen/xai-grok-shell/src/extensions/notification.rs b/crates/codegen/xai-grok-shell/src/extensions/notification.rs index c4a7c2a..e238ffa 100644 --- a/crates/codegen/xai-grok-shell/src/extensions/notification.rs +++ b/crates/codegen/xai-grok-shell/src/extensions/notification.rs @@ -543,10 +543,10 @@ pub enum SessionUpdate { /// Task completed notification TaskCompleted { task_snapshot: TaskSnapshot, - /// Whether an auto-wake prompt follows this completion. The pager - /// skips its between-turns status line when set — the wake turn's - /// end marker carries the fresh counts instead. Missing (old - /// shells) reads as `false`: emit the line. + /// Advisory: an auto-wake prompt follows this completion. The + /// first-party TUI no longer consumes it (remaining background work + /// is surfaced by its persistent "watching" status row); kept for + /// wire compatibility and other clients. Missing reads as `false`. #[serde(default)] will_wake: bool, }, @@ -648,10 +648,10 @@ pub enum SessionUpdate { /// Final output text from the subagent (if completed). #[serde(default, skip_serializing_if = "Option::is_none")] output: Option, - /// Whether an auto-wake prompt follows this completion. The pager - /// skips its between-turns status line when set — the wake turn's - /// end marker carries the fresh counts instead. Missing (old - /// shells) reads as `false`: emit the line. + /// Advisory: an auto-wake prompt follows this completion. The + /// first-party TUI no longer consumes it (remaining background work + /// is surfaced by its persistent "watching" status row); kept for + /// wire compatibility and other clients. Missing reads as `false`. #[serde(default)] will_wake: bool, }, diff --git a/crates/codegen/xai-grok-shell/src/extensions/pr.rs b/crates/codegen/xai-grok-shell/src/extensions/pr.rs index d747072..d92f30a 100644 --- a/crates/codegen/xai-grok-shell/src/extensions/pr.rs +++ b/crates/codegen/xai-grok-shell/src/extensions/pr.rs @@ -84,13 +84,21 @@ async fn gh_pr_view_by_branch(cwd: &str, branch: &str) -> Option { .stdin(std::process::Stdio::null()); xai_grok_tools::util::detach_command(&mut cmd); cmd.envs(xai_grok_tools::util::pager_env()); + // gh colorizes even piped --json output under CLICOLOR_FORCE or + // GH_FORCE_TTY (inherited from terminal-launched dev environments), and + // forcing beats NO_COLOR in gh's precedence; there is no --no-color flag + // (cli/cli#9436). CLICOLOR_FORCE=0 is gh's documented off-switch. + cmd.env("NO_COLOR", "1"); + cmd.env("CLICOLOR_FORCE", "0"); + cmd.env_remove("GH_FORCE_TTY"); let output = cmd.output().await.ok()?; if !output.status.success() { return None; } - let parsed = serde_json::from_slice::(&output.stdout).ok()?; + let parsed = + serde_json::from_slice::(&strip_ansi_csi(&output.stdout)).ok()?; let url = parsed.url?; let state = match parsed .state @@ -131,7 +139,10 @@ async fn gh_pr_is_in_merge_queue(cwd: &str, pr_url: &str) -> bool { .stdin(std::process::Stdio::null()); xai_grok_tools::util::detach_command(&mut cmd); cmd.envs(xai_grok_tools::util::pager_env()); + // Forcing (CLICOLOR_FORCE/GH_FORCE_TTY) beats NO_COLOR in gh's precedence. cmd.env("NO_COLOR", "1"); + cmd.env("CLICOLOR_FORCE", "0"); + cmd.env_remove("GH_FORCE_TTY"); let output = match cmd.output().await { Ok(output) => output, Err(_) => return false, @@ -187,6 +198,18 @@ fn strip_ansi_csi(bytes: &[u8]) -> Vec { mod tests { use super::*; + #[test] + fn gh_pr_view_json_parses_after_stripping_forced_color() { + let stdout = b"\x1b[1;37m{\x1b[m\n \x1b[1;34m\"isDraft\"\x1b[m\x1b[1;37m:\x1b[m \x1b[33mfalse\x1b[m\x1b[1;37m,\x1b[m\n \x1b[1;34m\"number\"\x1b[m\x1b[1;37m:\x1b[m 242682\x1b[1;37m,\x1b[m\n \x1b[1;34m\"state\"\x1b[m\x1b[1;37m:\x1b[m \x1b[32m\"OPEN\"\x1b[m\x1b[1;37m,\x1b[m\n \x1b[1;34m\"title\"\x1b[m\x1b[1;37m:\x1b[m \x1b[32m\"t\"\x1b[m\x1b[1;37m,\x1b[m\n \x1b[1;34m\"url\"\x1b[m\x1b[1;37m:\x1b[m \x1b[32m\"https://github.com/xai-org/xai/pull/242682\"\x1b[m\n\x1b[1;37m}\x1b[m\n"; + let parsed = serde_json::from_slice::(&strip_ansi_csi(stdout)).unwrap(); + assert_eq!(parsed.number, Some(242682)); + assert_eq!(parsed.state.as_deref(), Some("OPEN")); + assert_eq!( + parsed.url.as_deref(), + Some("https://github.com/xai-org/xai/pull/242682") + ); + } + #[test] fn parse_is_in_merge_queue_true() { let stdout = br#"{"data":{"resource":{"isInMergeQueue":true}}}"#; diff --git a/crates/codegen/xai-grok-shell/src/inspect/mod.rs b/crates/codegen/xai-grok-shell/src/inspect/mod.rs index 5821fa9..78bff95 100644 --- a/crates/codegen/xai-grok-shell/src/inspect/mod.rs +++ b/crates/codegen/xai-grok-shell/src/inspect/mod.rs @@ -24,7 +24,7 @@ use xai_grok_tools::util::truncate::estimate_tokens; const TREE: &str = "\u{2514}"; /// Coarse scope label for project instructions and plugin entries. -#[derive(Debug, Clone, Copy, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "lowercase")] pub enum Scope { Project, @@ -440,13 +440,35 @@ fn has_rules_directory(file_path: &str, config_dir: &str) -> bool { false } +fn instruction_scope( + file_path: &str, + grok_home: &Path, + vendor_homes: &[(PathBuf, bool)], + workspace_root: &Path, +) -> Scope { + if crate::util::is_user_instruction_path( + Path::new(file_path), + grok_home, + vendor_homes, + Some(workspace_root), + ) { + Scope::Global + } else { + Scope::Project + } +} + fn instruction_file_type( file_path: &str, + grok_home: &Path, claude_imported: bool, extra_rule_prefixes: &[PathBuf], ) -> &'static str { let path = Path::new(file_path); - if has_rules_directory(file_path, ".grok") + if path + .parent() + .is_some_and(|parent| parent == grok_home.join("rules")) + || has_rules_directory(file_path, ".grok") || has_rules_directory(file_path, ".cursor") || (!claude_imported && has_rules_directory(file_path, ".claude")) || extra_rule_prefixes @@ -468,7 +490,19 @@ async fn list_instructions(cwd: &Path) -> Vec { ) .await; - let grok_home = Some(crate::util::grok_home::grok_home()); + let grok_home = crate::util::grok_home::grok_home(); + let vendor_homes = dirs::home_dir() + .map(|home_dir| { + vec![ + (home_dir.join(".claude"), true), + (home_dir.join(".cursor"), true), + ] + }) + .unwrap_or_default(); + let workspace_root = git2::Repository::discover(cwd) + .ok() + .and_then(|repo| repo.workdir().map(Path::to_path_buf)) + .unwrap_or_else(|| cwd.to_path_buf()); // Phase 2 cutoff: when imported, stop classifying `.claude/rules/` paths // as rules. Equivalent dirs come in via `[paths] extra_rule_dirs`. @@ -494,15 +528,9 @@ async fn list_instructions(cwd: &Path) -> Vec { configs .into_iter() .map(|c| { - let file_type = instruction_file_type(&c.file_path, imported, &extra_rule_prefixes); - let scope = if grok_home - .as_deref() - .is_some_and(|home| Path::new(&c.file_path).starts_with(home)) - { - Scope::Global - } else { - Scope::Project - }; + let file_type = + instruction_file_type(&c.file_path, &grok_home, imported, &extra_rule_prefixes); + let scope = instruction_scope(&c.file_path, &grok_home, &vendor_homes, &workspace_root); let size = c.content.len(); let vendor = derive_vendor(&c.file_path).map(String::from); InstructionFile { @@ -1604,7 +1632,7 @@ mod tests { ("claude", "/repo/.claude/rules/team.md"), ("claude", r"C:\repo\.claude\rules\team.md"), ] { - let file_type = instruction_file_type(path, false, &[]); + let file_type = instruction_file_type(path, Path::new("/home/user/.grok"), false, &[]); assert_eq!(file_type, "rules"); assert_eq!( instruction_compat_status(&Some(vendor.to_owned()), file_type, &report), @@ -1613,19 +1641,25 @@ mod tests { } for path in ["/repo/.grok/rules/team.md", r"C:\repo\.grok\rules\team.md"] { - assert_eq!(instruction_file_type(path, false, &[]), "rules"); + assert_eq!( + instruction_file_type(path, Path::new("/home/user/.grok"), false, &[]), + "rules" + ); } for path in [ "/repo/.cursor/rules/team.md", r"C:\repo\.cursor\rules\team.md", ] { - assert_eq!(instruction_file_type(path, true, &[]), "rules"); + assert_eq!( + instruction_file_type(path, Path::new("/home/user/.grok"), true, &[]), + "rules" + ); } for path in [ "/repo/.claude/rules/team.md", r"C:\repo\.claude\rules\team.md", ] { - let file_type = instruction_file_type(path, true, &[]); + let file_type = instruction_file_type(path, Path::new("/home/user/.grok"), true, &[]); assert_eq!(file_type, "agents_md"); assert_eq!( instruction_compat_status(&Some("claude".to_owned()), file_type, &report), @@ -1636,10 +1670,96 @@ mod tests { "/repo/not.cursor/rules/team.md", r"C:\repo\.cursor\ruleset\team.md", ] { - assert_eq!(instruction_file_type(path, false, &[]), "agents_md"); + assert_eq!( + instruction_file_type(path, Path::new("/home/user/.grok"), false, &[]), + "agents_md" + ); } } + #[test] + fn grok_home_nested_in_workspace_keeps_direct_surfaces_global() { + let grok_home = Path::new("/repo/config"); + let workspace = Path::new("/repo"); + for path in ["/repo/config/AGENTS.md", "/repo/config/rules/global.md"] { + assert!(matches!( + instruction_scope(path, grok_home, &[], workspace), + Scope::Global + )); + } + for path in [ + "/repo/config/.grok/rules/project.md", + "/repo/config/src/AGENTS.md", + ] { + assert!(matches!( + instruction_scope(path, grok_home, &[], workspace), + Scope::Project + )); + } + } + + #[test] + fn vendor_home_nested_in_workspace_keeps_direct_surfaces_global() { + let vendor_homes = vec![(Path::new("/repo/.claude").to_path_buf(), true)]; + let workspace = Path::new("/repo"); + for path in ["/repo/.claude/rules/global.md", "/repo/.claude/CLAUDE.md"] { + assert!(matches!( + instruction_scope(path, Path::new("/other/grok"), &vendor_homes, workspace), + Scope::Global + )); + } + for path in [ + "/repo/.claude/.claude/rules/project.md", + "/repo/.claude/src/AGENTS.md", + ] { + assert!(matches!( + instruction_scope(path, Path::new("/other/grok"), &vendor_homes, workspace), + Scope::Project + )); + } + } + + #[test] + fn workspace_scope_wins_inside_grok_home() { + let grok_home = Path::new("/custom/grok"); + let workspace = Path::new("/custom/grok/worktrees/repo"); + for path in [ + "/custom/grok/worktrees/repo/.cursor/rules/project.md", + "/custom/grok/worktrees/repo/src/AGENTS.md", + ] { + assert!(matches!( + instruction_scope(path, grok_home, &[], workspace), + Scope::Project + )); + } + assert!(matches!( + instruction_scope("/custom/grok/rules/global.md", grok_home, &[], workspace,), + Scope::Global + )); + } + + #[test] + fn custom_grok_home_rules_are_classified_as_rules() { + assert_eq!( + instruction_file_type( + "/custom/config/rules/team.md", + Path::new("/custom/config"), + false, + &[], + ), + "rules" + ); + assert_eq!( + instruction_file_type( + "/custom/config/AGENTS.md", + Path::new("/custom/config"), + false, + &[], + ), + "agents_md" + ); + } + #[test] fn describe_config_file_flags_empty_and_parse_error() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/codegen/xai-grok-shell/src/leader/server.rs b/crates/codegen/xai-grok-shell/src/leader/server.rs index 31b30f1..d23d9cc 100644 --- a/crates/codegen/xai-grok-shell/src/leader/server.rs +++ b/crates/codegen/xai-grok-shell/src/leader/server.rs @@ -50,6 +50,12 @@ enum ServerEvent { Registered(ClientId, ClientMode, ClientCapabilities, String), Message(ClientId, ClientMessage), } +enum LeaderServerPoll { + Cancelled, + Accept(std::io::Result), + Event(ServerEvent), + Response(String), +} /// A live notification buffered during an in-flight `session/load`: the /// shared payload plus its `event_seq` (computed at buffer time, when the /// message is already parsed, so the post-load flush never re-parses). @@ -970,12 +976,17 @@ async fn wait_for_leader_auth( cancel: &CancellationToken, ) -> Result, ControlError> { let mut rx = ws.auth.subscribe(); - tokio::select! { - result = rx.wait_for(| v | v.is_some()) => match result { Ok(guard) => Ok(guard - .clone().expect("waited for Some")), Err(_) => - Err(workspace_err("leader is shutting down; cannot expose workspace to the hub",)), - }, _ = cancel.cancelled() => - Err(workspace_err("leader is shutting down; cannot expose workspace to the hub",)), + let result = tokio::select! { + result = rx.wait_for(| v | v.is_some()) => result, _ = cancel.cancelled() => { + return + Err(workspace_err("leader is shutting down; cannot expose workspace to the hub",)); + } + }; + match result { + Ok(guard) => Ok(guard.clone().expect("waited for Some")), + Err(_) => Err(workspace_err( + "leader is shutting down; cannot expose workspace to the hub", + )), } } fn workspace_server_id() -> String { @@ -1526,297 +1537,789 @@ pub async fn run_leader_server( let mut pending_requests: usize = 0; let relaunching = Arc::new(AtomicBool::new(false)); loop { - tokio::select! { - biased; _ = cancel.cancelled() => { let reason = shutdown_reason_rx.borrow() - .clone(); info!(? reason, "Leader server shutting down (cancelled)"); if - pending_requests > 0 { debug!(pending_requests, - "Resetting agent_busy on shutdown"); agent_busy.store(false, - Ordering::Relaxed); } broadcast_shutdown(& clients, reason). await; break; } - accept_result = listener.accept() => { match accept_result { Ok((stream, _)) - => { had_clients = true; let client_id = ClientId::new(); let (tx, rx) = - kanal::unbounded_async(); clients.insert(client_id, ClientState { tx, mode : - ClientMode::Stdio, capabilities : ClientCapabilities::default(), client_type - : String::new(), initialize_seen : false, patch_initialize_model : false, - registered : false, }); spawn_client_handler(client_id, stream, rx, event_tx - .clone(), cancel.child_token(), ready_rx.clone(), control_state.clone(),); } - Err(e) => error!(error = % e, "Accept failed"), } } Ok(event) = event_rx - .recv() => { match event { ServerEvent::Registered(id, mode, capabilities, - client_type) => { if let Some(client) = clients.get_mut(& id) { client.mode = - mode; client.capabilities = capabilities; client.client_type = client_type; - client.registered = true; client_count.fetch_add(1, Ordering::Relaxed); - debug!(client_id = id.0, ? mode, yolo_mode = client.capabilities.yolo_mode, - client_type = % client.client_type, "Client registered"); - xai_grok_telemetry::unified_log::info("leader.client.registered", None, - Some(serde_json::json!({ "client_id" : id.0, "client_type" : client - .client_type, })),); if mode == ClientMode::Headless { let newly_demanded = - relay_demand_tx.send_if_modified(| demanded | { let changed = !* demanded; * - demanded = true; changed }); if newly_demanded { info!(client_id = id.0, - "First headless client registered; signalling relay demand"); } } let - effective_leader_version = leader_version_override.unwrap_or(LEADER_VERSION); - if let Some(ref cv) = client.capabilities.client_version && let Some(payload) - = make_version_mismatch_notification(cv.as_str(), effective_leader_version,) - { warn!(client_id = id.0, client_version = cv.as_str(), leader_version = - effective_leader_version, - "Version mismatch: client binary differs from leader binary"); let _ = client - .tx.try_send(ClientOutbound::Acp(payload.into())); } } } - ServerEvent::Disconnected(id) => { let was_registered = clients.get(& id) - .is_some_and(| c | c.registered); clients.remove(& id); if was_registered { - client_count.fetch_sub(1, Ordering::Relaxed); - xai_grok_telemetry::unified_log::info("leader.client.disconnected", None, - Some(serde_json::json!({ "client_id" : id.0 })),); } pending_load_by_req - .retain(| _, (c, _) | * c != id); load_live_buffer.retain(| (c, _), _ | * c - != id); load_replay_max_seq.retain(| (c, _), _ | * c != id); let mut - detached_sessions : Vec < String > = Vec::new(); let viewed : Vec < String > - = session_subscribers.iter().filter(| (_, subs) | subs.contains(& id)).map(| - (sid, _) | sid.clone()).collect(); for sid in viewed { let now_empty = if let - Some(subs) = session_subscribers.get_mut(& sid) { subs.remove(& id); subs - .is_empty() } else { true }; if now_empty { session_subscribers.remove(& - sid); session_driver.remove(& sid); detached_sessions.push(sid); } else if - session_driver.get(& sid) == Some(& id) { if let Some(& next) = - session_subscribers.get(& sid).and_then(| s | s.iter().next()) { - session_driver.insert(sid.clone(), next); debug!(session_id = % sid, - old_driver = id.0, new_driver = next.0, - "Transferred session driver after disconnect"); } else { session_driver - .remove(& sid); } } } if last_active_client == Some(id) { last_active_client - = None; } if ! detached_sessions.is_empty() { let evict_notification = - serde_json::json!({ "jsonrpc" : "2.0", "method" : - "x.ai/internal/evict_sessions", "params" : { "sessionIds" : detached_sessions - } }); let _ = acp_tx.send(evict_notification.to_string()); info!(client_id = - id.0, session_count = detached_sessions.len(), - "Sent client-disconnect detach notification for disconnected client"); } - debug!(client_id = id.0, "Client removed"); if clients.is_empty() && - had_clients && ! no_exit_on_disconnect { - info!("Leader server shutting down (all clients disconnected)"); break; } } - ServerEvent::Message(id, ClientMessage::Control { request_id, command }) => { - if let Some(client) = clients.get(& id) { let client_tx = client.tx.clone(); - let control_state = control_state.clone(); let cancel = cancel.clone(); let - shutdown_tx = shutdown_tx.clone(); let agent_busy = agent_busy.clone(); let - agent_activity = agent_activity.clone(); let relaunching = relaunching - .clone(); tokio::spawn(async move { let result = match command { - ControlCommand::StopCpuProfile => { handle_stop_cpu_profile(control_state). - await } ControlCommand::WorkspaceStart { hub_url, cwd } => { - handle_workspace_start(control_state, hub_url, cwd, cancel.clone(),). await } - ControlCommand::WorkspacePause => { handle_workspace_pause(control_state). - await } ControlCommand::WorkspaceResume => { - handle_workspace_resume(control_state). await } ControlCommand::WorkspaceStop - => { handle_workspace_stop(control_state). await } - ControlCommand::WorkspaceStatus => { handle_workspace_status(control_state). - await } ControlCommand::RelaunchForUpdate { to_version } => { - decide_relaunch_for_update(& control_state, to_version, & relaunching,) } - other => handle_control_command(& control_state, other), }; let arm_relaunch - = matches!(result, Ok(ControlPayload::Relaunching { .. })); if let Err(e) = - client_tx.send(ServerMessage::ControlResult { request_id, result } .into()). - await { warn!(client_id = id.0, error = % e, - "Failed to send control response to client"); } if arm_relaunch { - spawn_relaunch_drain(shutdown_tx, cancel, agent_busy, agent_activity,); } }); - } } ServerEvent::Message(id, ClientMessage::Acp { payload }) => { let mut - json : Option < serde_json::Value > = serde_json::from_str(& payload).ok(); - let mut payload_mutated = false; if !* ready_rx.borrow() { if let - Some(error_payload) = json.as_ref().and_then(make_leader_starting_error) { if - let Some(client) = clients.get(& id) { let _ = client.tx - .try_send(ClientOutbound::Acp(error_payload.into())); } trace!(client_id = id - .0, "Returned leader_starting error (not yet ready)"); } else { - trace!(client_id = id.0, - "Dropped pre-ready notification (leader not yet ready)"); } continue; } if - let Some(client) = clients.get(& id) && client.mode == ClientMode::Stdio { - last_active_client = Some(id); } if let Some(session_id) = json.as_ref() - .and_then(extract_session_id) { session_subscribers.entry(session_id.clone()) - .or_default().insert(id); session_driver.entry(session_id.clone()) - .or_insert(id); backfill_child_routes(& session_id, id, & child_sessions, & - mut session_subscribers, & mut session_driver,); } if let (Some(json), - Some(client)) = (json.as_ref(), clients.get_mut(& id)) { if let - Some(yolo_mode) = extract_yolo_mode_change(json) { client.capabilities - .yolo_mode = yolo_mode; debug!(client_id = id.0, yolo_mode, - "Updated client yolo_mode from notification"); } if let Some(auto_mode) = - extract_auto_mode_change(json) { client.capabilities.auto_mode = auto_mode; - debug!(client_id = id.0, auto_mode, - "Updated client auto_mode from notification"); } if let Some(new_model) = - extract_model_id_from_set_model(json) { debug!(client_id = id.0, model = % - new_model, "Updated client default_model from session/setModel"); client - .capabilities.default_model = Some(new_model); } } if let (Some(json), - Some(client)) = (json.as_mut(), clients.get_mut(& id)) { if ! client - .initialize_seen { let (injected, was_initialize) = - inject_client_identity_into_initialize(json, & client.client_type); - payload_mutated |= injected; if was_initialize { client.initialize_seen = - true; if client.capabilities.default_model.as_ref().is_some_and(| m | ! m - .is_empty()) { client.patch_initialize_model = true; } } } payload_mutated |= - inject_capabilities_into_session_new(json, & client.capabilities, & client - .client_type, id); payload_mutated |= - inject_client_identity_into_yolo_notification(json, & client.client_type); } - let rewritten = json.as_mut().and_then(| j | rewrite_request_id(j, id)); - payload_mutated |= rewritten.is_some(); if let Some(json) = json.as_ref() && - is_session_load_request(json) && let Some(load_sid) = - extract_session_id(json) && let Some((ns_id, _)) = rewritten.as_ref() { - pending_load_by_req.insert(ns_id.clone(), (id, load_sid.clone())); - load_live_buffer.entry((id, load_sid)).or_default(); } if rewritten.is_some() - { pending_requests += 1; agent_busy.store(true, Ordering::Relaxed); } let - outbound = select_outbound_payload(json.as_ref(), payload_mutated, payload); - let _ = acp_tx.send(outbound); } ServerEvent::Message(_, _) => {} } } - Some(payload) = response_rx.recv() => { let mut json : Option < - serde_json::Value > = serde_json::from_str(& payload).ok(); let - parsed_response = json.as_mut().and_then(parse_response_id); if - parsed_response.is_some() { pending_requests = pending_requests - .saturating_sub(1); agent_busy.store(pending_requests > 0, - Ordering::Relaxed); } if let Some((orphan_client, ref orphan_req_id)) = - parsed_response && ! clients.contains_key(& orphan_client) { warn!(client_id - = orphan_client.0, request_id = orphan_req_id.as_str(), - "Dropping RPC response: requesting client disconnected (response orphaned)"); - xai_grok_telemetry::unified_log::warn("leader.response.orphaned", None, - Some(serde_json::json!({ "client_id" : orphan_client.0, "request_id" : - orphan_req_id, })),); } if let Some((client_id, ref raw_response_id)) = - parsed_response && let Some(client) = clients.get_mut(& client_id) && let - Some(json) = json.as_mut() { if let Some(session_id) = - extract_session_id_from_result(json) { session_subscribers.entry(session_id - .clone()).or_default().insert(client_id); session_driver.entry(session_id - .clone()).or_insert(client_id); backfill_child_routes(& session_id, - client_id, & child_sessions, & mut session_subscribers, & mut - session_driver,); trace!(client_id = client_id.0, session_id, - "Subscribed client to session from response"); } if client - .patch_initialize_model { client.patch_initialize_model = false; - patch_initialize_response_model(json, & client.capabilities.default_model); } - let restored_payload : Arc < str > = json.to_string().into(); match client.tx - .try_send(ClientOutbound::Acp(restored_payload)) { Ok(true) => { - trace!(client_id = client_id.0, "Routed response via request ID"); } - Ok(false) => { warn!(client_id = client_id.0, - "Failed to send response to client (channel full)"); - xai_grok_telemetry::unified_log::warn("leader.response.send_failed", None, - Some(serde_json::json!({ "client_id" : client_id.0, "reason" : - "channel_full", })),); } Err(e) => { warn!(client_id = client_id.0, error = % - e, "Failed to send response to client (channel closed)"); - xai_grok_telemetry::unified_log::warn("leader.response.send_failed", None, - Some(serde_json::json!({ "client_id" : client_id.0, "reason" : - "channel_closed", })),); } } if let Some((buf_client, buf_sid)) = - pending_load_by_req.remove(raw_response_id) { let replay_cutoff : Option < - u64 > = load_replay_max_seq.remove(& (buf_client, buf_sid.clone())); if let - Some(buffered) = load_live_buffer.remove(& (buf_client, buf_sid.clone())) && - let Some(target) = clients.get(& buf_client) { let mut count = 0usize; let - mut deduped = 0usize; for (buffered_payload, buffered_seq) in buffered { if - let Some(cutoff) = replay_cutoff && buffered_seq.is_some_and(| s | s <= - cutoff) { deduped += 1; continue; } if let Err(e) = target.tx - .try_send(ClientOutbound::Acp(buffered_payload)) { warn!(client_id = - buf_client.0, error = % e, - "Failed to flush buffered live notification after load (channel closed)"); - break; } count += 1; } if count > 0 || deduped > 0 { trace!(client_id = - buf_client.0, count, deduped, - "Flushed buffered live notifications after load (replay-overlap dropped)"); } - } if let Some(cached) = interaction_requests.get(buf_sid.as_str()) && let - Some(target) = clients.get(& buf_client) { let count = cached.len(); for req - in cached.values() { if let Err(e) = target.tx - .try_send(ClientOutbound::Acp(req.clone())) { warn!(client_id = buf_client.0, - error = % e, - "Failed to replay interaction request after load (channel closed)"); break; } - } if count > 0 { trace!(client_id = buf_client.0, count, session_id = buf_sid - .as_str(), "Replayed pending interaction modals to newly-attached client"); } - } } continue; } let payload : Arc < str > = payload.into(); let json = json; - if json.as_ref().is_some_and(is_machine_wide_broadcast_notification) { for - client in clients.values() { let _ = client.tx - .try_send(ClientOutbound::Acp(payload.clone())); } - trace!("Broadcast machine-wide notification to all clients"); continue; } if - let Some(target) = json.as_ref().and_then(extract_target_client_id) { if let - Some(client) = clients.get(& target) { match json.as_ref() - .and_then(extract_child_session_event) { - Some(ChildSessionEvent::Spawned(child_sid)) => { if let Some(parent) = json - .as_ref().and_then(extract_session_id) { child_sessions.entry(parent) - .or_default().insert(child_sid.clone()); } debug!(client_id = target.0, - child_session_id = % child_sid, - "Registered child route from replayed SubagentSpawned"); session_subscribers - .entry(child_sid).or_default().insert(target); } - Some(ChildSessionEvent::Finished(child_sid)) => { let emptied = - session_subscribers.get_mut(& child_sid).is_some_and(| subs | { subs.remove(& - target); subs.is_empty() }); if emptied { prune_child_route(& child_sid, & - mut session_subscribers, & mut session_driver, & mut child_sessions,); } } - None => {} } let replay_seq = json.as_ref().and_then(extract_session_id) - .zip(json.as_ref().and_then(event_seq_of)); match client.tx - .try_send(ClientOutbound::Acp(payload)) { Ok(true) => { if let Some((sid, - seq)) = replay_seq { let entry = load_replay_max_seq.entry((target, sid)) - .or_insert(0); * entry = (* entry).max(seq); } trace!(client_id = target.0, - "Unicast replay notification to loading client"); } Ok(false) => { - warn!(client_id = target.0, - "Replay notification dropped: loading client channel full (not counted toward flush cutoff)"); - } Err(e) => { warn!(client_id = target.0, error = % e, - "Failed to unicast replay notification to loading client (channel closed)"); - } } } else { if let Some(ChildSessionEvent::Finished(child_sid)) = json - .as_ref().and_then(extract_child_session_event) && session_subscribers.get(& - child_sid).is_none_or(| subs | subs.is_empty()) { prune_child_route(& - child_sid, & mut session_subscribers, & mut session_driver, & mut - child_sessions,); } if orphan_replay_warned.insert(target) { warn!(client_id - = target.0, - "Dropping targeted replay notification: loading client disconnected mid-replay (rest of burst logged at trace)"); - } else { trace!(client_id = target.0, - "Dropping targeted replay notification: loading client disconnected mid-replay"); - } } continue; } let session_id = json.as_ref().and_then(extract_session_id) - .or_else(|| json.as_ref().and_then(extract_session_id_from_prompt_complete)); - if let Some(ref sid) = session_id && let Some(tcid) = json.as_ref() - .and_then(extract_interaction_resolved_tool_call_id) && let Some(map) = - interaction_requests.get_mut(sid.as_str()) { map.remove(& tcid); if map - .is_empty() { interaction_requests.remove(sid.as_str()); } } let - is_reverse_request = json.as_ref().is_some_and(| j | j.get("id").is_some() && - j.get("method").is_some()); let is_inject_prompt = json.as_ref() - .is_some_and(is_scheduled_task_inject_prompt); let is_interaction = - is_reverse_request && json.as_ref().is_some_and(is_interaction_request); if - is_interaction && let Some(ref sid) = session_id && let Some(tcid) = json - .as_ref().and_then(extract_interaction_tool_call_id) { interaction_requests - .entry(sid.clone()).or_default().insert(tcid, payload.clone()); } if let - Some(ref sid) = session_id && session_subscribers.contains_key(sid.as_str()) - { let child_event = json.as_ref().and_then(extract_child_session_event); let - event_seq = json.as_ref().and_then(event_seq_of); if (is_reverse_request && ! - is_interaction) || is_inject_prompt { if let Some(& driver_id) = - session_driver.get(sid.as_str()) { if let Some(client) = clients.get(& - driver_id) { if let Err(e) = client.tx.try_send(ClientOutbound::Acp(payload - .clone())) { warn!(client_id = driver_id.0, session_id = sid.as_str(), - is_inject = is_inject_prompt, error = % e, - "Failed to route driver-only message (channel closed)"); } else { - trace!(client_id = driver_id.0, session_id = sid.as_str(), is_inject = - is_inject_prompt, "Routed driver-only message to driver"); } } else { - trace!(session_id = sid.as_str(), is_inject = is_inject_prompt, - "Dropping driver-only message: no live driver"); } } else { trace!(session_id - = sid.as_str(), is_inject = is_inject_prompt, - "Dropping driver-only message: session has no driver"); } } else if let - Some(subs) = session_subscribers.get(sid.as_str()) { for & cid in subs.iter() - { if let Some(buf) = load_live_buffer.get_mut(& (cid, sid.clone())) { if buf - .len() < MAX_BUFFERED_LIVE_PER_LOAD { buf.push((payload.clone(), event_seq)); - trace!(client_id = cid.0, session_id = sid.as_str(), - "Buffered live notification during in-flight load"); continue; } - warn!(client_id = cid.0, session_id = sid.as_str(), - "Live buffer for in-flight load exceeded cap; forwarding live (ordering not guaranteed)"); - } if let Some(client) = clients.get(& cid) { if let Err(e) = client.tx - .try_send(ClientOutbound::Acp(payload.clone())) { warn!(client_id = cid.0, - session_id = sid.as_str(), error = % e, - "Failed to broadcast notification to subscriber (channel closed)"); } else { - trace!(client_id = cid.0, session_id = sid.as_str(), - "Broadcast notification to subscriber"); } } } } match child_event { - Some(ChildSessionEvent::Spawned(child_sid)) => { let parent_subs = - session_subscribers.get(sid.as_str()).cloned().unwrap_or_default(); - info!(child_session_id = % child_sid, subscriber_count = parent_subs.len(), - "Registered child session from SubagentSpawned"); session_subscribers - .insert(child_sid.clone(), parent_subs); if let Some(& driver_id) = - session_driver.get(sid.as_str()) { session_driver.insert(child_sid.clone(), - driver_id); } child_sessions.entry(sid.clone()).or_default() - .insert(child_sid); } Some(ChildSessionEvent::Finished(child_sid)) => { - debug!(child_session_id = % child_sid, - "Deregistered child session from SubagentFinished"); prune_child_route(& - child_sid, & mut session_subscribers, & mut session_driver, & mut - child_sessions,); } None => {} } continue; } let is_notification = json - .as_ref().is_some_and(| j | j.get("id").is_none()); let - is_relay_session_notification = is_notification && session_id.as_ref() - .is_some_and(| s | ! session_subscribers.contains_key(s.as_str())); if ! - is_notification { - trace!("Dropping non-routable response (likely relay-originated)"); } else if - is_relay_session_notification { if let - Some(ChildSessionEvent::Finished(child_sid)) = json.as_ref() - .and_then(extract_child_session_event) && session_subscribers.get(& - child_sid).is_none_or(| subs | subs.is_empty()) { prune_child_route(& - child_sid, & mut session_subscribers, & mut session_driver, & mut - child_sessions,); } - trace!("Dropping notification for relay-owned session (already delivered via WS)"); - } else if let Some(client_id) = last_active_client && let Some(client) = - clients.get(& client_id) { debug!(client_id = client_id.0, - "Using fallback routing to last active client"); if let Err(e) = client.tx - .try_send(ClientOutbound::Acp(payload)) { warn!(client_id = client_id.0, - error = % e, - "Failed to send notification via fallback routing (channel closed)"); } } - else { - debug!("No client available for notification routing, message dropped"); } } + let poll = tokio::select! { + biased; _ = cancel.cancelled() => LeaderServerPoll::Cancelled, accept_result + = listener.accept() => { LeaderServerPoll::Accept(accept_result.map(| + (stream, _) | stream)) } Ok(event) = event_rx.recv() => + LeaderServerPoll::Event(event), Some(payload) = response_rx.recv() => + LeaderServerPoll::Response(payload), + }; + match poll { + LeaderServerPoll::Cancelled => { + let reason = shutdown_reason_rx.borrow().clone(); + info!(?reason, "Leader server shutting down (cancelled)"); + if pending_requests > 0 { + debug!(pending_requests, "Resetting agent_busy on shutdown"); + agent_busy.store(false, Ordering::Relaxed); + } + broadcast_shutdown(&clients, reason).await; + break; + } + LeaderServerPoll::Accept(accept_result) => match accept_result { + Ok(stream) => { + had_clients = true; + let client_id = ClientId::new(); + let (tx, rx) = kanal::unbounded_async(); + clients.insert( + client_id, + ClientState { + tx, + mode: ClientMode::Stdio, + capabilities: ClientCapabilities::default(), + client_type: String::new(), + initialize_seen: false, + patch_initialize_model: false, + registered: false, + }, + ); + spawn_client_handler( + client_id, + stream, + rx, + event_tx.clone(), + cancel.child_token(), + ready_rx.clone(), + control_state.clone(), + ); + } + Err(e) => error!(error = % e, "Accept failed"), + }, + LeaderServerPoll::Event(event) => match event { + ServerEvent::Registered(id, mode, capabilities, client_type) => { + if let Some(client) = clients.get_mut(&id) { + client.mode = mode; + client.capabilities = capabilities; + client.client_type = client_type; + client.registered = true; + client_count.fetch_add(1, Ordering::Relaxed); + debug!( + client_id = id.0, ? mode, yolo_mode = client.capabilities + .yolo_mode, client_type = % client.client_type, + "Client registered" + ); + xai_grok_telemetry::unified_log::info( + "leader.client.registered", + None, + Some(serde_json::json!( + { "client_id" : id.0, "client_type" : client.client_type, } + )), + ); + if mode == ClientMode::Headless { + let newly_demanded = relay_demand_tx.send_if_modified(|demanded| { + let changed = !*demanded; + *demanded = true; + changed + }); + if newly_demanded { + info!( + client_id = id.0, + "First headless client registered; signalling relay demand" + ); + } + } + let effective_leader_version = + leader_version_override.unwrap_or(LEADER_VERSION); + if let Some(ref cv) = client.capabilities.client_version + && let Some(payload) = make_version_mismatch_notification( + cv.as_str(), + effective_leader_version, + ) + { + warn!( + client_id = id.0, + client_version = cv.as_str(), + leader_version = effective_leader_version, + "Version mismatch: client binary differs from leader binary" + ); + let _ = client.tx.try_send(ClientOutbound::Acp(payload.into())); + } + } + } + ServerEvent::Disconnected(id) => { + let was_registered = clients.get(&id).is_some_and(|c| c.registered); + clients.remove(&id); + if was_registered { + client_count.fetch_sub(1, Ordering::Relaxed); + xai_grok_telemetry::unified_log::info( + "leader.client.disconnected", + None, + Some(serde_json::json!({ "client_id" : id.0 })), + ); + } + pending_load_by_req.retain(|_, (c, _)| *c != id); + load_live_buffer.retain(|(c, _), _| *c != id); + load_replay_max_seq.retain(|(c, _), _| *c != id); + let mut detached_sessions: Vec = Vec::new(); + let viewed: Vec = session_subscribers + .iter() + .filter(|(_, subs)| subs.contains(&id)) + .map(|(sid, _)| sid.clone()) + .collect(); + for sid in viewed { + let now_empty = if let Some(subs) = session_subscribers.get_mut(&sid) { + subs.remove(&id); + subs.is_empty() + } else { + true + }; + if now_empty { + session_subscribers.remove(&sid); + session_driver.remove(&sid); + detached_sessions.push(sid); + } else if session_driver.get(&sid) == Some(&id) { + if let Some(&next) = + session_subscribers.get(&sid).and_then(|s| s.iter().next()) + { + session_driver.insert(sid.clone(), next); + debug!( + session_id = % sid, old_driver = id.0, new_driver = next.0, + "Transferred session driver after disconnect" + ); + } else { + session_driver.remove(&sid); + } + } + } + if last_active_client == Some(id) { + last_active_client = None; + } + if !detached_sessions.is_empty() { + let evict_notification = serde_json::json!( + { "jsonrpc" : "2.0", "method" : + "x.ai/internal/evict_sessions", "params" : { "sessionIds" : + detached_sessions } } + ); + let _ = acp_tx.send(evict_notification.to_string()); + info!( + client_id = id.0, + session_count = detached_sessions.len(), + "Sent client-disconnect detach notification for disconnected client" + ); + } + debug!(client_id = id.0, "Client removed"); + if clients.is_empty() && had_clients && !no_exit_on_disconnect { + info!("Leader server shutting down (all clients disconnected)"); + break; + } + } + ServerEvent::Message( + id, + ClientMessage::Control { + request_id, + command, + }, + ) => { + if let Some(client) = clients.get(&id) { + let client_tx = client.tx.clone(); + let control_state = control_state.clone(); + let cancel = cancel.clone(); + let shutdown_tx = shutdown_tx.clone(); + let agent_busy = agent_busy.clone(); + let agent_activity = agent_activity.clone(); + let relaunching = relaunching.clone(); + tokio::spawn(async move { + let result = match command { + ControlCommand::StopCpuProfile => { + handle_stop_cpu_profile(control_state).await + } + ControlCommand::WorkspaceStart { hub_url, cwd } => { + handle_workspace_start( + control_state, + hub_url, + cwd, + cancel.clone(), + ) + .await + } + ControlCommand::WorkspacePause => { + handle_workspace_pause(control_state).await + } + ControlCommand::WorkspaceResume => { + handle_workspace_resume(control_state).await + } + ControlCommand::WorkspaceStop => { + handle_workspace_stop(control_state).await + } + ControlCommand::WorkspaceStatus => { + handle_workspace_status(control_state).await + } + ControlCommand::RelaunchForUpdate { to_version } => { + decide_relaunch_for_update( + &control_state, + to_version, + &relaunching, + ) + } + other => handle_control_command(&control_state, other), + }; + let arm_relaunch = + matches!(result, Ok(ControlPayload::Relaunching { .. })); + if let Err(e) = client_tx + .send(ServerMessage::ControlResult { request_id, result }.into()) + .await + { + warn!( + client_id = id.0, error = % e, + "Failed to send control response to client" + ); + } + if arm_relaunch { + spawn_relaunch_drain( + shutdown_tx, + cancel, + agent_busy, + agent_activity, + ); + } + }); + } + } + ServerEvent::Message(id, ClientMessage::Acp { payload }) => { + let mut json: Option = serde_json::from_str(&payload).ok(); + let mut payload_mutated = false; + if !*ready_rx.borrow() { + if let Some(error_payload) = + json.as_ref().and_then(make_leader_starting_error) + { + if let Some(client) = clients.get(&id) { + let _ = client + .tx + .try_send(ClientOutbound::Acp(error_payload.into())); + } + trace!( + client_id = id.0, + "Returned leader_starting error (not yet ready)" + ); + } else { + trace!( + client_id = id.0, + "Dropped pre-ready notification (leader not yet ready)" + ); + } + continue; + } + if let Some(client) = clients.get(&id) + && client.mode == ClientMode::Stdio + { + last_active_client = Some(id); + } + if let Some(session_id) = json.as_ref().and_then(extract_session_id) { + session_subscribers + .entry(session_id.clone()) + .or_default() + .insert(id); + session_driver.entry(session_id.clone()).or_insert(id); + backfill_child_routes( + &session_id, + id, + &child_sessions, + &mut session_subscribers, + &mut session_driver, + ); + } + if let (Some(json), Some(client)) = (json.as_ref(), clients.get_mut(&id)) { + if let Some(yolo_mode) = extract_yolo_mode_change(json) { + client.capabilities.yolo_mode = yolo_mode; + debug!( + client_id = id.0, + yolo_mode, "Updated client yolo_mode from notification" + ); + } + if let Some(auto_mode) = extract_auto_mode_change(json) { + client.capabilities.auto_mode = auto_mode; + debug!( + client_id = id.0, + auto_mode, "Updated client auto_mode from notification" + ); + } + if let Some(new_model) = extract_model_id_from_set_model(json) { + debug!( + client_id = id.0, model = % new_model, + "Updated client default_model from session/setModel" + ); + client.capabilities.default_model = Some(new_model); + } + } + if let (Some(json), Some(client)) = (json.as_mut(), clients.get_mut(&id)) { + if !client.initialize_seen { + let (injected, was_initialize) = + inject_client_identity_into_initialize(json, &client.client_type); + payload_mutated |= injected; + if was_initialize { + client.initialize_seen = true; + if client + .capabilities + .default_model + .as_ref() + .is_some_and(|m| !m.is_empty()) + { + client.patch_initialize_model = true; + } + } + } + payload_mutated |= inject_capabilities_into_session_new( + json, + &client.capabilities, + &client.client_type, + id, + ); + payload_mutated |= inject_client_identity_into_yolo_notification( + json, + &client.client_type, + ); + } + let rewritten = json.as_mut().and_then(|j| rewrite_request_id(j, id)); + payload_mutated |= rewritten.is_some(); + if let Some(json) = json.as_ref() + && is_session_load_request(json) + && let Some(load_sid) = extract_session_id(json) + && let Some((ns_id, _)) = rewritten.as_ref() + { + pending_load_by_req.insert(ns_id.clone(), (id, load_sid.clone())); + load_live_buffer.entry((id, load_sid)).or_default(); + } + if rewritten.is_some() { + pending_requests += 1; + agent_busy.store(true, Ordering::Relaxed); + } + let outbound = select_outbound_payload(json.as_ref(), payload_mutated, payload); + let _ = acp_tx.send(outbound); + } + ServerEvent::Message(_, _) => {} + }, + LeaderServerPoll::Response(payload) => { + let mut json: Option = serde_json::from_str(&payload).ok(); + let parsed_response = json.as_mut().and_then(parse_response_id); + if parsed_response.is_some() { + pending_requests = pending_requests.saturating_sub(1); + agent_busy.store(pending_requests > 0, Ordering::Relaxed); + } + if let Some((orphan_client, ref orphan_req_id)) = parsed_response + && !clients.contains_key(&orphan_client) + { + warn!( + client_id = orphan_client.0, + request_id = orphan_req_id.as_str(), + "Dropping RPC response: requesting client disconnected (response orphaned)" + ); + xai_grok_telemetry::unified_log::warn( + "leader.response.orphaned", + None, + Some(serde_json::json!( + { "client_id" : orphan_client.0, "request_id" : + orphan_req_id, } + )), + ); + } + if let Some((client_id, ref raw_response_id)) = parsed_response + && let Some(client) = clients.get_mut(&client_id) + && let Some(json) = json.as_mut() + { + if let Some(session_id) = extract_session_id_from_result(json) { + session_subscribers + .entry(session_id.clone()) + .or_default() + .insert(client_id); + session_driver + .entry(session_id.clone()) + .or_insert(client_id); + backfill_child_routes( + &session_id, + client_id, + &child_sessions, + &mut session_subscribers, + &mut session_driver, + ); + trace!( + client_id = client_id.0, + session_id, "Subscribed client to session from response" + ); + } + if client.patch_initialize_model { + client.patch_initialize_model = false; + patch_initialize_response_model(json, &client.capabilities.default_model); + } + let restored_payload: Arc = json.to_string().into(); + match client.tx.try_send(ClientOutbound::Acp(restored_payload)) { + Ok(true) => { + trace!(client_id = client_id.0, "Routed response via request ID"); + } + Ok(false) => { + warn!( + client_id = client_id.0, + "Failed to send response to client (channel full)" + ); + xai_grok_telemetry::unified_log::warn( + "leader.response.send_failed", + None, + Some(serde_json::json!( + { "client_id" : client_id.0, "reason" : "channel_full", } + )), + ); + } + Err(e) => { + warn!( + client_id = client_id.0, error = % e, + "Failed to send response to client (channel closed)" + ); + xai_grok_telemetry::unified_log::warn( + "leader.response.send_failed", + None, + Some(serde_json::json!( + { "client_id" : client_id.0, "reason" : "channel_closed", } + )), + ); + } + } + if let Some((buf_client, buf_sid)) = pending_load_by_req.remove(raw_response_id) + { + let replay_cutoff: Option = + load_replay_max_seq.remove(&(buf_client, buf_sid.clone())); + if let Some(buffered) = + load_live_buffer.remove(&(buf_client, buf_sid.clone())) + && let Some(target) = clients.get(&buf_client) + { + let mut count = 0usize; + let mut deduped = 0usize; + for (buffered_payload, buffered_seq) in buffered { + if let Some(cutoff) = replay_cutoff + && buffered_seq.is_some_and(|s| s <= cutoff) + { + deduped += 1; + continue; + } + if let Err(e) = + target.tx.try_send(ClientOutbound::Acp(buffered_payload)) + { + warn!( + client_id = buf_client.0, error = % e, + "Failed to flush buffered live notification after load (channel closed)" + ); + break; + } + count += 1; + } + if count > 0 || deduped > 0 { + trace!( + client_id = buf_client.0, + count, + deduped, + "Flushed buffered live notifications after load (replay-overlap dropped)" + ); + } + } + if let Some(cached) = interaction_requests.get(buf_sid.as_str()) + && let Some(target) = clients.get(&buf_client) + { + let count = cached.len(); + for req in cached.values() { + if let Err(e) = target.tx.try_send(ClientOutbound::Acp(req.clone())) + { + warn!( + client_id = buf_client.0, error = % e, + "Failed to replay interaction request after load (channel closed)" + ); + break; + } + } + if count > 0 { + trace!( + client_id = buf_client.0, + count, + session_id = buf_sid.as_str(), + "Replayed pending interaction modals to newly-attached client" + ); + } + } + } + continue; + } + let payload: Arc = payload.into(); + let json = json; + if json + .as_ref() + .is_some_and(is_machine_wide_broadcast_notification) + { + for client in clients.values() { + let _ = client.tx.try_send(ClientOutbound::Acp(payload.clone())); + } + trace!("Broadcast machine-wide notification to all clients"); + continue; + } + if let Some(target) = json.as_ref().and_then(extract_target_client_id) { + if let Some(client) = clients.get(&target) { + match json.as_ref().and_then(extract_child_session_event) { + Some(ChildSessionEvent::Spawned(child_sid)) => { + if let Some(parent) = json.as_ref().and_then(extract_session_id) { + child_sessions + .entry(parent) + .or_default() + .insert(child_sid.clone()); + } + debug!( + client_id = target.0, child_session_id = % child_sid, + "Registered child route from replayed SubagentSpawned" + ); + session_subscribers + .entry(child_sid) + .or_default() + .insert(target); + } + Some(ChildSessionEvent::Finished(child_sid)) => { + let emptied = + session_subscribers.get_mut(&child_sid).is_some_and(|subs| { + subs.remove(&target); + subs.is_empty() + }); + if emptied { + prune_child_route( + &child_sid, + &mut session_subscribers, + &mut session_driver, + &mut child_sessions, + ); + } + } + None => {} + } + let replay_seq = json + .as_ref() + .and_then(extract_session_id) + .zip(json.as_ref().and_then(event_seq_of)); + match client.tx.try_send(ClientOutbound::Acp(payload)) { + Ok(true) => { + if let Some((sid, seq)) = replay_seq { + let entry = + load_replay_max_seq.entry((target, sid)).or_insert(0); + *entry = (*entry).max(seq); + } + trace!( + client_id = target.0, + "Unicast replay notification to loading client" + ); + } + Ok(false) => { + warn!( + client_id = target.0, + "Replay notification dropped: loading client channel full (not counted toward flush cutoff)" + ); + } + Err(e) => { + warn!( + client_id = target.0, error = % e, + "Failed to unicast replay notification to loading client (channel closed)" + ); + } + } + } else { + if let Some(ChildSessionEvent::Finished(child_sid)) = + json.as_ref().and_then(extract_child_session_event) + && session_subscribers + .get(&child_sid) + .is_none_or(|subs| subs.is_empty()) + { + prune_child_route( + &child_sid, + &mut session_subscribers, + &mut session_driver, + &mut child_sessions, + ); + } + if orphan_replay_warned.insert(target) { + warn!( + client_id = target.0, + "Dropping targeted replay notification: loading client disconnected mid-replay (rest of burst logged at trace)" + ); + } else { + trace!( + client_id = target.0, + "Dropping targeted replay notification: loading client disconnected mid-replay" + ); + } + } + continue; + } + let session_id = json.as_ref().and_then(extract_session_id).or_else(|| { + json.as_ref() + .and_then(extract_session_id_from_prompt_complete) + }); + if let Some(ref sid) = session_id + && let Some(tcid) = json + .as_ref() + .and_then(extract_interaction_resolved_tool_call_id) + && let Some(map) = interaction_requests.get_mut(sid.as_str()) + { + map.remove(&tcid); + if map.is_empty() { + interaction_requests.remove(sid.as_str()); + } + } + let is_reverse_request = json + .as_ref() + .is_some_and(|j| j.get("id").is_some() && j.get("method").is_some()); + let is_inject_prompt = json.as_ref().is_some_and(is_scheduled_task_inject_prompt); + let is_interaction = + is_reverse_request && json.as_ref().is_some_and(is_interaction_request); + if is_interaction + && let Some(ref sid) = session_id + && let Some(tcid) = json.as_ref().and_then(extract_interaction_tool_call_id) + { + interaction_requests + .entry(sid.clone()) + .or_default() + .insert(tcid, payload.clone()); + } + if let Some(ref sid) = session_id + && session_subscribers.contains_key(sid.as_str()) + { + let child_event = json.as_ref().and_then(extract_child_session_event); + let event_seq = json.as_ref().and_then(event_seq_of); + if (is_reverse_request && !is_interaction) || is_inject_prompt { + if let Some(&driver_id) = session_driver.get(sid.as_str()) { + if let Some(client) = clients.get(&driver_id) { + if let Err(e) = + client.tx.try_send(ClientOutbound::Acp(payload.clone())) + { + warn!( + client_id = driver_id.0, session_id = sid.as_str(), + is_inject = is_inject_prompt, error = % e, + "Failed to route driver-only message (channel closed)" + ); + } else { + trace!( + client_id = driver_id.0, + session_id = sid.as_str(), + is_inject = is_inject_prompt, + "Routed driver-only message to driver" + ); + } + } else { + trace!( + session_id = sid.as_str(), + is_inject = is_inject_prompt, + "Dropping driver-only message: no live driver" + ); + } + } else { + trace!( + session_id = sid.as_str(), + is_inject = is_inject_prompt, + "Dropping driver-only message: session has no driver" + ); + } + } else if let Some(subs) = session_subscribers.get(sid.as_str()) { + for &cid in subs.iter() { + if let Some(buf) = load_live_buffer.get_mut(&(cid, sid.clone())) { + if buf.len() < MAX_BUFFERED_LIVE_PER_LOAD { + buf.push((payload.clone(), event_seq)); + trace!( + client_id = cid.0, + session_id = sid.as_str(), + "Buffered live notification during in-flight load" + ); + continue; + } + warn!( + client_id = cid.0, + session_id = sid.as_str(), + "Live buffer for in-flight load exceeded cap; forwarding live (ordering not guaranteed)" + ); + } + if let Some(client) = clients.get(&cid) { + if let Err(e) = + client.tx.try_send(ClientOutbound::Acp(payload.clone())) + { + warn!( + client_id = cid.0, session_id = sid.as_str(), error = % e, + "Failed to broadcast notification to subscriber (channel closed)" + ); + } else { + trace!( + client_id = cid.0, + session_id = sid.as_str(), + "Broadcast notification to subscriber" + ); + } + } + } + } + match child_event { + Some(ChildSessionEvent::Spawned(child_sid)) => { + let parent_subs = session_subscribers + .get(sid.as_str()) + .cloned() + .unwrap_or_default(); + info!( + child_session_id = % child_sid, subscriber_count = + parent_subs.len(), + "Registered child session from SubagentSpawned" + ); + session_subscribers.insert(child_sid.clone(), parent_subs); + if let Some(&driver_id) = session_driver.get(sid.as_str()) { + session_driver.insert(child_sid.clone(), driver_id); + } + child_sessions + .entry(sid.clone()) + .or_default() + .insert(child_sid); + } + Some(ChildSessionEvent::Finished(child_sid)) => { + debug!( + child_session_id = % child_sid, + "Deregistered child session from SubagentFinished" + ); + prune_child_route( + &child_sid, + &mut session_subscribers, + &mut session_driver, + &mut child_sessions, + ); + } + None => {} + } + continue; + } + let is_notification = json.as_ref().is_some_and(|j| j.get("id").is_none()); + let is_relay_session_notification = is_notification + && session_id + .as_ref() + .is_some_and(|s| !session_subscribers.contains_key(s.as_str())); + if !is_notification { + trace!("Dropping non-routable response (likely relay-originated)"); + } else if is_relay_session_notification { + if let Some(ChildSessionEvent::Finished(child_sid)) = + json.as_ref().and_then(extract_child_session_event) + && session_subscribers + .get(&child_sid) + .is_none_or(|subs| subs.is_empty()) + { + prune_child_route( + &child_sid, + &mut session_subscribers, + &mut session_driver, + &mut child_sessions, + ); + } + trace!( + "Dropping notification for relay-owned session (already delivered via WS)" + ); + } else if let Some(client_id) = last_active_client + && let Some(client) = clients.get(&client_id) + { + debug!( + client_id = client_id.0, + "Using fallback routing to last active client" + ); + if let Err(e) = client.tx.try_send(ClientOutbound::Acp(payload)) { + warn!( + client_id = client_id.0, error = % e, + "Failed to send notification via fallback routing (channel closed)" + ); + } + } else { + debug!("No client available for notification routing, message dropped"); + } + } } } finalize_workspace_on_shutdown(control_state.clone()).await; @@ -1925,10 +2428,8 @@ async fn run_client_session( ); while !*ready_rx.borrow() { tokio::select! { - biased; _ = cancel.cancelled() => { for _ in 0..10 { if ! server_rx - .is_empty() { break; } tokio::task::yield_now(). await; } while let - Ok(Some(msg)) = server_rx.try_recv() { if write_outbound(& mut writer, & - msg). await .is_err() { break; } } return Ok(()); } result = ready_rx + biased; _ = cancel.cancelled() => { drain_client_outbound_on_cancel(& + server_rx, & mut writer). await; return Ok(()); } result = ready_rx .changed() => { if result.is_err() { return Ok(()); } } } } @@ -1953,25 +2454,78 @@ async fn run_client_session( ); loop { tokio::select! { - biased; _ = cancel.cancelled() => { for _ in 0..10 { if ! server_rx - .is_empty() { break; } tokio::task::yield_now(). await; } while let - Ok(Some(msg)) = server_rx.try_recv() { if write_outbound(& mut writer, & msg) - . await .is_err() { break; } } break; } Ok(msg) = server_rx.recv() => { if + biased; _ = cancel.cancelled() => { drain_client_outbound_on_cancel(& + server_rx, & mut writer). await; break; } Ok(msg) = server_rx.recv() => { if write_outbound(& mut writer, & msg). await .is_err() { break; } } msg_result - = read_message::< _, ClientMessage > (& mut reader) => { match msg_result { - Ok(msg @ (ClientMessage::Acp { .. } | ClientMessage::Control { .. })) => { - let _ = event_tx.send(ServerEvent::Message(client_id, msg)). await; } - Ok(ClientMessage::Ping) => { write_message(& mut writer, & - ServerMessage::Pong). await ?; } Ok(ClientMessage::Disconnect) | - Err(ProtocolError::ConnectionClosed) => { info!(client_id = client_id.0, - "Client disconnected"); break; } Ok(ClientMessage::Register { .. }) => { - write_message(& mut writer, & ServerMessage::Error { code : 2, message : - "Already registered".into(), }). await ?; } Err(e) => { warn!(client_id = - client_id.0, error = % e, "Protocol error"); break; } } } + = read_message::< _, ClientMessage > (& mut reader) => { match + handle_client_inbound_message(msg_result, client_id, & event_tx, & mut + writer,). await ? { ClientSessionAction::Continue => {} + ClientSessionAction::Break => break, } } } } Ok(()) } +enum ClientSessionAction { + Continue, + Break, +} +async fn drain_client_outbound_on_cancel( + server_rx: &AsyncReceiver, + writer: &mut W, +) where + W: tokio::io::AsyncWrite + Unpin, +{ + for _ in 0..10 { + if !server_rx.is_empty() { + break; + } + tokio::task::yield_now().await; + } + while let Ok(Some(msg)) = server_rx.try_recv() { + if write_outbound(writer, &msg).await.is_err() { + break; + } + } +} +async fn handle_client_inbound_message( + msg_result: Result, + client_id: ClientId, + event_tx: &AsyncSender, + writer: &mut W, +) -> Result +where + W: tokio::io::AsyncWrite + Unpin, +{ + match msg_result { + Ok(msg @ (ClientMessage::Acp { .. } | ClientMessage::Control { .. })) => { + let _ = event_tx.send(ServerEvent::Message(client_id, msg)).await; + Ok(ClientSessionAction::Continue) + } + Ok(ClientMessage::Ping) => { + write_message(writer, &ServerMessage::Pong).await?; + Ok(ClientSessionAction::Continue) + } + Ok(ClientMessage::Disconnect) | Err(ProtocolError::ConnectionClosed) => { + info!(client_id = client_id.0, "Client disconnected"); + Ok(ClientSessionAction::Break) + } + Ok(ClientMessage::Register { .. }) => { + write_message( + writer, + &ServerMessage::Error { + code: 2, + message: "Already registered".into(), + }, + ) + .await?; + Ok(ClientSessionAction::Continue) + } + Err(e) => { + warn!(client_id = client_id.0, error = % e, "Protocol error"); + Ok(ClientSessionAction::Break) + } + } +} /// Broadcast a planned shutdown to all connected clients. /// /// Sends `ShuttingDown` (advance notice with reason and `delay_ms: 0`) diff --git a/crates/codegen/xai-grok-shell/src/lib.rs b/crates/codegen/xai-grok-shell/src/lib.rs index f298784..0cce6fc 100644 --- a/crates/codegen/xai-grok-shell/src/lib.rs +++ b/crates/codegen/xai-grok-shell/src/lib.rs @@ -5,6 +5,9 @@ unreachable_code, dead_code )] +#[cfg(all(test, feature = "dhat-heap"))] +#[global_allocator] +static DHAT_ALLOC: dhat::Alloc = dhat::Alloc; pub(crate) use xai_grok_telemetry::unified_log; pub use xai_tracing_macros::{teprintln, timed, tprintln}; pub mod active_sessions; diff --git a/crates/codegen/xai-grok-shell/src/sampling/error.rs b/crates/codegen/xai-grok-shell/src/sampling/error.rs index db7534c..0c4e626 100644 --- a/crates/codegen/xai-grok-shell/src/sampling/error.rs +++ b/crates/codegen/xai-grok-shell/src/sampling/error.rs @@ -13,11 +13,9 @@ use agent_client_protocol as acp; /// Uses the JSON-RPC implementation-defined server error range (-32000 to -32099). /// /// Contract: set only for actual HTTP 429 responses from the sampling client. -/// User-facing text is produced by [`format_rate_limited_user_message`] (free-usage -/// paywall rewrite, else server body, else a generic fallback). Pager/headless -/// use that helper. Desktop may still special-case `stopReason: rate_limit` with -/// its own UI and ignore the body — that is a client choice, not a shell -/// requirement to suppress detail. +/// Clients derive user-facing text via [`format_rate_limited_user_message`]. +/// The desktop path is unchanged: `prompt_complete_fields` still reports the +/// stop reason with no detail. pub const RATE_LIMITED_ERROR_CODE: i32 = -32003; /// OAuth / session rate-limit copy (personal plan upgrade path). @@ -29,20 +27,6 @@ pub const RATE_LIMITED_USER_MESSAGE_OAUTH: &str = /// See https://docs.x.ai/developers/rate-limits#rate-limit-tiers pub const RATE_LIMITED_USER_MESSAGE_API_KEY: &str = "You\u{2019}ve hit your team\u{2019}s API rate limit. Ask a team admin to purchase more credits for higher limits, or try again later. See https://docs.x.ai/developers/rate-limits#rate-limit-tiers"; -/// Pick rate-limit copy from the *active* auth method. -/// -/// Pass the real `is_api_key_auth` flag (pager `AppView`, `AuthMethodKind::is_api_key` -/// for the selected method). Do **not** decide from `has_xai_api_key_env()` alone: -/// when both an env key and a cached OAuth session exist, auth prefers the -/// cached session over the API key. -pub fn rate_limited_user_message(is_api_key_auth: bool) -> &'static str { - if is_api_key_auth { - RATE_LIMITED_USER_MESSAGE_API_KEY - } else { - RATE_LIMITED_USER_MESSAGE_OAUTH - } -} - /// Well-known free-usage exhaustion code CCP returns on HTTP 429. /// Matches `prod_util_well_known_errors::SUBSCRIPTION_FREE_USAGE_EXHAUSTED`. /// sampling-types' `parse_error_bytes` prepends the flat `code` to the @@ -62,29 +46,45 @@ pub fn is_free_usage_exhausted_error(detail: &str) -> bool { /// User-facing text for an ACP -32003 rate-limit error. /// -/// 1. Free-usage well-known code → [`FREE_USAGE_USER_MESSAGE`] (OAuth product paywall). -/// 2. Non-empty body that pushes a **personal** Grok subscription, when the -/// caller is on **API key** auth → [`RATE_LIMITED_USER_MESSAGE_API_KEY`] -/// (team credits / console rate-limit tiers; not grok.com SuperGrok). -/// 3. Any other non-empty body → shown as-is (capacity, team RPS limits, etc.). -/// 4. Empty body → [`rate_limited_user_message`]. -/// -/// Pass the real active auth method (`AppView.is_api_key_auth` / -/// `AuthMethodKind::is_api_key`), not env-only key presence. +/// Free-usage code first (consumer-only; intentional before API-key rewrite). +/// API-key + personal SuperGrok upsell → team credits copy. Else the body +/// after stripping `API error (status …):` (SamplingError Display prefix). +/// Empty → OAuth vs API-key fallback. Callers that show this in UI should +/// still run their usual sanitizer (scrub/cap). pub fn format_rate_limited_user_message( server_detail: Option<&str>, is_api_key_auth: bool, ) -> String { + // Free-usage sniff works on the prefixed wire string (`contains` the code). if server_detail.is_some_and(is_free_usage_exhausted_error) { return FREE_USAGE_USER_MESSAGE.to_string(); } if let Some(detail) = server_detail.map(str::trim).filter(|s| !s.is_empty()) { + let detail = strip_sampling_api_error_prefix(detail); if is_api_key_auth && pushes_consumer_subscription_upsell(detail) { return RATE_LIMITED_USER_MESSAGE_API_KEY.to_string(); } return detail.to_string(); } - rate_limited_user_message(is_api_key_auth).to_string() + if is_api_key_auth { + RATE_LIMITED_USER_MESSAGE_API_KEY + } else { + RATE_LIMITED_USER_MESSAGE_OAUTH + } + .to_string() +} + +/// Drop `SamplingError::Api`'s Display prefix so users see the IC body, not +/// `API error (status 429 Too Many Requests): …`. +fn strip_sampling_api_error_prefix(detail: &str) -> &str { + const PREFIX: &str = "API error (status "; + const SEP: &str = "): "; + if let Some(rest) = detail.strip_prefix(PREFIX) + && let Some(idx) = rest.find(SEP) + { + return rest[idx + SEP.len()..].trim(); + } + detail.trim() } /// IC sometimes reuses OAuth free-tier upsell copy on 429s ("upgrade to a Grok @@ -371,13 +371,13 @@ mod tests { } #[test] - fn rate_limited_user_message_oauth_vs_api_key() { + fn rate_limited_fallback_oauth_vs_api_key() { assert_eq!( - rate_limited_user_message(false), + format_rate_limited_user_message(None, false), RATE_LIMITED_USER_MESSAGE_OAUTH ); assert_eq!( - rate_limited_user_message(true), + format_rate_limited_user_message(None, true), RATE_LIMITED_USER_MESSAGE_API_KEY ); assert!(RATE_LIMITED_USER_MESSAGE_OAUTH.contains("Upgrade your account")); @@ -392,19 +392,19 @@ mod tests { #[test] fn format_rate_limited_surfaces_nonempty_server_detail() { - let service = "The service is temporarily at capacity. Please retry your request shortly."; - assert_eq!( - format_rate_limited_user_message(Some(service), false), - service - ); - assert_eq!( - format_rate_limited_user_message(Some(service), true), - service - ); + let body = "The service is temporarily at capacity. Please retry your request shortly."; + // Production detail is SamplingError::Api Display (prefixed). + let wire = format!("API error (status 429 Too Many Requests): {body}"); + assert_eq!(format_rate_limited_user_message(Some(&wire), false), body); + assert_eq!(format_rate_limited_user_message(Some(&wire), true), body); // Team console rate-limit copy has no personal SuperGrok upsell — surface as-is. let team = "resource-exhausted: Too many requests for team abc. See https://console.x.ai/team/default/rate-limits."; - assert_eq!(format_rate_limited_user_message(Some(team), true), team); + let team_wire = format!("API error (status 429 Too Many Requests): {team}"); + assert_eq!( + format_rate_limited_user_message(Some(&team_wire), true), + team + ); assert_eq!( format_rate_limited_user_message(Some("slow down"), false), "slow down" @@ -413,21 +413,25 @@ mod tests { #[test] fn format_rate_limited_api_key_rewrites_consumer_subscription_upsell() { - let rpm = "Some resource has been exhausted: You are sending requests too quickly. \ + let body = "Some resource has been exhausted: You are sending requests too quickly. \ Please slow down, or upgrade to a Grok subscription for higher limits: \ https://grok.com/supergrok"; + let wire = format!("API error (status 429 Too Many Requests): {body}"); // OAuth keeps the IC body (personal plan upgrade is correct). - assert_eq!(format_rate_limited_user_message(Some(rpm), false), rpm); + assert_eq!(format_rate_limited_user_message(Some(&wire), false), body); // API key must not push grok.com SuperGrok — team credits / rate-limit tiers. assert_eq!( - format_rate_limited_user_message(Some(rpm), true), + format_rate_limited_user_message(Some(&wire), true), RATE_LIMITED_USER_MESSAGE_API_KEY ); - assert!( - RATE_LIMITED_USER_MESSAGE_API_KEY - .contains("https://docs.x.ai/developers/rate-limits#rate-limit-tiers") - ); - assert!(!RATE_LIMITED_USER_MESSAGE_API_KEY.contains("grok.com/supergrok")); + } + + #[test] + fn format_rate_limited_strips_api_error_display_prefix() { + let body = "The service is temporarily at capacity."; + let wire = format!("API error (status 429 Too Many Requests): {body}"); + assert_eq!(format_rate_limited_user_message(Some(&wire), false), body); + assert!(!format_rate_limited_user_message(Some(&wire), false).contains("API error")); } #[test] @@ -446,13 +450,15 @@ mod tests { #[test] fn format_rate_limited_free_usage_uses_paywall_copy() { - let detail = "subscription:free-usage-exhausted: You have used all your free usage."; + let wire = "API error (status 429 Too Many Requests): \ + subscription:free-usage-exhausted: You have used all your free usage."; assert_eq!( - format_rate_limited_user_message(Some(detail), false), + format_rate_limited_user_message(Some(wire), false), FREE_USAGE_USER_MESSAGE ); + // Free-usage code is consumer-only; still wins for API-key callers. assert_eq!( - format_rate_limited_user_message(Some(detail), true), + format_rate_limited_user_message(Some(wire), true), FREE_USAGE_USER_MESSAGE ); } diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session.rs b/crates/codegen/xai-grok-shell/src/session/acp_session.rs index 5949788..63ffbd2 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session.rs @@ -10,7 +10,7 @@ //! use super::commands::{ ParsedPromptInfo, PromptCompletionKind, PromptTurnOk, PromptTurnResult, SessionCommand, - ok_end_turn, + TaskWakeAdmission, TaskWakeFallback, ok_end_turn, }; use super::handle::SessionHandle; use super::notifications::NotificationSender; @@ -196,6 +196,9 @@ pub(crate) struct InputItem { pub(crate) json_schema: Option, /// Who originated this prompt — user or auto-wake system. pub(crate) origin: super::PromptOrigin, + /// Typed deferred completion retained while an admitted task wake is queued. + /// Consumed by Ctrl+C if it removes the wake before the turn starts. + pub(crate) task_wake_fallback: Option, pub(crate) respond_to: oneshot::Sender, /// Fired after the user message is in chat history and a persistence flush /// barrier has completed (see `SessionCommand::Prompt::persist_ack`). @@ -271,8 +274,8 @@ pub(crate) struct State { pub(crate) running_task: Option, pub(crate) pending_inputs: VecDeque, pub(crate) pending_notifications: Vec, - /// When true, notifications are buffered but not drained until the next - /// user-initiated prompt arrives. Set on cancel, cleared on user Prompt. + /// When true, notifications are buffered but not drained until genuine + /// user re-engagement. Set by interactive Ctrl+C, cleared by a user prompt. pub(crate) notifications_suppressed: bool, /// Active prompt is still rewindable until the first outbound prompt-scoped /// event is emitted. @@ -300,13 +303,13 @@ impl State { } /// Sweep `pending_inputs`, removing entries matching `drop_if` EXCEPT the /// running turn's own slot, and return the removed items (callers harvest - /// them for telemetry counts / `auto_wake_delivered` un-marks). + /// them for telemetry counts / reservation releases). /// /// Returned items still carry live `respond_to` senders that this helper /// does NOT resolve — dropping them unfulfilled is correct only for /// synthetic items (no client RPC awaits them, the current callers); a /// caller whose predicate can match user-originated items must resolve - /// each returned item (see `respond_removed_queued_prompt`) or the + /// each returned item (see `respond_removed_prompt`) or the /// client's `session/prompt` hangs and fails spuriously. /// /// The guard is the safety invariant every sweep must inherit: the @@ -343,7 +346,8 @@ impl State { /// so they share one definition of idleness, with no drift between them. /// /// Returns `true` exactly when: no turn is running, no user prompt is -/// queued, and notifications haven't been suppressed by a cancel. +/// queued, and interactive Ctrl+C has not suppressed notifications pending +/// genuine user re-engagement. pub(crate) fn is_session_idle_for_injection(state: &State) -> bool { state.running_task.is_none() && state.pending_inputs.is_empty() diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/goal.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/goal.rs index 8d35387..b8f8d10 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/goal.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/goal.rs @@ -2202,6 +2202,7 @@ impl SessionActor { verbatim: true, json_schema: None, origin: super::super::PromptOrigin::GoalSummary, + task_wake_fallback: None, respond_to, persist_ack: None, parsed_prompt_tx: None, diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/interjection.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/interjection.rs index 91e7f14..d34c20d 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/interjection.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/interjection.rs @@ -73,6 +73,7 @@ impl SessionActor { verbatim: false, json_schema: None, origin: super::super::PromptOrigin::User, + task_wake_fallback: None, respond_to, persist_ack: None, parsed_prompt_tx: None, diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/model_switch.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/model_switch.rs index 2b827f2..2338367 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/model_switch.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/model_switch.rs @@ -216,6 +216,12 @@ impl SessionActor { ), ) .await; + if let Some(reservations) = self.tool_context.task_completion_reservations.clone() { + bridge.update_resource(reservations).await; + } + if let Some(gate) = self.tool_context.task_wake_suppressed.clone() { + bridge.update_resource(gate).await; + } self.inject_deny_read_globs().await; } { diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/notification_drain.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/notification_drain.rs index 25265af..cd6e5ec 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/notification_drain.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/notification_drain.rs @@ -19,6 +19,99 @@ pub(crate) struct PendingNotification { } impl SessionActor { + pub(super) fn push_pending_notification(state: &mut State, notification: PendingNotification) { + state.pending_notifications.push(notification); + let excess = state + .pending_notifications + .len() + .saturating_sub(MAX_PENDING_NOTIFICATIONS); + if excess > 0 { + state.pending_notifications.drain(..excess); + tracing::warn!( + dropped = excess, + "Dropped oldest pending notifications (exceeded cap of {})", + MAX_PENDING_NOTIFICATIONS, + ); + } + } + + pub(super) fn push_task_wake_fallback(state: &mut State, fallback: TaskWakeFallback) { + Self::push_pending_notification( + state, + PendingNotification { + prompt_id: fallback.prompt_id, + prompt_blocks: fallback.prompt_blocks, + priority: NotificationPriority::Later, + source: fallback.source, + }, + ); + } + + pub(super) async fn consume_deferred_completions(&self) -> Vec { + let mut state = self.state.lock().await; + self.sweep_monitor_buffer_into_pending(&mut state, "monitor-user-start-drain"); + let mut completion_ids: Vec = state + .pending_notifications + .iter() + .filter_map(|notification| match ¬ification.source { + NotificationSource::BashTaskCompleted { task_id } + | NotificationSource::MonitorCompleted { task_id } => Some(task_id.clone()), + NotificationSource::MonitorEvent { .. } => None, + }) + .collect(); + completion_ids.sort(); + completion_ids.dedup(); + let deferred_ids: std::collections::HashSet<&str> = + completion_ids.iter().map(String::as_str).collect(); + + let notifications = std::mem::take(&mut state.pending_notifications); + let mut deferred = Vec::new(); + let mut retained = Vec::new(); + for notification in notifications { + let consume = match ¬ification.source { + NotificationSource::BashTaskCompleted { .. } + | NotificationSource::MonitorCompleted { .. } => true, + NotificationSource::MonitorEvent { task_id } => { + deferred_ids.contains(task_id.as_str()) + } + }; + if consume { + deferred.push(notification); + } else { + retained.push(notification); + } + } + state.pending_notifications = retained; + + let completion_blocks = + Self::notification_blocks(&deferred, &self.tool_context.task_output_tool_name); + drop(state); + + let completion_text = completion_blocks + .into_iter() + .filter_map(|block| match block { + acp::ContentBlock::Text(text) => Some(text.text), + _ => None, + }) + .collect::>() + .join("\n"); + if !completion_text.is_empty() { + self.push_system_reminder(&completion_text); + } + let completion_id_refs: Vec<&str> = completion_ids.iter().map(String::as_str).collect(); + self.mark_completions_reported(&completion_id_refs).await; + completion_ids + } + + pub(super) async fn consume_deferred_completions_for_user_turn(&self) { + let consumed = self.consume_deferred_completions().await; + if let Some(reservations) = &self.tool_context.task_completion_reservations { + for task_id in consumed { + reservations.release(&task_id); + } + } + } + pub(super) async fn maybe_start_running_task( self: Arc, completion_tx: mpsc::UnboundedSender<(String, PromptTurnResult)>, @@ -70,6 +163,7 @@ impl SessionActor { screen_mode, verbatim, json_schema, + origin, ) = { let Some(front) = state.pending_inputs.front_mut() else { return; @@ -86,8 +180,20 @@ impl SessionActor { front.screen_mode.clone(), front.verbatim, front.json_schema.clone(), + front.origin.clone(), ) }; + if matches!(origin, super::PromptOrigin::User) { + if let Some(gate) = &self.tool_context.task_wake_suppressed { + gate.set(false); + } + state.notifications_suppressed = false; + xai_grok_telemetry::unified_log::info( + "shell.task_wake.gate_cleared", + Some(self.session_info.id.0.as_ref()), + Some(serde_json::json!({ "reason": "queued_user_promotion" })), + ); + } { let mut current_prompt_id = self .current_prompt_id @@ -262,16 +368,19 @@ impl SessionActor { buffer, Some(self.session_info.id.0.as_ref()), ) { - state.pending_notifications.push(PendingNotification { - prompt_id: format!("{prompt_id_prefix}-{}", uuid::Uuid::now_v7()), - prompt_blocks: vec![acp::ContentBlock::Text(acp::TextContent::new( - event.event_text, - ))], - priority: NotificationPriority::Next, - source: NotificationSource::MonitorEvent { - task_id: event.task_id, + Self::push_pending_notification( + state, + PendingNotification { + prompt_id: format!("{prompt_id_prefix}-{}", uuid::Uuid::now_v7()), + prompt_blocks: vec![acp::ContentBlock::Text(acp::TextContent::new( + event.event_text, + ))], + priority: NotificationPriority::Next, + source: NotificationSource::MonitorEvent { + task_id: event.task_id, + }, }, - }); + ); } } @@ -303,35 +412,34 @@ impl SessionActor { (to_surface, dropped) } - /// Build the merged `NotificationDrain` `InputItem` from `notifications` - /// and push it onto `state.pending_inputs`. Always returns `true` so the - /// caller starts the running task. - /// - /// Monitor-event notifications are collapsed into ONE - /// `format_monitor_events` block (same batched/deduped shape as the - /// mid-turn injection — `` groups with `[label N]` - /// lines), placed at the position of the first monitor entry. Other - /// notifications keep their raw blocks. Everything joins with `---`. - pub(super) fn drain_notifications_into_turn( - state: &mut State, - notifications: Vec, + fn notification_blocks( + notifications: &[PendingNotification], task_output_tool_name: &str, - ) -> bool { + ) -> Vec { use xai_grok_tools::implementations::grok_build::task::types::MonitorEventNotification; - // Collapse monitor entries: collect their text into events, remember - // where the first one sat so the batch lands in arrival position. + let completion_task_ids: std::collections::HashSet<&str> = notifications + .iter() + .filter_map(|notification| match ¬ification.source { + NotificationSource::MonitorCompleted { task_id } => Some(task_id.as_str()), + NotificationSource::MonitorEvent { .. } + | NotificationSource::BashTaskCompleted { .. } => None, + }) + .collect(); let mut monitor_events: Vec = Vec::new(); let mut sections: Vec> = Vec::new(); let mut monitor_section_idx: Option = None; - for notif in ¬ifications { - match ¬if.source { + for notification in notifications { + match ¬ification.source { NotificationSource::MonitorEvent { task_id } => { - let event_text = notif + if completion_task_ids.contains(task_id.as_str()) { + continue; + } + let event_text = notification .prompt_blocks .iter() - .filter_map(|b| match b { - acp::ContentBlock::Text(t) => Some(t.text.as_str()), + .filter_map(|block| match block { + acp::ContentBlock::Text(text) => Some(text.text.as_str()), _ => None, }) .collect::>() @@ -343,33 +451,42 @@ impl SessionActor { }); if monitor_section_idx.is_none() { monitor_section_idx = Some(sections.len()); - sections.push(Vec::new()); // placeholder, filled below + sections.push(Vec::new()); } } - NotificationSource::BashTaskCompleted { .. } => { - sections.push(notif.prompt_blocks.clone()); + NotificationSource::MonitorCompleted { .. } + | NotificationSource::BashTaskCompleted { .. } => { + sections.push(notification.prompt_blocks.clone()); } } } - if let (Some(idx), Some(batch)) = ( + if let (Some(index), Some(batch)) = ( monitor_section_idx, xai_grok_tools::reminders::task_completion::format_monitor_events( &monitor_events, Some(task_output_tool_name), ), ) { - sections[idx] = vec![acp::ContentBlock::Text(acp::TextContent::new(batch))]; + sections[index] = vec![acp::ContentBlock::Text(acp::TextContent::new(batch))]; } - let mut merged_blocks: Vec = Vec::new(); - for (i, section) in sections.iter().enumerate() { - if i > 0 { - merged_blocks.push(acp::ContentBlock::Text(acp::TextContent::new( - "---".to_string(), - ))); + let mut blocks = Vec::new(); + for (index, section) in sections.iter().enumerate() { + if index > 0 { + blocks.push(acp::ContentBlock::Text(acp::TextContent::new("---"))); } - merged_blocks.extend(section.iter().cloned()); + blocks.extend(section.iter().cloned()); } + blocks + } + + /// Merge notifications into one queued `NotificationDrain` turn. + pub(super) fn drain_notifications_into_turn( + state: &mut State, + notifications: Vec, + task_output_tool_name: &str, + ) -> bool { + let merged_blocks = Self::notification_blocks(¬ifications, task_output_tool_name); let merged_prompt_id = format!("notifications-{}", uuid::Uuid::now_v7()); @@ -389,6 +506,7 @@ impl SessionActor { verbatim: true, json_schema: None, origin: super::PromptOrigin::NotificationDrain, + task_wake_fallback: None, respond_to, persist_ack: None, parsed_prompt_tx: None, @@ -402,6 +520,7 @@ impl SessionActor { later_count = notifications.iter().filter(|n| n.priority == NotificationPriority::Later).count(), sources = %notifications.iter().map(|n| match &n.source { NotificationSource::MonitorEvent { task_id } => format!("monitor:{task_id}"), + NotificationSource::MonitorCompleted { task_id } => format!("monitor-completed:{task_id}"), NotificationSource::BashTaskCompleted { task_id } => format!("bash:{task_id}"), }).collect::>().join(","), "Drained pending notifications into single batched turn" diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/prompt_build.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/prompt_build.rs index 11a83fa..b64881b 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/prompt_build.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/prompt_build.rs @@ -3,11 +3,6 @@ //! payload preparation. #![allow(clippy::items_after_test_module)] use super::*; -/// Partition the AGENTS.md / Claude.md / `.grok/rules/*.md` files returned -/// by `read_agents_config_with_paths` into "workspace" (cwd / repo root / -/// extra workspace user dir) and "user" (`~/.grok/`, `~/.claude/`, -/// `~/.grok/bundled/`) buckets, mirroring the split between -/// `` and ``. /// Normalize a free-form name (e.g. an MCP server identifier) into a /// single safe filesystem segment. /// @@ -41,20 +36,24 @@ pub(super) fn pick_user_image_url(image: &agent_client_protocol::ImageContent) - } fn partition_rules_by_scope( files: Vec, + grok_home: &std::path::Path, + vendor_homes: &[(std::path::PathBuf, bool)], + workspace_root: Option<&std::path::Path>, ) -> ( Vec, Vec, ) { - let home = dirs::home_dir().map(|p| p.to_string_lossy().to_string()); - let user_prefixes: Vec = match home { - Some(h) => vec![format!("{h}/.grok/"), format!("{h}/.claude/")], - None => vec![], - }; let mut workspace = Vec::new(); let mut user = Vec::new(); - for f in files { - let entry = xai_grok_agent::prompt::user_message::RuleEntry::from(f); - if user_prefixes.iter().any(|p| entry.path.starts_with(p)) { + for file in files { + let is_user_rule = crate::util::is_user_instruction_path( + std::path::Path::new(&file.file_path), + grok_home, + vendor_homes, + workspace_root, + ); + let entry = xai_grok_agent::prompt::user_message::RuleEntry::from(file); + if is_user_rule { user.push(entry); } else { workspace.push(entry); @@ -62,6 +61,133 @@ fn partition_rules_by_scope( } (workspace, user) } +#[cfg(test)] +mod partition_rules_by_scope_tests { + use super::partition_rules_by_scope; + use std::path::Path; + use xai_grok_agent::prompt::agents_md::AgentConfigFile; + fn file(path: &str) -> AgentConfigFile { + AgentConfigFile { + file_name: Path::new(path) + .file_name() + .unwrap() + .to_string_lossy() + .into_owned(), + file_path: path.to_string(), + content: path.to_string(), + } + } + fn paths(entries: &[xai_grok_agent::prompt::user_message::RuleEntry]) -> Vec<&str> { + entries.iter().map(|entry| entry.content.as_str()).collect() + } + #[test] + fn partitions_custom_grok_and_vendor_home_rules_as_user_scope() { + let files = vec![ + file("/custom/config/rules/a.md"), + file("/home/user/.cursor/rules/b.md"), + file("/repo/.cursor/rules/c.md"), + file("/repo/src/AGENTS.md"), + file("/custom/config/rules/d.md"), + ]; + let vendor_homes = vec![ + (Path::new("/home/user/.claude").to_path_buf(), true), + (Path::new("/home/user/.cursor").to_path_buf(), true), + ]; + let (workspace, user) = partition_rules_by_scope( + files, + Path::new("/custom/config"), + &vendor_homes, + Some(Path::new("/repo")), + ); + assert_eq!( + paths(&user), + vec![ + "/custom/config/rules/a.md", + "/home/user/.cursor/rules/b.md", + "/custom/config/rules/d.md", + ] + ); + assert_eq!( + paths(&workspace), + vec!["/repo/.cursor/rules/c.md", "/repo/src/AGENTS.md"] + ); + } + #[test] + fn grok_home_nested_in_workspace_keeps_direct_surfaces_user_scoped() { + let files = vec![ + file("/repo/config/AGENTS.md"), + file("/repo/config/rules/global.md"), + file("/repo/config/.grok/rules/project.md"), + file("/repo/config/src/AGENTS.md"), + ]; + let (workspace, user) = partition_rules_by_scope( + files, + Path::new("/repo/config"), + &[], + Some(Path::new("/repo")), + ); + assert_eq!( + paths(&user), + vec!["/repo/config/AGENTS.md", "/repo/config/rules/global.md"] + ); + assert_eq!( + paths(&workspace), + vec![ + "/repo/config/.grok/rules/project.md", + "/repo/config/src/AGENTS.md", + ] + ); + } + #[test] + fn vendor_home_nested_in_workspace_keeps_direct_surfaces_user_scoped() { + let files = vec![ + file("/repo/.claude/rules/global.md"), + file("/repo/.claude/CLAUDE.md"), + file("/repo/.claude/.claude/rules/project.md"), + file("/repo/.claude/src/AGENTS.md"), + ]; + let vendor_homes = vec![(Path::new("/repo/.claude").to_path_buf(), true)]; + let (workspace, user) = partition_rules_by_scope( + files, + Path::new("/other/grok"), + &vendor_homes, + Some(Path::new("/repo")), + ); + assert_eq!( + paths(&user), + vec!["/repo/.claude/rules/global.md", "/repo/.claude/CLAUDE.md"] + ); + assert_eq!( + paths(&workspace), + vec![ + "/repo/.claude/.claude/rules/project.md", + "/repo/.claude/src/AGENTS.md", + ] + ); + } + #[test] + fn nested_grok_home_workspace_files_stay_workspace_scoped() { + let files = vec![ + file("/custom/grok/rules/global.md"), + file("/custom/grok/worktrees/repo/.cursor/rules/project.md"), + file("/custom/grok/worktrees/repo/src/AGENTS.md"), + ]; + let (workspace, user) = partition_rules_by_scope( + files, + Path::new("/custom/grok"), + &[], + Some(Path::new("/custom/grok/worktrees/repo")), + ); + assert_eq!(paths(&user), vec!["/custom/grok/rules/global.md"]); + assert_eq!( + paths(&workspace), + vec![ + "/custom/grok/worktrees/repo/.cursor/rules/project.md", + "/custom/grok/worktrees/repo/src/AGENTS.md", + ] + ); + } +} /// True iff `conversation` already contains a project-instructions reminder, /// either tagged [`SyntheticReason::ProjectInstructions`] or a legacy untagged /// copy whose first text part starts with [`LEGACY_AGENTS_MD_REMINDER_PREFIX`]. @@ -393,7 +519,31 @@ impl SessionActor { let bridge = self.agent.borrow().tool_bridge().clone(); let (vcs_root, vcs_status) = self.gather_vcs_for_prefix(cwd).await; let agents_files = read_agents_config_with_paths(&cwd_str, self.rebuild_spec.compat).await; - let (workspace_rules, user_rules) = partition_rules_by_scope(agents_files); + let grok_home = xai_grok_config::grok_home(); + let vendor_homes = dirs::home_dir() + .map(|home_dir| { + vec![ + ( + home_dir.join(".claude"), + self.rebuild_spec.compat.claude.agents, + ), + ( + home_dir.join(".cursor"), + self.rebuild_spec.compat.cursor.agents, + ), + ] + }) + .unwrap_or_default(); + let workspace_root = git2::Repository::discover(cwd) + .ok() + .and_then(|repo| repo.workdir().map(std::path::Path::to_path_buf)) + .unwrap_or_else(|| cwd.to_path_buf()); + let (workspace_rules, user_rules) = partition_rules_by_scope( + agents_files, + &grok_home, + &vendor_homes, + Some(&workspace_root), + ); let skills = bridge.slash_skills().await; let mcp_servers = self.gather_mcp_servers(cwd).await; let shell = resolve_session_shell(); diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/prompt_queue.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/prompt_queue.rs index 20e2d5d..84ebdfd 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/prompt_queue.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/prompt_queue.rs @@ -18,6 +18,7 @@ impl SessionActor { verbatim: bool, json_schema: Option, send_now: bool, + task_wake_fallback: Option, respond_to: oneshot::Sender, persist_ack: Option>, parsed_prompt_tx: Option>, @@ -84,10 +85,8 @@ impl SessionActor { (trace_gcs_config, artifact_tracker) }; - // Pre-mark auto-wake completion IDs so TaskCompletionReminder doesn't - // duplicate the notification the model already sees in the synthetic prompt. - if let Some(id) = origin.completion_id() { - self.mark_completions_reported(&[id]).await; + if let crate::session::PromptOrigin::SubagentCompleted { subagent_id } = &origin { + self.mark_completions_reported(&[subagent_id]).await; } // For synthetic prompts, derive trace config from the template @@ -133,17 +132,18 @@ impl SessionActor { }); if preempt_armed { let dropped = state.sweep_pending_inputs(|i| i.origin.is_synthetic()); + if let Some(reservations) = &self.tool_context.task_completion_reservations { + for task_id in dropped + .iter() + .filter_map(|item| item.origin.completion_id()) + { + reservations.release(task_id); + } + } tracing::info!( dropped_count = dropped.len(), "auto-wake: dropping pending synthetic prompts (user prompt has priority)" ); - // Un-mark preempted IDs so TaskCompletionReminder can report - // them on the next pass instead of permanently suppressing them. - if let Some(ref auto_wake) = self.tool_context.auto_wake_delivered { - for id in dropped.iter().filter_map(|i| i.origin.completion_id()) { - auto_wake.remove(id); - } - } } } @@ -190,6 +190,7 @@ impl SessionActor { verbatim, json_schema, origin, + task_wake_fallback, respond_to, persist_ack, parsed_prompt_tx, @@ -392,7 +393,7 @@ impl SessionActor { /// benign no-op — the actor still re-broadcasts so the client reconciles. /// The in-flight turn is never removed. `owner` (when `Some`) scopes the /// edit to the requesting client's own items. - /// Resolve a removed/cleared queued prompt's in-flight `session/prompt` RPC + /// Resolve a removed prompt's in-flight `session/prompt` RPC /// before its [`InputItem`] is dropped. /// /// A queued prompt still has a client awaiting its `respond_to` oneshot (the @@ -412,8 +413,8 @@ impl SessionActor { /// delta, so other attached clients (leader mode) don't see the running /// turn spuriously end. Token count is `0` — a removed queued prompt never /// ran (and the value is discarded by the gate regardless). - fn respond_removed_queued_prompt(item: InputItem) { - let _ = item.respond_to.send(Ok(PromptTurnOk { + pub(super) fn respond_removed_prompt(respond_to: oneshot::Sender) { + let _ = respond_to.send(Ok(PromptTurnOk { stop_reason: acp::StopReason::Cancelled, total_tokens: 0, turn_snapshot: None, @@ -441,7 +442,7 @@ impl SessionActor { }) { if let Some(item) = state.pending_inputs.remove(pos) { - Self::respond_removed_queued_prompt(item); + Self::respond_removed_prompt(item.respond_to); } removed = true; } @@ -619,7 +620,7 @@ impl SessionActor { let mut state = self.state.lock().await; // Partition rather than `retain`: each cleared user prompt still has a // client awaiting its `respond_to`, so it must be resolved with - // `Cancelled` (see [`respond_removed_queued_prompt`]) instead of being + // `Cancelled` (see [`respond_removed_prompt`]) instead of being // dropped — a bare drop surfaces as "session failed to respond" and a // spurious "Turn failed" on the running turn. let running_id = state.running_prompt_id().map(str::to_string); @@ -638,7 +639,7 @@ impl SessionActor { if keep { kept.push_back(item); } else { - Self::respond_removed_queued_prompt(item); + Self::respond_removed_prompt(item.respond_to); } } state.pending_inputs = kept; diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/reminders.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/reminders.rs index 71103c5..a2e3b58 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/reminders.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/reminders.rs @@ -249,8 +249,8 @@ impl SessionActor { /// Mark completion IDs as reported in the shared /// `ReportedTaskCompletions` state so the per-tool-call /// `TaskCompletionReminder` won't (re-)surface them. Used both to dedupe - /// completions the model already saw (notification-drain / auto-wake - /// prompts) and to drop them during the goal loop (between-turn drain). + /// completions the model actually saw (notification-drain / started + /// auto-wake prompts) and to drop them during the goal loop (between-turn drain). /// No-op on an empty list. pub(super) async fn mark_completions_reported(&self, ids: &[&str]) { if ids.is_empty() { @@ -283,7 +283,13 @@ impl SessionActor { pub(super) async fn drain_between_turn_completions(&self) { let goal_loop_active = self.goal_loop_active(); let bridge = self.agent.borrow().tool_bridge().clone(); - let bash_completions = bridge.drain_between_turn_bash_completions().await; + let reserved = self + .tool_context + .task_completion_reservations + .as_ref() + .map(|reservations| reservations.snapshot()) + .unwrap_or_default(); + let bash_completions = bridge.drain_between_turn_bash_completions(&reserved).await; if !bash_completions.is_empty() { let ids: Vec<&str> = bash_completions .iter() @@ -324,9 +330,9 @@ impl SessionActor { }; let suppress_ids = self .tool_context - .auto_wake_delivered + .task_completion_reservations .as_ref() - .map(|a| a.snapshot()) + .map(|reservations| reservations.snapshot()) .unwrap_or_default(); let (respond_to, rx) = tokio::sync::oneshot::channel(); if tx diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/run_loop.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/run_loop.rs index a7a8f5b..ea95e81 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/run_loop.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/run_loop.rs @@ -30,6 +30,58 @@ mod yolo_toggle_report_tests { /// Best-effort removal of this session's per-session scratch staging on /// teardown. A no-op in builds without a scratch producer. fn cleanup_session_scratch(_session: &SessionActor) {} +impl SessionActor { + /// Serialize terminal task-wake admission with interactive cancellation. + pub(super) async fn admit_task_completion_wake( + &self, + origin: &super::PromptOrigin, + admission: TaskWakeAdmission, + ) -> Option { + let TaskWakeAdmission { + respond_to, + fallback, + } = admission; + let super::PromptOrigin::TaskCompleted { task_id } = origin else { + return respond_to.send(true).is_ok().then_some(fallback); + }; + let gate_suppressed = self + .tool_context + .task_wake_suppressed + .as_ref() + .is_some_and(|gate| gate.get()); + let mut state = self.state.lock().await; + let state_suppressed = state.notifications_suppressed; + let admitted = !gate_suppressed && !state_suppressed; + if !admitted { + Self::push_task_wake_fallback(&mut state, fallback); + drop(state); + xai_grok_telemetry::unified_log::info( + "shell.task_wake.actor_admission", + Some(self.session_info.id.0.as_ref()), + Some(serde_json::json!( + { "task_id" : task_id, "gate" : gate_suppressed, "state" : + state_suppressed, "admitted" : false, } + )), + ); + let _ = respond_to.send(false); + return None; + } + if respond_to.send(true).is_err() { + Self::push_task_wake_fallback(&mut state, fallback); + return None; + } + drop(state); + xai_grok_telemetry::unified_log::info( + "shell.task_wake.actor_admission", + Some(self.session_info.id.0.as_ref()), + Some(serde_json::json!( + { "task_id" : task_id, "gate" : gate_suppressed, "state" : + state_suppressed, "admitted" : true, } + )), + ); + Some(fallback) + } +} pub(super) async fn run_session( session: Arc, mut cmd_rx: mpsc::UnboundedReceiver, @@ -280,11 +332,18 @@ pub(super) async fn run_session( move { s.resume_plan_approval(completion_tx). await; }); } SessionCommand::Prompt { prompt_id, prompt_blocks, prompt_mode, artifact_upload_ctx, client_identifier, screen_mode, verbatim, traceparent, - json_schema, send_now, respond_to, persist_ack, parsed_prompt_tx } => { - session.ensure_prefix_ready(). await; let origin = - super::PromptOrigin::from_prompt_id(& prompt_id); if ! origin.is_synthetic() - { let mut state = session.state.lock(). await; state.notifications_suppressed - = false; session.user_input_generation.fetch_add(1, + json_schema, send_now, admission, respond_to, persist_ack, parsed_prompt_tx } + => { let origin = super::PromptOrigin::from_prompt_id(& prompt_id); let + (actor_admitted, task_wake_fallback) = match admission { Some(admission) => { + let fallback = session.admit_task_completion_wake(& origin, admission). + await; (fallback.is_some(), fallback) } None => (true, None), }; if ! + actor_admitted { SessionActor::respond_removed_prompt(respond_to); continue; + } session.ensure_prefix_ready(). await; if ! origin.is_synthetic() { if let + Some(gate) = & session.tool_context.task_wake_suppressed { gate.set(false); } + let mut state = session.state.lock(). await; state.notifications_suppressed = + false; xai_grok_telemetry::unified_log::info("shell.task_wake.gate_cleared", + Some(session.session_info.id.0.as_ref()), Some(serde_json::json!({ "reason" : + "user_intake" })),); session.user_input_generation.fetch_add(1, std::sync::atomic::Ordering::AcqRel); } if origin.is_synthetic() { let state = session.state.lock(). await; let has_running = state.running_task .is_some(); let queue_depth = state.pending_inputs.len(); drop(state); @@ -297,8 +356,8 @@ pub(super) async fn run_session( => (Some(tu.gcs_config), Some(tu.artifact_tracker)), None => (None, None), }; let cancel_for_send_now = session.queue_input(prompt_blocks, prompt_id, prompt_mode, trace_gcs_config, artifact_tracker, client_identifier, - screen_mode, verbatim, json_schema, send_now, respond_to, persist_ack, - parsed_prompt_tx). await; if cancel_for_send_now { session + screen_mode, verbatim, json_schema, send_now, task_wake_fallback, respond_to, + persist_ack, parsed_prompt_tx). await; if cancel_for_send_now { session .cancel_turn_for_send_now(& mut replay_buffer). await; } SessionActor::maybe_start_running_task(session.clone(), completion_tx .clone()). await; } SessionCommand::SessionMode { session_mode, responds_to } @@ -395,14 +454,9 @@ pub(super) async fn run_session( .session_info.id.0.to_string(),), }, MAX_BUFFER_EVENTS,); tracing::debug!(task_id = % task_id, "Routed monitor event to mid-turn buffer"); } } else { { let mut state = - session.state.lock(). await; state.pending_notifications - .push(PendingNotification { prompt_id, prompt_blocks, priority, source, }); - if state.pending_notifications.len() > MAX_PENDING_NOTIFICATIONS { let excess - = state.pending_notifications.len() - MAX_PENDING_NOTIFICATIONS; state - .pending_notifications.drain(..excess); tracing::warn!(dropped = excess, - "Dropped oldest pending notifications (exceeded cap of {})", - MAX_PENDING_NOTIFICATIONS,); } } - SessionActor::maybe_drain_notifications(session.clone(), completion_tx + session.state.lock(). await; SessionActor::push_pending_notification(& mut + state, PendingNotification { prompt_id, prompt_blocks, priority, source, },); + } SessionActor::maybe_drain_notifications(session.clone(), completion_tx .clone()). await; } } SessionCommand::RecordGoalTurnTaskIds { task_ids } => { session.record_reparented_goal_turn_task_ids(task_ids); } SessionCommand::RemoveQueuedPrompt { id, expected_version, owner } => { @@ -421,16 +475,19 @@ pub(super) async fn run_session( kill_background_tasks, rewind_if_pristine, trigger, } => { if let Some(notification) = replay_buffer.flush() { session .emit_buffered(notification). await; } session.pending_interjections.clear(); - session.cancel_running_task(cancel_subagents, kill_background_tasks, + let suppress_task_wakes = trigger.as_deref() == Some("ctrl_c"); session + .cancel_running_task(cancel_subagents, kill_background_tasks, rewind_if_pristine, trigger,). await; session.auto_pause_goal_if_active(crate ::session::goal_tracker::GoalPauseReason::User,). await; SessionActor::maybe_start_running_task(session.clone(), completion_tx - .clone()). await; } SessionCommand::CompactSession { user_context, respond_to - } => { let s = session.clone(); tokio::task::spawn_local(async move { let - compact_session = s.run_compact(user_context). await; let _ = respond_to - .send(compact_session); }); } SessionCommand::ReloadPlugins { registry } => { - if ! session.startup_hints.is_subagent { let registry = session - .preserve_session_plugin_dirs(registry); session + .clone()). await; if ! suppress_task_wakes { + SessionActor::maybe_drain_notifications(session.clone(), completion_tx + .clone(),). await; } } SessionCommand::CompactSession { user_context, + respond_to } => { let s = session.clone(); tokio::task::spawn_local(async + move { let compact_session = s.run_compact(user_context). await; let _ = + respond_to.send(compact_session); }); } SessionCommand::ReloadPlugins { + registry } => { if ! session.startup_hints.is_subagent { let registry = + session.preserve_session_plugin_dirs(registry); session .apply_plugin_registry_snapshot(registry). await; } } SessionCommand::ReloadHooks => { if ! session.startup_hints.is_subagent { let _ = session.reload_hooks_impl(). await; } } @@ -751,19 +808,20 @@ pub(super) async fn run_session( prompt_blocks, prompt_mode : crate ::session::plan_mode::PromptMode::Agent, trace_gcs_config : None, artifact_tracker : None, client_identifier : None, screen_mode : None, verbatim : true, json_schema : None, origin : - super::PromptOrigin::GoalSummary, respond_to, persist_ack : None, - parsed_prompt_tx : None, queue_meta : None, send_now : false, }); } - SessionActor::maybe_start_running_task(session.clone(), completion_tx - .clone()). await; } SessionCommand::TakeTurnMessages { respond_to } => { let - result = session.chat_state_handle.take_turn_messages(). await; let _ = - respond_to.send(result); } SessionCommand::TakeHarnessTraceTurns { respond_to - } => { let result = session.chat_state_handle.take_harness_trace_turns(). - await; let _ = respond_to.send(result); } - SessionCommand::TakeStreamingCapture { prompt_id, respond_to } => { let taken - = { let mut cap = session.streaming_turn_capture.lock(); if cap.prompt_id - .as_deref() == Some(prompt_id.as_str()) { Some(std::mem::take(& mut * cap)) } - else { if ! cap.is_empty() { tracing::warn!(requested_prompt_id = % - prompt_id, slot_prompt_id = ? cap.prompt_id, + super::PromptOrigin::GoalSummary, task_wake_fallback : None, respond_to, + persist_ack : None, parsed_prompt_tx : None, queue_meta : None, send_now : + false, }); } SessionActor::maybe_start_running_task(session.clone(), + completion_tx.clone()). await; } SessionCommand::TakeTurnMessages { + respond_to } => { let result = session.chat_state_handle.take_turn_messages() + . await; let _ = respond_to.send(result); } + SessionCommand::TakeHarnessTraceTurns { respond_to } => { let result = + session.chat_state_handle.take_harness_trace_turns(). await; let _ = + respond_to.send(result); } SessionCommand::TakeStreamingCapture { prompt_id, + respond_to } => { let taken = { let mut cap = session.streaming_turn_capture + .lock(); if cap.prompt_id.as_deref() == Some(prompt_id.as_str()) { + Some(std::mem::take(& mut * cap)) } else { if ! cap.is_empty() { + tracing::warn!(requested_prompt_id = % prompt_id, slot_prompt_id = ? cap + .prompt_id, "streaming_capture race: live slot belongs to a different prompt; \ dropping streaming_partial.json for the requested turn",); } None } }; let result = taken.and_then(| mut cap | { cap diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/sampler_turn.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/sampler_turn.rs index 3e79253..710dfae 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/sampler_turn.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/sampler_turn.rs @@ -913,6 +913,14 @@ impl SessionActor { } } /// Proactively refresh the auth token if near expiry. + /// + /// Session-token path is best-effort: on success, update credentials and + /// return. On failure, do **not** fall through to the JWT/config.toml + /// branch when the session gate was active — that path is for BYOK JWTs + /// only. Falling through after a failed session refresh left hard-expired + /// opaque tokens (External/OIDC) on the wire and guaranteed a 401. + /// Soft failures with a still-usable access token still return here + /// (grace / optimistic send); 401 recovery remains the safety net. pub(super) async fn refresh_token_if_expired(&self) { if let Some(ref am) = self.auth_manager { let creds = self.chat_state_handle.get_credentials().await; @@ -922,15 +930,33 @@ impl SessionActor { .await .map(|c| (c.model, c.base_url)) .unwrap_or_default(); - if self.auth_gate(&model_id, &base_url).active() - && let Ok(key) = am.get_valid_token().await - { - if creds.api_key.as_deref() != Some(&key) { - let mut creds = creds; - creds.api_key = Some(key); - self.chat_state_handle.update_credentials(creds); + if self.auth_gate(&model_id, &base_url).active() { + match am.get_valid_token().await { + Ok(key) => { + if creds.api_key.as_deref() != Some(&key) { + let mut creds = creds; + creds.api_key = Some(key); + self.chat_state_handle.update_credentials(creds); + } + return; + } + Err(e) => { + let hard_expired = !am.has_usable_token(); + tracing::warn!( + error = % e, hard_expired, model = % model_id, + "auth: preflight get_valid_token failed" + ); + xai_grok_telemetry::unified_log::warn( + "auth.preflight.refresh_failed", + Some(self.session_info.id.0.as_ref()), + Some(serde_json::json!( + { "error" : format!("{e}"), "hard_expired" : hard_expired, + "model" : model_id, } + )), + ); + return; + } } - return; } } else { xai_grok_telemetry::unified_log::debug( diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/spawn.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/spawn.rs index 14c4a62..8619347 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/spawn.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/spawn.rs @@ -118,6 +118,7 @@ pub(crate) async fn spawn_session_actor( system_prompt_label: String, compaction_mode: xai_chat_state::CompactionMode, compaction_verbatim_input: bool, + compaction_tool_choice: crate::util::config::CompactionToolChoice, two_pass_enabled: bool, buffering_settings: Option, origin_client: Option, @@ -464,9 +465,12 @@ pub(crate) async fn spawn_session_actor( None => FileStateTracker::new(), }); let file_state_handle = FileStateHandle::new(file_state_tracker.clone()); - let auto_wake_delivered = - xai_grok_tools::reminders::task_completion::AutoWakeDeliveredIds::default(); - tool_context.auto_wake_delivered = Some(auto_wake_delivered.clone()); + let task_completion_reservations = + xai_grok_tools::reminders::task_completion::TaskCompletionReservations::default(); + let task_wake_suppressed = + xai_grok_tools::reminders::task_completion::TaskWakeSuppressed::default(); + tool_context.task_completion_reservations = Some(task_completion_reservations.clone()); + tool_context.task_wake_suppressed = Some(task_wake_suppressed.clone()); let synthetic_trace_tx_shared: std::sync::Arc< std::sync::Mutex< Option< @@ -522,7 +526,8 @@ pub(crate) async fn spawn_session_actor( current_prompt_mode: current_prompt_mode.clone(), turn_prompt_mode: turn_prompt_mode.clone(), session_cmd_tx: cmd_tx.clone(), - auto_wake_delivered: auto_wake_delivered.clone(), + task_completion_reservations: task_completion_reservations.clone(), + task_wake_suppressed: task_wake_suppressed.clone(), synthetic_trace_tx: synthetic_trace_tx_shared.clone(), task_output_tool_name: task_output_tool_name.clone(), read_tool_name: read_tool_name.clone(), @@ -545,19 +550,46 @@ pub(crate) async fn spawn_session_actor( grep_ugrep, } }; + let cursor_harness = false; + let terminal_backend_kind = select_terminal_backend_kind( + startup_hints.is_subagent, + parent_terminal_backend.is_some(), + client_terminal_capable, + tool_context.gateway.is_some(), + cursor_harness, + ); let terminal_backend: std::sync::Arc = - if let Some(parent_tb) = parent_terminal_backend.filter(|_| startup_hints.is_subagent) { - parent_tb - } else if client_terminal_capable && tool_context.gateway.is_some() { - std::sync::Arc::new(crate::terminal::AcpTerminalAdapter::new( - tool_context.gateway.clone().unwrap(), - tool_context.session_id.clone().unwrap(), - )) as std::sync::Arc - } else { - let backend: std::sync::Arc = - std::sync::Arc::new(LocalTerminalBackend::new_local(resolve_search_shadows())); - backend + match terminal_backend_kind { + TerminalBackendKind::ReuseParent => parent_terminal_backend + .expect("ReuseParent is only selected when a parent backend is present"), + TerminalBackendKind::AcpClient => { + std::sync::Arc::new(crate::terminal::AcpTerminalAdapter::new( + tool_context.gateway.clone().unwrap(), + tool_context.session_id.clone().unwrap(), + )) + as std::sync::Arc + } + TerminalBackendKind::LocalPersistent => std::sync::Arc::new( + LocalTerminalBackend::new_local_with_persistent_shell(resolve_search_shadows()), + ), + TerminalBackendKind::LocalNonPersistent => { + let login_shell_capture = crate::util::config::resolve_login_shell_capture( + remote_settings.as_ref().and_then(|r| r.login_shell_capture), + ); + std::sync::Arc::new(LocalTerminalBackend::new_local_with_login_shell_capture( + resolve_search_shadows(), + login_shell_capture, + )) + } }; + if matches!( + terminal_backend_kind, + TerminalBackendKind::LocalPersistent | TerminalBackendKind::LocalNonPersistent + ) { + terminal_backend + .warm_shell(tool_context.cwd.as_path()) + .await; + } let fs_backend: std::sync::Arc = if client_fs_capable && tool_context.gateway.is_some() { std::sync::Arc::new(xai_grok_workspace::file_system::AcpFsAdapter::new( @@ -866,6 +898,14 @@ pub(crate) async fn spawn_session_actor( ); e })?; + agent + .tool_bridge() + .update_resource(task_completion_reservations.clone()) + .await; + agent + .tool_bridge() + .update_resource(task_wake_suppressed) + .await; let resolved_task_output = xai_grok_tools::reminders::task_completion::resolve_task_output_tool_name( agent.tool_bridge(), @@ -1143,6 +1183,7 @@ pub(crate) async fn spawn_session_actor( previous_model: std::cell::Cell::new(None), compaction_mode, verbatim_input: compaction_verbatim_input, + tool_choice: compaction_tool_choice, prefire: crate::session::compaction_config::PrefireState::default(), prefix_released: std::sync::atomic::AtomicBool::new(false), }, @@ -1686,6 +1727,7 @@ pub(crate) async fn spawn_session_on_thread( system_prompt_label: String, compaction_mode: xai_chat_state::CompactionMode, compaction_verbatim_input: bool, + compaction_tool_choice: crate::util::config::CompactionToolChoice, two_pass_enabled: bool, buffering_settings: Option, origin_client: Option, @@ -1848,6 +1890,7 @@ pub(crate) async fn spawn_session_on_thread( system_prompt_label, compaction_mode, compaction_verbatim_input, + compaction_tool_choice, two_pass_enabled, buffering_settings, origin_client, @@ -2017,3 +2060,78 @@ impl crate::session::mcp_restart::RestartActions for SessionRestartActions { .end_restart(server); } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TerminalBackendKind { + ReuseParent, + AcpClient, + LocalPersistent, + LocalNonPersistent, +} +fn select_terminal_backend_kind( + is_subagent: bool, + has_parent_backend: bool, + client_terminal_capable: bool, + has_gateway: bool, + cursor_harness: bool, +) -> TerminalBackendKind { + if is_subagent && has_parent_backend { + TerminalBackendKind::ReuseParent + } else if client_terminal_capable && has_gateway { + TerminalBackendKind::AcpClient + } else if cursor_harness { + TerminalBackendKind::LocalPersistent + } else { + TerminalBackendKind::LocalNonPersistent + } +} +#[cfg(test)] +mod terminal_backend_select_tests { + use super::{TerminalBackendKind, select_terminal_backend_kind}; + #[test] + fn subagent_with_parent_reuses_parent() { + assert_eq!( + select_terminal_backend_kind(true, true, true, true, true), + TerminalBackendKind::ReuseParent + ); + } + #[test] + fn subagent_without_parent_falls_through() { + assert_eq!( + select_terminal_backend_kind(true, false, true, true, true), + TerminalBackendKind::AcpClient + ); + assert_eq!( + select_terminal_backend_kind(true, false, false, true, true), + TerminalBackendKind::LocalPersistent + ); + } + #[test] + fn non_subagent_never_reuses_parent() { + assert_eq!( + select_terminal_backend_kind(false, true, false, false, true), + TerminalBackendKind::LocalPersistent + ); + } + #[test] + fn client_terminal_uses_acp_only_with_gateway() { + assert_eq!( + select_terminal_backend_kind(false, false, true, true, true), + TerminalBackendKind::AcpClient + ); + assert_eq!( + select_terminal_backend_kind(false, false, true, false, true), + TerminalBackendKind::LocalPersistent + ); + } + #[test] + fn local_session_cursor_harness_selects_persistent_backend() { + assert_eq!( + select_terminal_backend_kind(false, false, false, false, true), + TerminalBackendKind::LocalPersistent + ); + assert_eq!( + select_terminal_backend_kind(false, false, false, false, false), + TerminalBackendKind::LocalNonPersistent + ); + } +} diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/tasks_cancel.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/tasks_cancel.rs index f1369bf..fd3e4d4 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/tasks_cancel.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/tasks_cancel.rs @@ -216,7 +216,15 @@ impl SessionActor { self.cancel_running_task(false, false, false, Some("send_now".to_string())) .await; // Re-enable notification drains: unlike Ctrl+C, a send-now means the user is re-engaged. + if let Some(gate) = &self.tool_context.task_wake_suppressed { + gate.set(false); + } self.state.lock().await.notifications_suppressed = false; + xai_grok_telemetry::unified_log::info( + "shell.task_wake.gate_cleared", + Some(self.session_info.id.0.as_ref()), + Some(serde_json::json!({ "reason": "send_now" })), + ); } pub(super) async fn cancel_running_task( @@ -226,6 +234,32 @@ impl SessionActor { rewind_if_pristine: bool, trigger: Option, ) { + let suppress_task_wakes = trigger.as_deref() == Some("ctrl_c"); + if suppress_task_wakes { + if let Some(gate) = &self.tool_context.task_wake_suppressed { + gate.set(true); + } + let mut state = self.state.try_lock().expect("session state is actor-owned"); + state.notifications_suppressed = true; + xai_grok_telemetry::unified_log::info( + "shell.task_wake.cancel_barrier", + Some(self.session_info.id.0.as_ref()), + Some(serde_json::json!({ + "ctrl_c": true, + "gate": self + .tool_context + .task_wake_suppressed + .as_ref() + .is_some_and(|gate| gate.get()), + "state": state.notifications_suppressed, + })), + ); + drop(state); + if let Some(is_turn_active) = &self.tool_context.is_turn_active { + is_turn_active.store(false, std::sync::atomic::Ordering::Relaxed); + } + } + // Unified-log processing marker (counterpart of `shell.cancel.received` // in `MvpAgent::cancel`): records which prompt the cancel lands on so // a stuck "Cancelling…" can be attributed to delivery vs. processing. @@ -319,8 +353,8 @@ impl SessionActor { // Closes the race between abort() and TurnActiveGuard drop: // is_turn_active may still be true, causing InjectNotification // to route Next-priority events to the buffer instead of - // pending_notifications. Moving them here ensures they survive - // in the notification queue (suppressed until next user prompt). + // pending_notifications. Moving them here ensures they survive in + // the queue; Ctrl+C defers their drain, while other cancels do not. self.sweep_monitor_buffer_into_pending(&mut state, "monitor-cancel-drain"); // When killing all background tasks, also clear their pending @@ -333,7 +367,15 @@ impl SessionActor { if let Some(task) = state.running_task.take() { task.abort(); } + if let Some(gate) = &self.tool_context.task_wake_suppressed { + gate.set(false); + } state.notifications_suppressed = false; + xai_grok_telemetry::unified_log::info( + "shell.task_wake.gate_cleared", + Some(self.session_info.id.0.as_ref()), + Some(serde_json::json!({ "reason": "rewind" })), + ); state.rewindable = false; state.pending_inputs.pop_front() } else { @@ -342,9 +384,7 @@ impl SessionActor { let running_task = if rewound_input.is_some() { None } else { - let running_task = state.running_task.take(); - state.notifications_suppressed = true; - running_task + state.running_task.take() }; // Decide which queued inputs get resolved with `Cancelled` now vs. @@ -355,10 +395,10 @@ impl SessionActor { // path that sends `Shutdown` next): drain the WHOLE queue — there // is no point starting the next prompt and draining resolves every // queued input's `respond_to` cleanly. - // * normal interactive cancel: remove ONLY the running turn, - // PRESERVING every queued prompt so the `Cancel` handler's - // follow-up `maybe_start_running_task` promotes the new front (the - // user's next queued prompt) and rebroadcasts `x.ai/queue/changed`. + // * normal cancel: remove the running turn; only Ctrl+C also removes + // queued terminal task-completion wakes. Preserve real user prompts + // and unrelated synthetic entries so `maybe_start_running_task` can + // promote the next genuine user turn. // The cancelling client does not pull any prompt back into its // input — the server queue is the single source of truth for what // runs next. Previously every cancel did `std::mem::take`, @@ -391,6 +431,13 @@ impl SessionActor { let is_running_turn = idx == 0; if is_running_turn { cancelled.push_back(item); + } else if suppress_task_wakes + && matches!(&item.origin, super::PromptOrigin::TaskCompleted { .. }) + { + if let Some(fallback) = item.task_wake_fallback { + Self::push_task_wake_fallback(&mut state, fallback); + } + Self::respond_removed_prompt(item.respond_to); } else { kept.push_back(item); } @@ -490,6 +537,9 @@ impl SessionActor { if let Some(running_task) = running_task { running_task.abort(); } + if let Some(is_turn_active) = &self.tool_context.is_turn_active { + is_turn_active.store(false, std::sync::atomic::Ordering::Relaxed); + } // The aborted turn's `BlockingWaitGuard`s drop asynchronously (they // live in tool futures owned by the drainer task / subagent spawn // task). Until they do, `queue_input` would read a stale depth > 0 and @@ -564,19 +614,14 @@ impl SessionActor { return; } - // Un-mark cancelled synthetic IDs so TaskCompletionReminder can - // report them on the next pass instead of permanently suppressing them. - if let Some(ref auto_wake) = self.tool_context.auto_wake_delivered { - for input in pending_inputs.iter() { - if let Some(id) = input.origin.completion_id() { - auto_wake.remove(id); - } - } - } - for (idx, input) in pending_inputs.into_iter().enumerate() { // Running turn is idx 0; queued prompts never spent tokens. let is_running_turn = idx == 0; + if let Some(task_id) = input.origin.completion_id() + && let Some(reservations) = &self.tool_context.task_completion_reservations + { + reservations.release(task_id); + } let _ = input .respond_to .send(Ok(PromptTurnOk { diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/tool_calls.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/tool_calls.rs index 08fd514..6d589f9 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/tool_calls.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/tool_calls.rs @@ -1064,6 +1064,15 @@ impl SessionActor { self.permissions.set_classifier_transcript(turns); } } + let edit_path_context = matches!(&access_kind, AccessKind::Edit(_)).then(|| { + xai_grok_workspace::permission::types::EditPathContext { + real_cwd: std::path::PathBuf::from(self.session_info.cwd.as_str()), + display_cwd: self + .display_cwd + .get() + .map(|cwd| std::path::PathBuf::from(cwd.as_str())), + } + }); let decision = { let _pending_guard = crate::session::pending_interaction::PendingInteractionGuard::new( @@ -1074,9 +1083,10 @@ impl SessionActor { crate::session::pending_interaction::PendingKind::Permission, ); self.permissions - .request( + .request_with_edit_path_context( access_kind.clone(), tool_call_update, + edit_path_context, Some(self.session_info.id.0.to_string()), None, None, @@ -1520,6 +1530,7 @@ impl SessionActor { false, None, false, + None, respond_to, None, None, @@ -1887,9 +1898,8 @@ impl SessionActor { /// which is the same predicate used by `TaskCompletionReminder` — /// they cannot drift because they share the function. /// - /// `AutoWakeDeliveredIds` is **deliberately not unmarked** here - /// (unlike `queue_input`'s preempt path). The tool result that - /// triggered this sweep IS the canonical consumption surface, and + /// Reservations are deliberately not released here because the tool result + /// that triggered this sweep is the canonical consumption surface, and /// `TaskCompletionReminder` already suppresses the per-tool-call /// reminder for these IDs via its own suppress list (also derived /// from `consumed_completion_ids`). Un-marking here would risk a @@ -1905,18 +1915,26 @@ impl SessionActor { return; } let mut state = self.state.lock().await; - let dropped_inputs = state - .sweep_pending_inputs(|i| { - i.origin - .completion_id() - .is_some_and(|id| consumed_ids.contains(&id)) - }) - .len(); + let dropped = state.sweep_pending_inputs(|i| { + i.origin + .completion_id() + .is_some_and(|id| consumed_ids.contains(&id)) + }); + let dropped_inputs = dropped.len(); let before_notifications = state.pending_notifications.len(); state .pending_notifications .retain(|n| !consumed_ids.contains(&n.source.task_id())); let dropped_notifications = before_notifications - state.pending_notifications.len(); + drop(state); + if let Some(reservations) = &self.tool_context.task_completion_reservations { + for task_id in dropped + .iter() + .filter_map(|input| input.origin.completion_id()) + { + reservations.release(task_id); + } + } if dropped_inputs > 0 || dropped_notifications > 0 { tracing::info!( dropped_inputs, dropped_notifications, consumed_ids = ? consumed_ids, @@ -1937,8 +1955,26 @@ impl SessionActor { /// returns. Real user inputs are preserved. pub(super) async fn drop_pending_synthetic_items(&self) { let mut state = self.state.lock().await; - state.pending_inputs.retain(|i| !i.origin.is_synthetic()); + let mut kept = VecDeque::with_capacity(state.pending_inputs.len()); + let mut dropped = Vec::new(); + for input in std::mem::take(&mut state.pending_inputs) { + if input.origin.is_synthetic() { + dropped.push(input); + } else { + kept.push_back(input); + } + } + state.pending_inputs = kept; state.pending_notifications.clear(); + drop(state); + if let Some(reservations) = &self.tool_context.task_completion_reservations { + for task_id in dropped + .iter() + .filter_map(|input| input.origin.completion_id()) + { + reservations.release(task_id); + } + } } /// Record git/PR ops from a successful tool result into session signals /// (`turn_result.json`) and telemetry. Detection runs here at the shell's diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/turn.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/turn.rs index 99e3801..a8e31a4 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/turn.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/turn.rs @@ -238,7 +238,14 @@ impl SessionActor { { "prompt_id" : prompt_id, "block_count" : prompt_blocks.len(), } )), ); - if !super::super::PromptOrigin::from_prompt_id(prompt_id).is_synthetic() { + let origin = super::super::PromptOrigin::from_prompt_id(prompt_id); + if let Some(completion_id) = origin.completion_id() { + self.mark_completions_reported(&[completion_id]).await; + if let Some(reservations) = &self.tool_context.task_completion_reservations { + reservations.release(completion_id); + } + } + if !origin.is_synthetic() { self.cancel_pending_recap_for_new_prompt(); } *self.turn_start_prompt_mode.lock() = prompt_mode; @@ -623,6 +630,17 @@ impl SessionActor { self.maybe_inject_date_rollover_reminder().await; self.inject_plan_mode_reminders().await; self.inject_resumed_tasks_reminder(); + if matches!(&origin, super::super::PromptOrigin::User) { + if let Some(gate) = &self.tool_context.task_wake_suppressed { + gate.set(false); + } + xai_grok_telemetry::unified_log::info( + "shell.task_wake.gate_cleared", + Some(self.session_info.id.0.as_ref()), + Some(serde_json::json!({ "reason" : "handle_prompt_user_start" })), + ); + self.consume_deferred_completions_for_user_turn().await; + } self.drain_between_turn_completions().await; let user_message = if user_images.is_empty() { user_message diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/auth_error_no_retry_tests.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/auth_error_no_retry_tests.rs index 9845a3d..ebab4b9 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/auth_error_no_retry_tests.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/auth_error_no_retry_tests.rs @@ -285,6 +285,101 @@ async fn pre_flight_refresh_skips_api_key_auth_type() { .await; } +/// Hard-expired session token: pre-flight must call the refresher and must +/// not leave credentials stuck while pretending the JWT/config path applies. +#[tokio::test(flavor = "current_thread")] +#[serial_test::serial(attribution_emit_count)] +async fn pre_flight_refreshes_hard_expired_session_token() { + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let called = Arc::new(AtomicBool::new(false)); + let refresher: Arc = + Arc::new(AlwaysSucceedRefresher { + called: called.clone(), + }); + let (_dir, am) = auth_manager_with_refresher(refresher); + assert!( + !am.has_usable_token(), + "precondition: access token is hard-expired" + ); + + let (actor, _rx) = make_actor_with_auth_manager(Some(am.clone())).await; + actor.refresh_token_if_expired().await; + + assert!( + called.load(Ordering::SeqCst), + "pre-flight must invoke the refresher for a hard-expired session token" + ); + assert_eq!( + actor + .chat_state_handle + .get_credentials() + .await + .api_key + .as_deref(), + Some("refreshed-test-token"), + "credentials must be updated to the refreshed bearer" + ); + assert!(am.has_usable_token()); + }) + .await; +} + +/// Hard-expired + failed refresh: do not fall through to JWT/config.toml; +/// leave credentials unchanged so 401 recovery remains the safety net. +#[tokio::test(flavor = "current_thread")] +#[serial_test::serial(attribution_emit_count)] +async fn pre_flight_hard_expired_refresh_failure_skips_jwt_fallthrough() { + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let call_count = Arc::new(std::sync::atomic::AtomicU32::new(0)); + let refresher: Arc = Arc::new({ + struct AlwaysFail(Arc); + #[async_trait::async_trait] + impl crate::auth::refresh::TokenRefresher for AlwaysFail { + async fn refresh( + &self, + _: crate::auth::refresh::RefreshReason, + ) -> crate::auth::refresh::RefreshOutcome { + self.0.fetch_add(1, Ordering::SeqCst); + crate::auth::refresh::RefreshOutcome::transient("refresh failed") + } + } + AlwaysFail(call_count.clone()) + }); + let (_dir, am) = auth_manager_with_refresher(refresher); + let (actor, _rx) = make_actor_with_auth_manager(Some(am.clone())).await; + + actor.refresh_token_if_expired().await; + + assert!( + call_count.load(Ordering::SeqCst) >= 1, + "pre-flight must attempt refresh" + ); + assert_eq!( + actor + .chat_state_handle + .get_credentials() + .await + .api_key + .as_deref(), + Some("initial-test-key"), + "failed hard-expired pre-flight must not invent a JWT/config bearer" + ); + assert!( + !am.has_usable_token(), + "token remains hard-expired after failed refresh" + ); + assert!( + am.permanent_failure().is_none(), + "transient refresh failure must not poison permanent_failure" + ); + }) + .await; +} + /// Proactive refresh keeps the cache hot so `refresh_token_if_expired` /// (per-turn pre-flight) is a cache hit — the refresher fires once /// (proactive), then the per-turn call sees the fresh token without diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/auto_wake_suppression_tests.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/auto_wake_suppression_tests.rs index c7fb545..55266d5 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/auto_wake_suppression_tests.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/auto_wake_suppression_tests.rs @@ -60,6 +60,38 @@ fn bash_completed_notification(task_id: &str) -> PendingNotification { }, } } +fn monitor_completed_notification(task_id: &str) -> PendingNotification { + PendingNotification { + prompt_id: format!("monitor-completed-{task_id}"), + prompt_blocks: vec![], + priority: NotificationPriority::Later, + source: NotificationSource::MonitorCompleted { + task_id: task_id.to_string(), + }, + } +} +fn task_wake_admission( + task_id: &str, + source: NotificationSource, +) -> ( + crate::session::commands::TaskWakeAdmission, + oneshot::Receiver, +) { + let (respond_to, response_rx) = oneshot::channel(); + ( + crate::session::commands::TaskWakeAdmission { + respond_to, + fallback: crate::session::commands::TaskWakeFallback { + prompt_id: format!("deferred-{task_id}"), + prompt_blocks: vec![acp::ContentBlock::Text(acp::TextContent::new(format!( + "completion {task_id}" + )))], + source, + }, + }, + response_rx, + ) +} fn monitor_event_notification(task_id: &str) -> PendingNotification { PendingNotification { prompt_id: format!("monitor-{task_id}"), @@ -73,6 +105,30 @@ fn monitor_event_notification(task_id: &str) -> PendingNotification { /// Monitor notifications in the idle drain collapse into ONE /// `format_monitor_events` block (same shape as the mid-turn injection); /// non-monitor notifications keep their raw blocks, `---`-separated. +#[test] +fn pending_notification_cap_keeps_newest_entries() { + let mut state = State { + running_task: None, + pending_inputs: std::collections::VecDeque::new(), + pending_notifications: Vec::new(), + notifications_suppressed: true, + rewindable: false, + nudges_used_this_session: 0, + }; + for index in 0..(MAX_PENDING_NOTIFICATIONS + 3) { + SessionActor::push_pending_notification( + &mut state, + bash_completed_notification(&format!("task-{index}")), + ); + } + assert_eq!(state.pending_notifications.len(), MAX_PENDING_NOTIFICATIONS); + assert_eq!(state.pending_notifications[0].source.task_id(), "task-3"); + let newest = format!("task-{}", MAX_PENDING_NOTIFICATIONS + 2); + assert_eq!( + state.pending_notifications.last().unwrap().source.task_id(), + newest + ); +} #[tokio::test(flavor = "current_thread")] async fn drain_batches_monitor_notifications_into_formatted_block() { let local = tokio::task::LocalSet::new(); @@ -144,6 +200,514 @@ async fn drain_batches_monitor_notifications_into_formatted_block() { }) .await; } +#[tokio::test(flavor = "current_thread")] +async fn cancel_barrier_rejects_task_completion_wake_without_reporting_it() { + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let (gateway_tx, _) = + tokio::sync::mpsc::unbounded_channel::(); + let (persistence_tx, _) = tokio::sync::mpsc::unbounded_channel::(); + let actor = create_test_actor(0, 256_000, 85, gateway_tx, persistence_tx).await; + let reservations = actor + .tool_context + .task_completion_reservations + .clone() + .expect("completion reservations"); + reservations.reserve("bg-suppressed".to_string()); + actor.state.lock().await.notifications_suppressed = true; + let gate = actor + .tool_context + .task_wake_suppressed + .clone() + .expect("task-wake gate"); + gate.set(true); + let resources = actor + .agent + .borrow() + .tool_bridge() + .clone() + .shared_resources() + .await; + { + let mut resources = resources.lock().await; + resources.insert(reservations.clone()); + resources.insert(gate.clone()); + } + let origin = crate::session::PromptOrigin::TaskCompleted { + task_id: "bg-suppressed".to_string(), + }; + let (admission, response_rx) = task_wake_admission( + "bg-suppressed", + NotificationSource::BashTaskCompleted { + task_id: "bg-suppressed".to_string(), + }, + ); + assert!( + actor + .admit_task_completion_wake(&origin, admission) + .await + .is_none() + ); + assert_eq!(response_rx.await, Ok(false)); + assert!(gate.get()); + let state = actor.state.lock().await; + assert!(state.running_task.is_none()); + assert!(state.pending_inputs.is_empty()); + assert!( + matches!(state.pending_notifications.as_slice(), [PendingNotification { + source : NotificationSource::BashTaskCompleted { task_id }, .. }] if + task_id == "bg-suppressed") + ); + drop(state); + assert!(reservations.contains("bg-suppressed")); + let res = resources.lock().await; + assert!( + res.get::>() + .is_none(), + "declined admission must not report before user re-engagement" + ); + drop(res); + let reminder = xai_grok_tools::reminders::TaskCompletionReminder; + let reminders = xai_grok_tools::types::tool::Reminder::collect_reminders( + &reminder, + resources, + &ToolOutput::Dynamic(serde_json::Value::Null.into()), + ) + .await; + assert!(reminders.is_empty()); + assert!(reservations.contains("bg-suppressed")); + reservations.release("bg-suppressed"); + }) + .await; +} +#[tokio::test(flavor = "current_thread")] +async fn closed_admission_ack_stores_fallback_before_prompt_rejection() { + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let (gateway_tx, _) = + tokio::sync::mpsc::unbounded_channel::(); + let (persistence_tx, _) = tokio::sync::mpsc::unbounded_channel::(); + let actor = create_test_actor(0, 256_000, 85, gateway_tx, persistence_tx).await; + let origin = crate::session::PromptOrigin::TaskCompleted { + task_id: "mon-timeout".to_string(), + }; + let (admission, response_rx) = task_wake_admission( + "mon-timeout", + NotificationSource::MonitorCompleted { + task_id: "mon-timeout".to_string(), + }, + ); + drop(response_rx); + assert!( + actor + .admit_task_completion_wake(&origin, admission) + .await + .is_none() + ); + let state = actor.state.lock().await; + assert!( + matches!(state.pending_notifications.as_slice(), [PendingNotification { + source : NotificationSource::MonitorCompleted { task_id }, .. }] if + task_id == "mon-timeout") + ); + }) + .await; +} +#[tokio::test(flavor = "current_thread")] +async fn non_task_prompt_is_not_subject_to_task_wake_barrier() { + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let (gateway_tx, _) = + tokio::sync::mpsc::unbounded_channel::(); + let (persistence_tx, _) = tokio::sync::mpsc::unbounded_channel::(); + let actor = create_test_actor(0, 256_000, 85, gateway_tx, persistence_tx).await; + actor.state.lock().await.notifications_suppressed = true; + let (admission, response_rx) = task_wake_admission( + "sub-1", + NotificationSource::BashTaskCompleted { + task_id: "sub-1".to_string(), + }, + ); + assert!( + actor + .admit_task_completion_wake( + &crate::session::PromptOrigin::SubagentCompleted { + subagent_id: "sub-1".to_string(), + }, + admission, + ) + .await + .is_some(), + "subagent completion is outside terminal task-wake suppression scope" + ); + assert_eq!(response_rx.await, Ok(true)); + }) + .await; +} +#[tokio::test(flavor = "current_thread")] +async fn task_completion_wake_is_admitted_without_cancel_barrier() { + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let (gateway_tx, _) = + tokio::sync::mpsc::unbounded_channel::(); + let (persistence_tx, _) = tokio::sync::mpsc::unbounded_channel::(); + let actor = std::sync::Arc::new( + create_test_actor(0, 256_000, 85, gateway_tx, persistence_tx).await, + ); + let origin = crate::session::PromptOrigin::TaskCompleted { + task_id: "bg-normal".to_string(), + }; + actor + .tool_context + .task_completion_reservations + .as_ref() + .expect("completion reservations") + .reserve("bg-normal".to_string()); + let (admission, response_rx) = task_wake_admission( + "bg-normal", + NotificationSource::BashTaskCompleted { + task_id: "bg-normal".to_string(), + }, + ); + let fallback = actor + .admit_task_completion_wake(&origin, admission) + .await + .expect("normal task wake should be admitted"); + assert_eq!(response_rx.await, Ok(true)); + let (respond_to, _rx) = oneshot::channel(); + actor + .queue_input( + vec![], + "task-completed-bg-normal".to_string(), + PromptMode::Agent, + None, + None, + None, + None, + true, + None, + false, + Some(fallback), + respond_to, + None, + None, + ) + .await; + let state = actor.state.lock().await; + assert_eq!(state.pending_inputs.len(), 1); + assert!( + matches!(state.pending_inputs.front().map(| item | & item.origin), + Some(crate ::session::PromptOrigin::TaskCompleted { task_id }) if task_id + == "bg-normal") + ); + drop(state); + let resources = actor + .agent + .borrow() + .tool_bridge() + .clone() + .shared_resources() + .await; + assert!( + resources + .lock() + .await + .get::>() + .is_none(), + "queue acceptance alone must not mark the completion reported" + ); + let actor_for_turn = actor.clone(); + let turn = tokio::task::spawn_local(async move { + actor_for_turn + .handle_prompt( + "task-completed-bg-normal", + vec![acp::ContentBlock::Text(acp::TextContent::new("done"))], + PromptMode::Agent, + None, + None, + None, + None, + true, + None, + None, + None, + ) + .await + }); + tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + if already_reported(&actor, "bg-normal").await { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("synthetic turn marked completion reported"); + turn.abort(); + assert!( + already_reported(&actor, "bg-normal").await, + "actual synthetic turn start must mark the completion reported" + ); + assert!( + actor + .tool_context + .task_completion_reservations + .as_ref() + .is_none_or(|ids| !ids.contains("bg-normal")) + ); + }) + .await; +} +#[tokio::test(flavor = "current_thread")] +async fn genuine_user_start_consumes_deferred_completions_without_notification_turn() { + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let (gateway_tx, _) = + tokio::sync::mpsc::unbounded_channel::(); + let (persistence_tx, _) = tokio::sync::mpsc::unbounded_channel::(); + let actor = std::sync::Arc::new( + create_test_actor(0, 256_000, 85, gateway_tx, persistence_tx).await, + ); + let body = xai_grok_tools::reminders::task_completion::format_monitor_completion( + &xai_grok_tools::types::TaskSnapshot { + task_id: "mon-quiet".to_string(), + command: "tail -f quiet.log".to_string(), + display_command: Some("[monitor] quiet logs".to_string()), + cwd: String::new(), + start_time: std::time::SystemTime::now(), + end_time: Some(std::time::SystemTime::now()), + output: String::new(), + output_file: std::path::PathBuf::new(), + truncated: false, + exit_code: Some(0), + signal: None, + completed: true, + kind: xai_grok_tools::computer::types::TaskKind::Monitor, + block_waited: false, + explicitly_killed: false, + owner_session_id: None, + }, + Some("get_command_or_subagent_output"), + ); + { + let mut state = actor.state.lock().await; + state.notifications_suppressed = true; + state + .pending_notifications + .push(monitor_event_notification("mon-quiet")); + let mut monitor_completion = monitor_completed_notification("mon-quiet"); + monitor_completion.prompt_blocks = + vec![acp::ContentBlock::Text(acp::TextContent::new(body))]; + state.pending_notifications.push(monitor_completion); + let mut bash_completion = bash_completed_notification("bash-deferred"); + bash_completion.prompt_blocks = vec![acp::ContentBlock::Text( + acp::TextContent::new("Background task bash-deferred completed."), + )]; + state.pending_notifications.push(bash_completion); + } + let reservations = actor + .tool_context + .task_completion_reservations + .as_ref() + .expect("completion reservations"); + reservations.reserve("mon-quiet".to_string()); + reservations.reserve("bash-deferred".to_string()); + actor + .tool_context + .task_wake_suppressed + .as_ref() + .expect("task-wake gate") + .set(true); + let actor_for_turn = actor.clone(); + let turn = tokio::task::spawn_local(async move { + actor_for_turn + .handle_prompt( + "user-deferred-completions", + vec![acp::ContentBlock::Text(acp::TextContent::new("continue"))], + PromptMode::Agent, + None, + None, + None, + None, + false, + None, + None, + None, + ) + .await + }); + tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + if actor.state.lock().await.pending_notifications.is_empty() { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("user turn consumed deferred completions"); + turn.abort(); + tokio::task::yield_now().await; + let state = actor.state.lock().await; + assert!(state.notifications_suppressed); + assert!(state.pending_notifications.is_empty()); + assert!(state.pending_inputs.iter().all(|input| !matches!( + input.origin, + crate::session::PromptOrigin::NotificationDrain + ))); + drop(state); + let (completion_tx, _completion_rx) = tokio::sync::mpsc::unbounded_channel(); + SessionActor::maybe_drain_notifications(actor.clone(), completion_tx).await; + let state = actor.state.lock().await; + assert!(state.pending_inputs.iter().all(|input| !matches!( + input.origin, + crate::session::PromptOrigin::NotificationDrain + ))); + drop(state); + assert!(!reservations.contains("mon-quiet")); + assert!(!reservations.contains("bash-deferred")); + assert!( + !actor + .tool_context + .task_wake_suppressed + .as_ref() + .expect("task-wake gate") + .get() + ); + let conversation = actor.chat_state_handle.get_conversation().await; + let text = conversation + .iter() + .map(|item| item.text_content()) + .collect::>() + .join("\n"); + assert!(text.contains("Monitor \"mon-quiet\" ended")); + assert!(text.contains("Background task bash-deferred completed.")); + assert!(!text.contains("(); + let (persistence_tx, _) = tokio::sync::mpsc::unbounded_channel::(); + let actor = std::sync::Arc::new( + create_test_actor(0, 256_000, 85, gateway_tx, persistence_tx).await, + ); + let reservations = actor + .tool_context + .task_completion_reservations + .as_ref() + .expect("completion reservations"); + reservations.reserve("accepted-race".to_string()); + actor + .tool_context + .task_wake_suppressed + .as_ref() + .expect("task-wake gate") + .set(true); + let actor_for_turn = actor.clone(); + let turn = tokio::task::spawn_local(async move { + actor_for_turn + .handle_prompt( + "user-accepted-race", + vec![acp::ContentBlock::Text(acp::TextContent::new("continue"))], + PromptMode::Agent, + None, + None, + None, + None, + false, + None, + None, + None, + ) + .await + }); + tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + if actor + .tool_context + .task_wake_suppressed + .as_ref() + .is_none_or(|gate| !gate.get()) + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("user turn started"); + assert!(reservations.contains("accepted-race")); + turn.abort(); + reservations.release("accepted-race"); + }) + .await; +} +#[tokio::test(flavor = "current_thread")] +async fn same_id_bash_completion_does_not_suppress_monitor_event() { + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let (gateway_tx, _) = tokio::sync::mpsc::unbounded_channel::< + xai_acp_lib::AcpClientMessage, + >(); + let (persistence_tx, _) = tokio::sync::mpsc::unbounded_channel::< + PersistenceMsg, + >(); + let actor = create_test_actor(0, 256_000, 85, gateway_tx, persistence_tx) + .await; + let monitor = PendingNotification { + prompt_id: "monitor-shared".to_string(), + prompt_blocks: vec![ + acp::ContentBlock::Text(acp::TextContent::new("\nstdout\n",)) + ], + priority: NotificationPriority::Next, + source: NotificationSource::MonitorEvent { + task_id: "shared".to_string(), + }, + }; + let mut bash = bash_completed_notification("shared"); + bash.prompt_blocks = vec![ + acp::ContentBlock::Text(acp::TextContent::new("Background task shared completed.",)) + ]; + let mut state = actor.state.lock().await; + SessionActor::drain_notifications_into_turn( + &mut state, + vec![monitor, bash], + "get_command_or_subagent_output", + ); + let text = state + .pending_inputs + .back() + .expect("drained turn") + .prompt_blocks + .iter() + .filter_map(|block| match block { + acp::ContentBlock::Text(text) => Some(text.text.as_str()), + _ => None, + }) + .collect::>() + .join("\n"); + assert!(text.contains("")); + assert!(text.contains("Background task shared completed.")); + }) + .await; +} /// Fix 1, TaskOutput(completed) — the matching pending `task-completed-{id}` /// input must be dropped; any non-matching synthetic prompt must survive. #[tokio::test(flavor = "current_thread")] @@ -248,6 +812,12 @@ async fn user_prompt_preempt_keeps_running_synthetic_slot() { tokio::sync::mpsc::unbounded_channel::(); let (persistence_tx, _) = tokio::sync::mpsc::unbounded_channel::(); let actor = create_test_actor(0, 256_000, 85, gateway_tx, persistence_tx).await; + let reservations = actor + .tool_context + .task_completion_reservations + .as_ref() + .expect("completion reservations"); + reservations.reserve("bg-other".to_string()); { let mut state = actor.state.lock().await; state.running_task = Some(running_task_stub("task-completed-bg-target")); @@ -271,6 +841,7 @@ async fn user_prompt_preempt_keeps_running_synthetic_slot() { false, None, false, + None, respond_to, None, None, @@ -288,6 +859,10 @@ async fn user_prompt_preempt_keeps_running_synthetic_slot() { "the running synthetic turn's slot must survive the user-priority \ preempt; only the queued non-running synthetic is dropped" ); + assert!( + !reservations.contains("bg-other"), + "ordinary user-priority preemption releases ownership immediately" + ); }) .await; } @@ -902,16 +1477,15 @@ async fn reparented_record_is_noop_without_goal_harness() { } /// Regression: the between-turn completion drain must suppress subagent /// completions already delivered to the model via auto-wake synthetic -/// prompts. Without `auto_wake_delivered` feeding `suppress_ids`, the same +/// prompts. Without completion reservations feeding `suppress_ids`, the same /// completion is reported twice — once as the auto-wake "Background subagent /// … completed" prompt and again as the "While you were idle, N background /// subagent(s) completed" reminder. #[tokio::test(flavor = "current_thread")] -async fn between_turn_drain_suppresses_auto_wake_delivered_subagents() { +async fn between_turn_drain_suppresses_reserved_subagents() { use xai_grok_tools::implementations::grok_build::task::types::{ SubagentCompletionSummary, SubagentEvent, }; - use xai_grok_tools::reminders::task_completion::AutoWakeDeliveredIds; let local = tokio::task::LocalSet::new(); local .run_until(async { @@ -919,9 +1493,12 @@ async fn between_turn_drain_suppresses_auto_wake_delivered_subagents() { tokio::sync::mpsc::unbounded_channel::(); let (persistence_tx, _) = tokio::sync::mpsc::unbounded_channel::(); let mut actor = create_test_actor(0, 256_000, 85, gateway_tx, persistence_tx).await; - let auto_wake = AutoWakeDeliveredIds::default(); - auto_wake.insert("sa-autowake".to_string()); - actor.tool_context.auto_wake_delivered = Some(auto_wake); + actor + .tool_context + .task_completion_reservations + .as_ref() + .expect("completion reservations") + .reserve("sa-autowake".to_string()); let captured: std::sync::Arc>> = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); @@ -951,7 +1528,7 @@ async fn between_turn_drain_suppresses_auto_wake_delivered_subagents() { let suppress = captured.lock().unwrap().clone(); assert!( suppress.contains(&"sa-autowake".to_string()), - "between-turn drain must pass auto-wake-delivered ids as suppress_ids: \ + "between-turn drain must pass reserved ids as suppress_ids: \ {suppress:?}", ); let conversation = actor.chat_state_handle.get_conversation().await; @@ -966,7 +1543,14 @@ async fn between_turn_drain_suppresses_auto_wake_delivered_subagents() { ); assert!( !texts.contains("sa-autowake"), - "auto-wake-delivered completion must NOT be re-surfaced: {texts}", + "reserved completion must NOT be re-surfaced: {texts}", + ); + assert!( + actor + .tool_context + .task_completion_reservations + .as_ref() + .is_some_and(|ids| ids.contains("sa-autowake")) ); }) .await; diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/cancel_running_task_tests.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/cancel_running_task_tests.rs index 8c5ad6f..a9d0676 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/cancel_running_task_tests.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/cancel_running_task_tests.rs @@ -157,6 +157,7 @@ async fn persist_ack_waits_for_disk_flush_before_success() { previous_model: std::cell::Cell::new(None), compaction_mode: xai_chat_state::CompactionMode::Transcript, verbatim_input: true, + tool_choice: crate::util::config::CompactionToolChoice::Auto, prefire: crate::session::compaction_config::PrefireState::default(), prefix_released: std::sync::atomic::AtomicBool::new(false), }, @@ -609,6 +610,7 @@ async fn first_turn_memory_injection_disabled_does_not_persist_to_chat_history() previous_model: std::cell::Cell::new(None), compaction_mode: xai_chat_state::CompactionMode::Transcript, verbatim_input: true, + tool_choice: crate::util::config::CompactionToolChoice::Auto, prefire: crate::session::compaction_config::PrefireState::default(), prefix_released: std::sync::atomic::AtomicBool::new(false), }, @@ -882,6 +884,7 @@ async fn cancel_running_task_teardown_clears_running_and_pending_work() { previous_model: std::cell::Cell::new(None), compaction_mode: xai_chat_state::CompactionMode::Transcript, verbatim_input: true, + tool_choice: crate::util::config::CompactionToolChoice::Auto, prefire: crate::session::compaction_config::PrefireState::default(), prefix_released: std::sync::atomic::AtomicBool::new(false), }, @@ -1052,6 +1055,7 @@ async fn cancel_running_task_teardown_clears_running_and_pending_work() { verbatim: false, json_schema: None, origin: crate::session::PromptOrigin::User, + task_wake_fallback: None, respond_to: tx, persist_ack: None, parsed_prompt_tx: None, @@ -1488,6 +1492,7 @@ async fn cancel_running_task_interactive_preserves_queued_work() { verbatim: false, json_schema: None, origin: crate::session::PromptOrigin::User, + task_wake_fallback: None, respond_to, persist_ack: None, parsed_prompt_tx: None, @@ -1650,6 +1655,191 @@ async fn cancel_after_own_completion_sweep_preserves_queued_user_prompt() { }) .await; } +#[tokio::test(flavor = "current_thread")] +async fn interactive_cancel_drops_queued_task_wakes_and_promotes_user() { + use tokio::sync::oneshot::error::TryRecvError; + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let (gateway_tx, _gateway_rx) = + tokio::sync::mpsc::unbounded_channel::(); + let (persistence_tx, _persistence_rx) = + tokio::sync::mpsc::unbounded_channel::(); + let actor = create_test_actor(0, 256_000, 85, gateway_tx, persistence_tx).await; + let reservations = actor + .tool_context + .task_completion_reservations + .clone() + .expect("completion reservations"); + reservations.reserve("bg-queued".to_string()); + let actor = Arc::new(actor); + let (running_item, mut running_rx) = + input_with_origin_rx("user-running", crate::session::PromptOrigin::User); + let (mut wake_item, mut wake_rx) = input_with_origin_rx( + "task-completed-bg-queued", + crate::session::PromptOrigin::TaskCompleted { + task_id: "bg-queued".to_string(), + }, + ); + wake_item.task_wake_fallback = Some(crate::session::commands::TaskWakeFallback { + prompt_id: "bash-completed-bg-queued".to_string(), + prompt_blocks: vec![acp::ContentBlock::Text(acp::TextContent::new( + "completion bg-queued", + ))], + source: NotificationSource::BashTaskCompleted { + task_id: "bg-queued".to_string(), + }, + }); + let (queued_user, mut queued_user_rx) = + input_with_origin_rx("user-next", crate::session::PromptOrigin::User); + { + let mut state = actor.state.lock().await; + state.running_task = Some(running_task_stub("user-running")); + state.pending_inputs.push_back(running_item); + state.pending_inputs.push_back(wake_item); + state.pending_inputs.push_back(queued_user); + } + let cancel = actor.cancel_running_task(true, false, false, Some("ctrl_c".to_string())); + tokio::pin!(cancel); + tokio::select! { + _ = & mut cancel => {} _ = tokio::task::yield_now() => { assert!(actor + .state.try_lock().expect("state lock").notifications_suppressed, + "Ctrl+C must arm actor suppression before the first await"); + assert!(actor.tool_context.task_wake_suppressed.as_ref().is_some_and(| + gate | gate.get()), + "Ctrl+C must arm the reminder gate before the first await"); cancel. + await; } + } + assert!( + actor + .tool_context + .task_wake_suppressed + .as_ref() + .is_some_and(|gate| gate.get()), + "Ctrl+C must synchronously arm the reminder gate" + ); + { + let state = actor.state.lock().await; + let remaining: Vec<&str> = state + .pending_inputs + .iter() + .map(|item| item.prompt_id.as_str()) + .collect(); + assert_eq!(remaining, vec!["user-next"]); + assert!( + matches!(state.pending_notifications.as_slice(), [PendingNotification + { source : NotificationSource::BashTaskCompleted { task_id }, .. }] + if task_id == "bg-queued") + ); + assert!(state.notifications_suppressed); + } + assert!(matches!(running_rx.try_recv(), Ok(Ok(_)))); + assert!(matches!( + wake_rx.try_recv(), + Ok(Ok(crate::session::commands::PromptTurnOk { + completion_kind: PromptCompletionKind::RemovedFromQueue, + .. + })) + )); + assert!(matches!( + queued_user_rx.try_recv(), + Err(TryRecvError::Empty) + )); + assert!(reservations.contains("bg-queued")); + actor.consume_deferred_completions_for_user_turn().await; + { + let state = actor.state.lock().await; + assert!( + state.pending_notifications.is_empty(), + "the genuine user turn must consume the parked fallback exactly once" + ); + } + assert!(!reservations.contains("bg-queued")); + actor.consume_deferred_completions_for_user_turn().await; + assert!( + actor.state.lock().await.pending_notifications.is_empty(), + "a second user-start drain must not rediscover the completion" + ); + }) + .await; +} +#[tokio::test(flavor = "current_thread")] +async fn ctrl_c_clears_turn_active_before_background_completion_routes() { + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let (gateway_tx, _gateway_rx) = + tokio::sync::mpsc::unbounded_channel::(); + let (persistence_tx, _persistence_rx) = + tokio::sync::mpsc::unbounded_channel::(); + let mut actor = create_test_actor(0, 256_000, 85, gateway_tx, persistence_tx).await; + let is_turn_active = Arc::new(std::sync::atomic::AtomicBool::new(true)); + actor.tool_context.is_turn_active = Some(is_turn_active.clone()); + let (running_item, _running_rx) = + input_with_origin_rx("user-running", crate::session::PromptOrigin::User); + { + let mut state = actor.state.lock().await; + state.running_task = Some(running_task_stub("user-running")); + state.pending_inputs.push_back(running_item); + } + actor + .cancel_running_task(true, false, false, Some("ctrl_c".to_string())) + .await; + assert!(!is_turn_active.load(std::sync::atomic::Ordering::Relaxed)); + assert!(actor.state.lock().await.notifications_suppressed); + }) + .await; +} +#[tokio::test(flavor = "current_thread")] +async fn non_ctrl_c_cancel_preserves_queued_task_wakes_and_does_not_arm_barrier() { + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + for trigger in [Some("esc"), Some("mouse"), Some("dashboard"), None] { + let (gateway_tx, _gateway_rx) = + tokio::sync::mpsc::unbounded_channel::(); + let (persistence_tx, _persistence_rx) = + tokio::sync::mpsc::unbounded_channel::(); + let actor = create_test_actor(0, 256_000, 85, gateway_tx, persistence_tx).await; + let (running_item, _running_rx) = + input_with_origin_rx("user-running", crate::session::PromptOrigin::User); + let (wake_item, _wake_rx) = input_with_origin_rx( + "task-completed-bg-preserved", + crate::session::PromptOrigin::TaskCompleted { + task_id: "bg-preserved".to_string(), + }, + ); + let (queued_user, _queued_user_rx) = + input_with_origin_rx("user-next", crate::session::PromptOrigin::User); + { + let mut state = actor.state.lock().await; + state.running_task = Some(running_task_stub("user-running")); + state.pending_inputs.push_back(running_item); + state.pending_inputs.push_back(wake_item); + state.pending_inputs.push_back(queued_user); + } + actor + .cancel_running_task(true, false, false, trigger.map(str::to_string)) + .await; + let state = actor.state.lock().await; + let remaining: Vec<&str> = state + .pending_inputs + .iter() + .map(|item| item.prompt_id.as_str()) + .collect(); + assert_eq!( + remaining, + vec!["task-completed-bg-preserved", "user-next"], + "non-Ctrl+C cancel {trigger:?} must preserve the queued task wake" + ); + assert!( + !state.notifications_suppressed, + "non-Ctrl+C cancel {trigger:?} must not arm task-wake suppression" + ); + } + }) + .await; +} /// Regression for the cancel-spinner hang: an interactive cancel must resolve /// the in-flight front prompt's `respond_to` with `Cancelled` even when /// `state.running_task` is `None`. @@ -1682,6 +1872,7 @@ async fn cancel_resolves_front_when_running_task_is_none() { verbatim: false, json_schema: None, origin: crate::session::PromptOrigin::User, + task_wake_fallback: None, respond_to, persist_ack: None, parsed_prompt_tx: None, @@ -1926,6 +2117,7 @@ async fn cancel_propagates_to_sampler_handle_so_no_further_emission() { previous_model: std::cell::Cell::new(None), compaction_mode: xai_chat_state::CompactionMode::Transcript, verbatim_input: true, + tool_choice: crate::util::config::CompactionToolChoice::Auto, prefire: crate::session::compaction_config::PrefireState::default(), prefix_released: std::sync::atomic::AtomicBool::new(false), }, @@ -2209,6 +2401,7 @@ async fn cancel_keeps_remaining_queued_prompts_visible_to_clients() { verbatim: false, json_schema: None, origin: crate::session::PromptOrigin::User, + task_wake_fallback: None, respond_to, persist_ack: None, parsed_prompt_tx: None, diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/goal/goal_backoff_tests.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/goal/goal_backoff_tests.rs index c1eb54d..28fa77f 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/goal/goal_backoff_tests.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/goal/goal_backoff_tests.rs @@ -193,6 +193,7 @@ async fn seed_pending_classifier_nudge(actor: &SessionActor) { verbatim: true, json_schema: None, origin: crate::session::PromptOrigin::GoalClassifierNudge, + task_wake_fallback: None, respond_to, persist_ack: None, parsed_prompt_tx: None, @@ -2937,6 +2938,7 @@ async fn idempotency_matcher_suppresses_goal_summary_when_classifier_nudge_pendi verbatim: true, json_schema: None, origin: crate::session::PromptOrigin::GoalClassifierNudge, + task_wake_fallback: None, respond_to, persist_ack: None, parsed_prompt_tx: None, diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/goal/goal_classifier_e2e_tests.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/goal/goal_classifier_e2e_tests.rs index 24948d3..6494f52 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/goal/goal_classifier_e2e_tests.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/goal/goal_classifier_e2e_tests.rs @@ -1683,6 +1683,7 @@ async fn goal_classifier_nudge_suppresses_subsequent_goal_summary() { verbatim: true, json_schema: None, origin: PromptOrigin::GoalClassifierNudge, + task_wake_fallback: None, respond_to, persist_ack: None, parsed_prompt_tx: None, diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/idle_resume_tests.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/idle_resume_tests.rs index 6eb0862..5c9805d 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/idle_resume_tests.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/idle_resume_tests.rs @@ -181,6 +181,7 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() { previous_model: std::cell::Cell::new(None), compaction_mode: xai_chat_state::CompactionMode::Transcript, verbatim_input: true, + tool_choice: crate::util::config::CompactionToolChoice::Auto, prefire: crate::session::compaction_config::PrefireState::default(), prefix_released: std::sync::atomic::AtomicBool::new(false), }, diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/inline_auto_compact_flow_tests.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/inline_auto_compact_flow_tests.rs index 0968e20..a6d1df6 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/inline_auto_compact_flow_tests.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/inline_auto_compact_flow_tests.rs @@ -111,6 +111,7 @@ async fn create_test_actor( previous_model: std::cell::Cell::new(None), compaction_mode: xai_chat_state::CompactionMode::Transcript, verbatim_input: true, + tool_choice: crate::util::config::CompactionToolChoice::Auto, prefire: crate::session::compaction_config::PrefireState::default(), prefix_released: std::sync::atomic::AtomicBool::new(false), }, @@ -541,6 +542,7 @@ async fn create_test_actor_with_memory( previous_model: std::cell::Cell::new(None), compaction_mode: xai_chat_state::CompactionMode::Transcript, verbatim_input: true, + tool_choice: crate::util::config::CompactionToolChoice::Auto, prefire: crate::session::compaction_config::PrefireState::default(), prefix_released: std::sync::atomic::AtomicBool::new(false), }, @@ -1309,6 +1311,7 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() { previous_model: std::cell::Cell::new(None), compaction_mode: xai_chat_state::CompactionMode::Transcript, verbatim_input: true, + tool_choice: crate::util::config::CompactionToolChoice::Auto, prefire: crate::session::compaction_config::PrefireState::default(), prefix_released: std::sync::atomic::AtomicBool::new(false), }, diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/laziness/laziness_integration_tests.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/laziness/laziness_integration_tests.rs index 8764a36..35c0e4c 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/laziness/laziness_integration_tests.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/laziness/laziness_integration_tests.rs @@ -336,6 +336,7 @@ async fn idle_recheck_after_sleep_short_circuits_silently() { verbatim: true, json_schema: None, origin: crate::session::PromptOrigin::User, + task_wake_fallback: None, respond_to, persist_ack: None, parsed_prompt_tx: None, diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/memory_config_tests.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/memory_config_tests.rs index 017f434..0c79497 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/memory_config_tests.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/memory_config_tests.rs @@ -161,6 +161,7 @@ async fn create_test_actor_with_memory( previous_model: std::cell::Cell::new(None), compaction_mode: xai_chat_state::CompactionMode::Transcript, verbatim_input: true, + tool_choice: crate::util::config::CompactionToolChoice::Auto, prefire: crate::session::compaction_config::PrefireState::default(), prefix_released: std::sync::atomic::AtomicBool::new(false), }, diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/prompt_queue_actor_tests.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/prompt_queue_actor_tests.rs index ba28b8f..3420460 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/prompt_queue_actor_tests.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/prompt_queue_actor_tests.rs @@ -842,6 +842,7 @@ async fn queue_input_send_now_inserts_behind_running_front_and_requests_cancel() false, None, /* send_now */ true, + None, respond_to, None, None, @@ -902,6 +903,7 @@ async fn queue_input_stacked_send_now_prompts_insert_fifo_during_goal_turn() { false, None, /* send_now */ true, + None, respond_to, None, None, @@ -954,6 +956,7 @@ async fn queue_input_auto_send_now_only_inside_wait_window() { false, None, false, + None, respond_to, None, None, @@ -978,6 +981,7 @@ async fn queue_input_auto_send_now_only_inside_wait_window() { false, None, false, + None, respond_to, None, None, @@ -1036,6 +1040,7 @@ async fn queue_input_auto_send_now_when_wait_and_held_queue_empty() { false, None, false, + None, respond_to, None, None, @@ -1073,6 +1078,7 @@ async fn queue_input_auto_send_now_when_wait_and_held_queue_empty() { false, None, false, + None, respond_to, None, None, @@ -1140,6 +1146,7 @@ async fn queue_input_auto_send_now_during_foreground_subagent_await_window() { false, None, false, + None, respond_to, None, None, @@ -1170,6 +1177,7 @@ async fn queue_input_auto_send_now_during_foreground_subagent_await_window() { false, None, false, + None, respond_to, None, None, @@ -1229,6 +1237,7 @@ async fn queue_input_send_now_exempts_synthetic_and_goal_turns() { true, None, false, + None, respond_to, None, None, @@ -1253,6 +1262,7 @@ async fn queue_input_send_now_exempts_synthetic_and_goal_turns() { false, None, true, + None, respond_to, None, None, @@ -1380,6 +1390,7 @@ async fn queue_input_send_now_pins_front_on_running_task_identity() { false, None, /* send_now */ true, + None, respond_to, None, None, diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/recap_display_only_tests.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/recap_display_only_tests.rs index 162ba21..b21a379 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/recap_display_only_tests.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/recap_display_only_tests.rs @@ -63,6 +63,7 @@ async fn queue_input_user_prompt_bumps_recap_epoch() { false, None, false, + None, respond_to, None, None, @@ -101,6 +102,7 @@ async fn queue_input_synthetic_does_not_bump_recap_epoch() { false, None, false, + None, respond_to, None, None, diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/replay_buffer_send_update_tests.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/replay_buffer_send_update_tests.rs index 91c83fe..c92062a 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/replay_buffer_send_update_tests.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/replay_buffer_send_update_tests.rs @@ -115,6 +115,7 @@ pub(super) async fn make_replay_send_update_fixture() -> ReplaySendUpdateFixture previous_model: std::cell::Cell::new(None), compaction_mode: xai_chat_state::CompactionMode::Transcript, verbatim_input: true, + tool_choice: crate::util::config::CompactionToolChoice::Auto, prefire: crate::session::compaction_config::PrefireState::default(), prefix_released: std::sync::atomic::AtomicBool::new(false), }, diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/rewrite_zero_turn_prefix_tests.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/rewrite_zero_turn_prefix_tests.rs index cd23116..fae5288 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/rewrite_zero_turn_prefix_tests.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/rewrite_zero_turn_prefix_tests.rs @@ -98,6 +98,57 @@ async fn rebuild_reinjects_goal_update_handle() { }) .await; } +#[tokio::test(flavor = "current_thread")] +async fn rebuild_reinjects_task_completion_resource_identity() { + use xai_grok_tools::reminders::task_completion::{ + TaskCompletionReservations, TaskWakeSuppressed, + }; + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let (gw_tx, _gw_rx) = tokio::sync::mpsc::unbounded_channel(); + let (persist_tx, _persist_rx) = tokio::sync::mpsc::unbounded_channel(); + let actor = create_test_actor(0, 256_000, 85, gw_tx, persist_tx).await; + let session_reservations = actor + .tool_context + .task_completion_reservations + .clone() + .expect("session completion reservations"); + let session_gate = actor + .tool_context + .task_wake_suppressed + .clone() + .expect("session task-wake gate"); + session_reservations.reserve("before-rebuild".to_string()); + session_gate.set(true); + actor + .handle_rebuild_agent_for_definition( + xai_grok_agent::AgentDefinition::default_grok_build(), + ) + .await + .expect("zero-turn rebuild should succeed"); + let bridge = actor.agent.borrow().tool_bridge().clone(); + let resources = bridge.shared_resources().await; + let guard = resources.lock().await; + let rebuilt_reservations = guard + .get::() + .expect("rebuilt bridge completion reservations"); + let rebuilt_gate = guard + .get::() + .expect("rebuilt bridge task-wake gate"); + assert!(rebuilt_reservations.contains("before-rebuild")); + assert!(rebuilt_gate.get()); + session_reservations.release("before-rebuild"); + session_gate.set(false); + assert!(!rebuilt_reservations.contains("before-rebuild")); + assert!(!rebuilt_gate.get()); + rebuilt_reservations.reserve("from-rebuilt-bridge".to_string()); + rebuilt_gate.set(true); + assert!(session_reservations.contains("from-rebuilt-bridge")); + assert!(session_gate.get()); + }) + .await; +} /// The seeded skill used by the rebuild skill-reminder tests. A non-plugin /// Local skill is always listable, so it renders into the grok markdown skill /// catalog when the pending baseline is drained for a different agent. diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/support.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/support.rs index f844fd3..27d720f 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/support.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/support.rs @@ -159,7 +159,12 @@ pub(crate) async fn create_test_actor_ex( xai_hunk_tracker::TrackingMode::AgentOnly, tokio_util::sync::CancellationToken::new(), ); - let tool_context = ToolContext::new(cwd.clone(), None, None, fs, terminal, hunk_tracker_handle); + let mut tool_context = + ToolContext::new(cwd.clone(), None, None, fs, terminal, hunk_tracker_handle); + tool_context.task_completion_reservations = + Some(xai_grok_tools::reminders::task_completion::TaskCompletionReservations::default()); + tool_context.task_wake_suppressed = + Some(xai_grok_tools::reminders::task_completion::TaskWakeSuppressed::default()); let state = TokioMutex::new(State { running_task: None, pending_inputs: VecDeque::new(), @@ -235,6 +240,7 @@ pub(crate) async fn create_test_actor_ex( previous_model: std::cell::Cell::new(None), compaction_mode: xai_chat_state::CompactionMode::Transcript, verbatim_input: true, + tool_choice: crate::util::config::CompactionToolChoice::Auto, prefire: crate::session::compaction_config::PrefireState::default(), prefix_released: std::sync::atomic::AtomicBool::new(false), }, @@ -358,6 +364,22 @@ pub(crate) async fn create_test_actor_ex( workspace_ops: xai_grok_workspace::WorkspaceOps::for_test(), trace_config_template: std::cell::RefCell::new(None), }; + if let Some(reservations) = actor.tool_context.task_completion_reservations.clone() { + actor + .agent + .borrow() + .tool_bridge() + .update_resource(reservations) + .await; + } + if let Some(gate) = actor.tool_context.task_wake_suppressed.clone() { + actor + .agent + .borrow() + .tool_bridge() + .update_resource(gate) + .await; + } (actor, event_rx) } #[cfg(test)] @@ -399,6 +421,7 @@ pub(crate) fn user_item_with_rx( verbatim: false, json_schema: None, origin: crate::session::PromptOrigin::User, + task_wake_fallback: None, respond_to, persist_ack: None, parsed_prompt_tx: None, @@ -438,6 +461,7 @@ pub(crate) fn input_with_origin_rx( verbatim, json_schema: None, origin, + task_wake_fallback: None, respond_to, persist_ack: None, parsed_prompt_tx: None, diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/turn_completion_emit_tests.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/turn_completion_emit_tests.rs index 9e14754..7225b6e 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/turn_completion_emit_tests.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/turn_completion_emit_tests.rs @@ -70,6 +70,7 @@ fn pending_input(prompt_id: &str) -> (InputItem, oneshot::Receiver &str { match self { - Self::MonitorEvent { task_id } | Self::BashTaskCompleted { task_id } => task_id, + Self::MonitorEvent { task_id } + | Self::MonitorCompleted { task_id } + | Self::BashTaskCompleted { task_id } => task_id, } } } +#[derive(Debug)] +pub struct TaskWakeFallback { + pub prompt_id: String, + pub prompt_blocks: Vec, + pub source: NotificationSource, +} +#[derive(Debug)] +pub struct TaskWakeAdmission { + pub respond_to: oneshot::Sender, + pub fallback: TaskWakeFallback, +} pub enum SessionCommand { Initialize { system_prompt: String, @@ -142,6 +156,8 @@ pub enum SessionCommand { /// Also derived server-side during an interruptible wait (see /// [`SessionActor::queue_input`]). send_now: bool, + /// Actor-authoritative admission and deferred fallback for terminal task wakes. + admission: Option, respond_to: oneshot::Sender, /// Optional oneshot fired after the user message has been appended to /// chat history and a persistence flush barrier has completed, before @@ -567,10 +583,10 @@ pub enum SessionCommand { }, /// Cancel the running turn. `kill_background_tasks` distinguishes a hard /// teardown (subagent shutdown — drains the whole queue) from a normal - /// interactive cancel (Ctrl+C — preserves the queued prompts so the next - /// one auto-runs). On an interactive cancel only the running turn (the front - /// of `pending_inputs`) is torn down; the follow-up `maybe_start_running_task` - /// promotes the new front so the user's next queued prompt auto-runs. + /// interactive cancel (Ctrl+C — preserves queued user prompts so the next + /// one auto-runs). Ctrl+C tears down the running turn and queued terminal + /// task-completion wakes; other cancel triggers tear down only the running + /// turn. The follow-up `maybe_start_running_task` promotes the next item. Cancel { cancel_subagents: bool, kill_background_tasks: bool, diff --git a/crates/codegen/xai-grok-shell/src/session/compaction.rs b/crates/codegen/xai-grok-shell/src/session/compaction.rs index c2c4f10..bac12c2 100644 --- a/crates/codegen/xai-grok-shell/src/session/compaction.rs +++ b/crates/codegen/xai-grok-shell/src/session/compaction.rs @@ -206,6 +206,7 @@ impl SessionActor { &sampling_config, self.inference_idle_timeout, wall_clock_budget_secs, + self.compaction.tool_choice, ) .await { @@ -999,6 +1000,7 @@ impl SessionActor { sampling_config.clone(), self.inference_idle_timeout, wall_clock_budget_secs, + self.compaction.tool_choice, ); let observer = crate::session::helpers::full_replace_compaction::ShellFullReplaceObserver::new( @@ -2238,6 +2240,7 @@ mod inline_auto_compact_flow_tests { previous_model: std::cell::Cell::new(None), compaction_mode: xai_chat_state::CompactionMode::Transcript, verbatim_input: true, + tool_choice: crate::util::config::CompactionToolChoice::Auto, prefire: crate::session::compaction_config::PrefireState::default(), prefix_released: std::sync::atomic::AtomicBool::new(false), }, diff --git a/crates/codegen/xai-grok-shell/src/session/compaction_config.rs b/crates/codegen/xai-grok-shell/src/session/compaction_config.rs index e14c492..f9ebc62 100644 --- a/crates/codegen/xai-grok-shell/src/session/compaction_config.rs +++ b/crates/codegen/xai-grok-shell/src/session/compaction_config.rs @@ -147,6 +147,7 @@ pub struct CompactionConfig { pub compaction_mode: xai_chat_state::CompactionMode, /// When `true`, feed the summarizer the verbatim conversation instead of the lossy rewrite (the retry loop may still fall back). pub verbatim_input: bool, + pub tool_choice: crate::util::config::CompactionToolChoice, /// Prefire two-pass state (background NOTE₁ cache + in-flight guard). /// `Default` (empty cache, not in-flight). pub prefire: PrefireState, diff --git a/crates/codegen/xai-grok-shell/src/session/helpers/full_replace_compaction.rs b/crates/codegen/xai-grok-shell/src/session/helpers/full_replace_compaction.rs index eca6e85..c84ad29 100644 --- a/crates/codegen/xai-grok-shell/src/session/helpers/full_replace_compaction.rs +++ b/crates/codegen/xai-grok-shell/src/session/helpers/full_replace_compaction.rs @@ -71,6 +71,7 @@ pub(crate) struct ShellCompactionSampler { /// Wall-clock budget (secs) forwarded to `generate_session_compact` as the /// reasoning-runaway backstop; `0` disables it. wall_clock_budget_secs: u64, + tool_choice: crate::util::config::CompactionToolChoice, /// Full output of the most recent successful sample (for L5 telemetry). last_success: Mutex>, } @@ -87,6 +88,7 @@ impl ShellCompactionSampler { sampling_config: SamplingConfig, idle_timeout: Duration, wall_clock_budget_secs: u64, + tool_choice: crate::util::config::CompactionToolChoice, ) -> Self { Self { use_short_prompt, @@ -98,6 +100,7 @@ impl ShellCompactionSampler { sampling_config, idle_timeout, wall_clock_budget_secs, + tool_choice, last_success: Mutex::new(None), } } @@ -136,6 +139,7 @@ impl CompactionSampler for ShellCompactionSampler { &self.sampling_config, self.idle_timeout, self.wall_clock_budget_secs, + self.tool_choice, ) .await { diff --git a/crates/codegen/xai-grok-shell/src/session/helpers/session_compact.rs b/crates/codegen/xai-grok-shell/src/session/helpers/session_compact.rs index 9d474d4..cd21d8b 100644 --- a/crates/codegen/xai-grok-shell/src/session/helpers/session_compact.rs +++ b/crates/codegen/xai-grok-shell/src/session/helpers/session_compact.rs @@ -339,9 +339,7 @@ where /// prefix and force a full prefill on the summarizer call — attaching them /// keeps the request prefix byte-identical to the turn requests so the /// engine reuses the session's KV cache (the whole point of the verbatim -/// input path). Tool *use* is forbidden via `tool_choice: none` where the -/// backend can express it (ChatCompletions, Responses); the Messages wire -/// enum has no `none`, so that path relies on the prompt instruction alone. +/// input path). /// /// Errors carry a [`CompactFailure`] classification so the caller can /// short-circuit retries on deterministic failures (4xx schema violations, @@ -356,8 +354,17 @@ pub(crate) async fn generate_session_compact( sampling_config: &SamplingConfig, idle_timeout: std::time::Duration, wall_clock_budget_secs: u64, + tool_choice: crate::util::config::CompactionToolChoice, ) -> Result { let num_messages = chat_history.len(); + let wire_tool_choice = match tool_choice { + crate::util::config::CompactionToolChoice::Auto => ToolChoice::auto(), + crate::util::config::CompactionToolChoice::None => ToolChoice::none(), + }; + let conversation_tool_choice = match tool_choice { + crate::util::config::CompactionToolChoice::Auto => ConversationToolChoice::Auto, + crate::util::config::CompactionToolChoice::None => ConversationToolChoice::None, + }; let output = match sampling_config.api_backend { ApiBackend::ChatCompletions => { let chat_messages: Vec = @@ -373,7 +380,7 @@ pub(crate) async fn generate_session_compact( .map(|t| ToolDefinition::function(t.name, t.description, t.parameters)) .collect(), ) - .with_tool_choice(ToolChoice::none()); + .with_tool_choice(wire_tool_choice); } let sid = session_id.to_string(); message.x_grok_conv_id = Some(sid.clone()); @@ -467,7 +474,7 @@ pub(crate) async fn generate_session_compact( ApiBackend::Responses => { let request = ConversationRequest { items: chat_history, - tool_choice: (!tools.is_empty()).then_some(ConversationToolChoice::None), + tool_choice: (!tools.is_empty()).then_some(conversation_tool_choice), tools, hosted_tools, model: Some(sampling_config.model.to_owned()), @@ -1578,6 +1585,7 @@ mod reasoning_compaction_regression_tests { &config, std::time::Duration::from_secs(30), 0, + crate::util::config::CompactionToolChoice::Auto, ) .await .unwrap_or_else(|_| panic!("compaction must succeed")); @@ -1666,6 +1674,7 @@ mod reasoning_compaction_regression_tests { &config, std::time::Duration::from_secs(30), 0, + crate::util::config::CompactionToolChoice::Auto, ) .await; let output = result @@ -1673,12 +1682,8 @@ mod reasoning_compaction_regression_tests { assert_eq!(output.content, "ok"); let _ = shutdown_tx.send(()); } - /// The compaction request must carry the turn loop's tool definitions - /// (prompt-prefix/KV-cache alignment) with `tool_choice: "none"`, and - /// must omit both keys when no tools are passed (Chat Completions rejects a bare - /// `tool_choice`). #[tokio::test] - async fn chat_completions_compaction_attaches_tools_with_tool_choice_none() { + async fn chat_completions_compaction_attaches_tools_with_tool_choice_auto() { use std::sync::{Arc, Mutex}; let captured: Arc>> = Arc::new(Mutex::new(Vec::new())); let cap = captured.clone(); @@ -1731,6 +1736,7 @@ mod reasoning_compaction_regression_tests { &config, std::time::Duration::from_secs(30), 0, + crate::util::config::CompactionToolChoice::Auto, ) .await .unwrap_or_else(|_| panic!("compaction with tools must succeed")); @@ -1744,6 +1750,7 @@ mod reasoning_compaction_regression_tests { &config, std::time::Duration::from_secs(30), 0, + crate::util::config::CompactionToolChoice::Auto, ) .await .unwrap_or_else(|_| panic!("compaction without tools must succeed")); @@ -1752,8 +1759,8 @@ mod reasoning_compaction_regression_tests { let with_tools = &bodies[0]; assert_eq!( with_tools["tool_choice"], - json!("none"), - "tool use must be disabled at decode time" + json!("auto"), + "default compaction tool_choice is auto" ); let sent_tools = with_tools["tools"] .as_array() @@ -1771,6 +1778,149 @@ mod reasoning_compaction_regression_tests { ); let _ = shutdown_tx.send(()); } + fn responses_summary_stream() -> Vec { + vec![ + Event::default().data( + json!({ "type" : "response.created", "sequence_number" + : 0, "response" : { "id" : "resp_test", "object" : "response", "created_at" : + 1234567890, "model" : "test-model", "status" : "in_progress", "output" : [] } + }) + .to_string(), + ), + Event::default().data( + json!({ "type" : + "response.output_text.delta", "sequence_number" : 1, "item_id" : "msg_test", + "output_index" : 0, "content_index" : 0, "delta" : "ok" }) + .to_string(), + ), + Event::default().data( + json!({ "type" : "response.completed", + "sequence_number" : 2, "response" : { "id" : "resp_test", "object" : + "response", "created_at" : 1234567890, "model" : "test-model", "status" : + "completed", "output" : [] } }) + .to_string(), + ), + ] + } + fn test_config_responses(base_url: &str) -> SamplerConfig { + let mut config = test_config(base_url); + config.api_backend = ApiBackend::Responses; + config + } + #[tokio::test] + async fn responses_compaction_attaches_tools_with_tool_choice_auto() { + use std::sync::{Arc, Mutex}; + let captured: Arc>> = Arc::new(Mutex::new(Vec::new())); + let cap = captured.clone(); + let app = Router::new().route( + "/v1/responses", + post(move |body: axum::Json| { + let cap = cap.clone(); + async move { + cap.lock().unwrap().push(body.0); + let stream = stream::iter( + responses_summary_stream() + .into_iter() + .map(Ok::<_, std::convert::Infallible>), + ); + Sse::new(stream).keep_alive(KeepAlive::default()) + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); + tokio::spawn(async move { + axum::serve(listener, app) + .with_graceful_shutdown(async { + let _ = shutdown_rx.await; + }) + .await + .unwrap(); + }); + let base_url = format!("http://{addr}/v1"); + let config = test_config_responses(&base_url); + let chat_history = vec![ + ConversationItem::system("You are a helpful assistant."), + ConversationItem::user("\nfix the bug\n"), + ConversationItem::assistant("I fixed it."), + ConversationItem::user("Summarize the conversation so far."), + ]; + let tools = vec![ToolSpec { + name: "read_file".to_string(), + description: Some("Reads a file".to_string()), + parameters: json!({ "type" : "object", "properties" : {} }), + }]; + let hosted = vec![HostedTool::WebSearch { + allowed_domains: None, + }]; + let client = Client::new(config.clone()).unwrap(); + generate_session_compact( + chat_history.clone(), + tools, + hosted, + client, + acp::SessionId::new("test-session"), + &config, + std::time::Duration::from_secs(30), + 0, + crate::util::config::CompactionToolChoice::Auto, + ) + .await + .unwrap_or_else(|_| panic!("Responses compaction with tools must succeed")); + let client = Client::new(config.clone()).unwrap(); + generate_session_compact( + chat_history, + vec![], + vec![], + client, + acp::SessionId::new("test-session"), + &config, + std::time::Duration::from_secs(30), + 0, + crate::util::config::CompactionToolChoice::Auto, + ) + .await + .unwrap_or_else(|_| panic!("Responses compaction without tools must succeed")); + let bodies = captured.lock().unwrap(); + assert_eq!(bodies.len(), 2, "mock must have served both requests"); + let with_tools = &bodies[0]; + assert_eq!( + with_tools["tool_choice"], + json!("auto"), + "default Responses compaction tool_choice is auto" + ); + let sent_tools = with_tools["tools"] + .as_array() + .expect("tools must be attached for prefix-cache alignment"); + let has_read_file = sent_tools.iter().any(|t| { + t.get("name") == Some(&json!("read_file")) + || t.pointer("/name") == Some(&json!("read_file")) + }); + assert!( + has_read_file, + "client function tool must be present: {sent_tools:?}" + ); + assert!( + sent_tools + .iter() + .any(|t| t.get("type") == Some(&json!("web_search"))), + "hosted web_search must be present for prefix alignment: {sent_tools:?}" + ); + let without_tools = &bodies[1]; + assert!( + without_tools + .get("tools") + .map(|t| t.as_array().is_none_or(|a| a.is_empty())) + .unwrap_or(true), + "no tools when none are passed" + ); + assert!( + without_tools.get("tool_choice").is_none(), + "tool_choice without tools should be omitted" + ); + let _ = shutdown_tx.send(()); + } #[tokio::test] async fn stalled_compaction_stream_times_out_as_transient() { let app = Router::new().route( @@ -1807,6 +1957,7 @@ mod reasoning_compaction_regression_tests { &config, std::time::Duration::from_millis(150), 0, + crate::util::config::CompactionToolChoice::Auto, ) .await; match result { @@ -1874,6 +2025,7 @@ mod reasoning_compaction_regression_tests { &config, std::time::Duration::from_millis(150), 0, + crate::util::config::CompactionToolChoice::Auto, ) .await; match result { @@ -1945,6 +2097,7 @@ mod reasoning_compaction_regression_tests { &config, std::time::Duration::from_millis(150), 0, + crate::util::config::CompactionToolChoice::Auto, ) .await; match result { @@ -2013,6 +2166,7 @@ mod reasoning_compaction_regression_tests { &config, std::time::Duration::from_millis(150), 0, + crate::util::config::CompactionToolChoice::Auto, ) .await; match result { diff --git a/crates/codegen/xai-grok-shell/src/session/mod.rs b/crates/codegen/xai-grok-shell/src/session/mod.rs index cc61f43..3457bec 100644 --- a/crates/codegen/xai-grok-shell/src/session/mod.rs +++ b/crates/codegen/xai-grok-shell/src/session/mod.rs @@ -137,7 +137,7 @@ impl PromptOrigin { } } /// If this is an auto-wake prompt, returns the inner completion ID - /// (task or subagent ID). Used to pre-mark reported completions. + /// (task or subagent ID). Used by queue preemption and cancellation cleanup. pub fn completion_id(&self) -> Option<&str> { match self { Self::TaskCompleted { task_id } => Some(task_id), diff --git a/crates/codegen/xai-grok-shell/src/test_support/lsp_runtime.rs b/crates/codegen/xai-grok-shell/src/test_support/lsp_runtime.rs index f79a3ad..4467e19 100644 --- a/crates/codegen/xai-grok-shell/src/test_support/lsp_runtime.rs +++ b/crates/codegen/xai-grok-shell/src/test_support/lsp_runtime.rs @@ -97,7 +97,6 @@ pub(crate) fn ctx_with_toggle(toggle: HashMap) -> SubagentSpawnCon parent_session_info: None, subagent_roles: HashMap::new(), subagent_personas: HashMap::new(), - persona_io_summaries: Vec::new(), parent_chat_state: None, available_models: indexmap::IndexMap::new(), subagent_model_overrides: HashMap::new(), @@ -141,7 +140,7 @@ pub(crate) fn ctx_with_toggle(toggle: HashMap) -> SubagentSpawnCon parent_skills: None, parent_skills_config: xai_grok_agent::prompt::skills::SkillsConfig::default(), parent_compat: xai_grok_tools::types::compat::CompatConfig::default(), - auto_wake_delivered: None, + task_completion_reservations: None, synthetic_trace_tx: None, task_output_tool_name: xai_grok_tools::reminders::task_completion::DEFAULT_TASK_OUTPUT_TOOL .to_string(), diff --git a/crates/codegen/xai-grok-shell/src/tools/config.rs b/crates/codegen/xai-grok-shell/src/tools/config.rs index 4330c62..e492506 100644 --- a/crates/codegen/xai-grok-shell/src/tools/config.rs +++ b/crates/codegen/xai-grok-shell/src/tools/config.rs @@ -35,6 +35,7 @@ pub struct BashToolConfig { /// Whether to allow a background `&` operator in foreground commands /// (default: `true`). Resolution: config.toml (this) > remote settings > `true`. pub allow_background_operator: Option, + pub login_shell_capture: Option, } impl BashToolConfig { @@ -110,6 +111,10 @@ pub struct WebFetchToolConfig { /// default allowlist. An explicit empty list blocks all fetches. /// Resolution: TOML > remote settings > built-in defaults. pub allowed_domains: Option>, + /// Allow fetches to explicit loopback hosts only (`localhost` / `127.0.0.0/8` + /// / `::1`). Private and metadata ranges stay blocked. Default off. + /// Resolution: TOML > `GROK_WEB_FETCH_ALLOW_LOCAL` env > false. + pub allow_local: Option, } impl WebFetchToolConfig { @@ -139,10 +144,15 @@ impl WebFetchToolConfig { .cloned() .or_else(|| remote_domains.map(|d| d.to_vec())); + let allow_local = self + .allow_local + .or_else(|| xai_grok_config::env_bool("GROK_WEB_FETCH_ALLOW_LOCAL")); + xai_grok_tools::implementations::grok_build::web_fetch::WebFetchParams { proxy_endpoint, allowed_domains, context_window_tokens, + allow_local, ..Default::default() } } @@ -576,6 +586,7 @@ mod tests { let local = WebFetchToolConfig { proxy_endpoint: Some("https://toml-proxy.example.com".to_owned()), allowed_domains: Some(vec!["toml.example.com".to_owned()]), + allow_local: Some(true), }; let params = local.resolve_params( Some("https://remote-proxy.example.com"), @@ -590,6 +601,8 @@ mod tests { params.allowed_domains, Some(vec!["toml.example.com".to_owned()]) ); + assert_eq!(params.allow_local, Some(true)); + assert!(params.allow_local()); } #[test] @@ -608,6 +621,7 @@ mod tests { params.allowed_domains, Some(vec!["remote.example.com".to_owned()]) ); + assert!(!params.allow_local()); } #[test] @@ -616,6 +630,7 @@ mod tests { let params = local.resolve_params(None, None, None); assert!(params.proxy_endpoint.is_none()); assert!(params.allowed_domains.is_none()); + assert!(!params.allow_local()); } #[test] @@ -623,6 +638,7 @@ mod tests { let local = WebFetchToolConfig { proxy_endpoint: None, allowed_domains: Some(vec![]), + allow_local: None, }; let params = local.resolve_params(None, Some(&["remote.example.com".to_owned()]), None); assert_eq!(params.allowed_domains, Some(vec![])); diff --git a/crates/codegen/xai-grok-shell/src/tools/notification_bridge.rs b/crates/codegen/xai-grok-shell/src/tools/notification_bridge.rs index 0878f23..85c7aee 100644 --- a/crates/codegen/xai-grok-shell/src/tools/notification_bridge.rs +++ b/crates/codegen/xai-grok-shell/src/tools/notification_bridge.rs @@ -17,6 +17,8 @@ use crate::session::commands::{NotificationPriority, NotificationSource}; use crate::session::persistence::PersistenceMsg; use xai_grok_workspace::session::file_state::FileStateTracker; +const TASK_WAKE_ADMISSION_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(250); + /// Configuration for the notification bridge. pub struct NotificationBridgeConfig { /// ACP gateway for sending streaming updates to TUI @@ -55,9 +57,9 @@ pub struct NotificationBridgeConfig { pub turn_prompt_mode: Arc>, /// Session command channel for monitor events and task-completed injections. pub session_cmd_tx: mpsc::UnboundedSender, - /// Shared set of IDs delivered via auto-wake, used to suppress duplicate - /// `TaskCompletionReminder` entries for the same task/subagent. - pub auto_wake_delivered: xai_grok_tools::reminders::task_completion::AutoWakeDeliveredIds, + pub task_completion_reservations: + xai_grok_tools::reminders::task_completion::TaskCompletionReservations, + pub task_wake_suppressed: xai_grok_tools::reminders::task_completion::TaskWakeSuppressed, /// Channel for requesting trace uploads for synthetic auto-wake turns. /// Wrapped in `Arc>` because the coordinator creates the channel /// after the notification bridge is spawned — the bridge reads the latest @@ -331,20 +333,11 @@ async fn handle_notification( .goal_loop_active .load(std::sync::atomic::Ordering::Relaxed); - // Block-waited / explicitly-killed: the model already has the result - // (blocking wait return or kill_task tool response). Skip auto-wake - // for both bash and monitors — a redundant synthetic prompt is noise. - // - // Natural monitor exit (including exit code 0) MUST auto-wake the - // same way bash does. Relying only on the pipeline's terminal - // `MonitorEvent` + idle-gated `InjectNotification` was easy to miss - // when the agent was idle and the monitor produced no further - // stdout. The pager still receives x.ai/task_completed below for UI. - // Stamped on the completion notification below so the pager knows - // whether a wake response follows the chip. + // Natural monitor exit uses the same immediate wake path as bash; + // x.ai/task_completed still drives the pager UI in every branch. let mut will_wake = false; if task_snapshot.block_waited || task_snapshot.explicitly_killed { - // no auto-wake + // The blocking wait or kill result already reports completion. } else if goal_loop_active { // Goal loop active: suppress the wake (synthetic prompt + the // idle-gated fallback); surfaces 2/3 drain it. See @@ -355,21 +348,7 @@ async fn handle_notification( "auto-wake: suppressed completion (goal loop active)" ); } else if config.auto_wake_enabled { - // Mark delivered so `TaskCompletionReminder` suppresses the - // duplicate on the next tool call (bash and monitor alike). - config.auto_wake_delivered.insert(task_id.clone()); - - // Monitor exit: the TaskCompleted Prompt is the sole model-facing - // wake. Drop any already-queued pipeline MonitorEvents for this - // task (stdout lines + terminal ended) so they do not start a - // second NotificationDrain turn after the wake. - if is_monitor { - let _ = config - .session_cmd_tx - .send(SessionCommand::DropMonitorNotifications { - task_id: task_id.clone(), - }); - } + config.task_completion_reservations.reserve(task_id.clone()); let tool_name = resolved_tool_name(&config.task_output_tool_name); let read_name = resolved_tool_name(&config.read_tool_name); @@ -389,25 +368,20 @@ async fn handle_notification( let prompt_id = format!("task-completed-{task_id}"); let prompt_blocks = vec![acp::ContentBlock::Text(acp::TextContent::new(message))]; - // Capture a pre-prompt session snapshot for the trace upload path. - let (before_copy_tx, before_copy_rx) = tokio::sync::oneshot::channel(); - let _ = config.session_cmd_tx.send(SessionCommand::CopyFile { - respond_to: before_copy_tx, - }); - + let synthetic_trace_tx = config + .synthetic_trace_tx + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(); let (respond_to, completion_rx) = tokio::sync::oneshot::channel(); + let (admission_tx, admission_rx) = tokio::sync::oneshot::channel(); tracing::info!( task_id = %task_id, prompt_id = %prompt_id, is_monitor, - "auto-wake: injecting synthetic prompt for completed background task" + "auto-wake: requesting synthetic prompt admission for completed background task" ); - // Stamp from the actual enqueue: `will_wake` on the completion - // notification must never promise a wake this send didn't queue - // (mirrors `parent_channel_open` in `should_auto_wake_subagent`). - // The channel is unbounded, so this only fails when the session - // actor is already gone. - will_wake = config + let enqueued = config .session_cmd_tx .send(SessionCommand::Prompt { prompt_id: prompt_id.clone(), @@ -420,32 +394,99 @@ async fn handle_notification( traceparent: xai_file_utils::trace_context::current_traceparent(), json_schema: None, send_now: false, + admission: Some(crate::session::commands::TaskWakeAdmission { + respond_to: admission_tx, + fallback: crate::session::commands::TaskWakeFallback { + prompt_id: if is_monitor { + format!("monitor-completed-{task_id}") + } else { + format!("bash-completed-{task_id}") + }, + prompt_blocks: vec![acp::ContentBlock::Text( + acp::TextContent::new(body.clone()), + )], + source: if is_monitor { + NotificationSource::MonitorCompleted { + task_id: task_id.clone(), + } + } else { + NotificationSource::BashTaskCompleted { + task_id: task_id.clone(), + } + }, + }, + }), respond_to, persist_ack: None, parsed_prompt_tx: None, }) .is_ok(); - - if let Some(ref trace_tx) = *config - .synthetic_trace_tx - .lock() - .unwrap_or_else(|e| e.into_inner()) - { - tracing::info!( - task_id = %task_id, - "auto-wake: sending synthetic turn trace request" - ); - let _ = trace_tx.send(crate::upload::turn::SyntheticTurnTraceRequest { - session_id: config.session_id.clone(), - prompt_id, - completion_rx, - before_session_copy_rx: before_copy_rx, - }); + if !enqueued { + config.task_completion_reservations.release(&task_id); + } + let admitted = if enqueued { + tokio::time::timeout(TASK_WAKE_ADMISSION_TIMEOUT, admission_rx) + .await + .ok() + .and_then(Result::ok) + .unwrap_or(false) } else { - tracing::debug!( - task_id = %task_id, - "auto-wake: no synthetic_trace_tx, skipping trace request" - ); + false + }; + will_wake = admitted; + xai_grok_telemetry::unified_log::info( + "shell.task_wake.bridge_admission", + Some(config.session_id.0.as_ref()), + Some(serde_json::json!({ + "task_id": &task_id, + "monitor": is_monitor, + "enqueued": enqueued, + "admitted": admitted, + "gate": config.task_wake_suppressed.get(), + })), + ); + + if will_wake { + if is_monitor { + let _ = + config + .session_cmd_tx + .send(SessionCommand::DropMonitorNotifications { + task_id: task_id.clone(), + }); + } + if let Some(trace_tx) = synthetic_trace_tx { + let (before_copy_tx, before_session_copy_rx) = + tokio::sync::oneshot::channel(); + let copy_requested = config + .session_cmd_tx + .send(SessionCommand::CopyFile { + respond_to: before_copy_tx, + }) + .is_ok(); + if copy_requested { + tracing::info!( + task_id = %task_id, + "auto-wake: sending synthetic turn trace request" + ); + let _ = trace_tx.send(crate::upload::turn::SyntheticTurnTraceRequest { + session_id: config.session_id.clone(), + prompt_id, + completion_rx, + before_session_copy_rx, + }); + } else { + tracing::debug!( + task_id = %task_id, + "auto-wake: session snapshot request failed, skipping trace request" + ); + } + } else { + tracing::debug!( + task_id = %task_id, + "auto-wake: no synthetic trace consumer, skipping trace request" + ); + } } } else { // Auto-wake disabled — fall back to idle-gated notification drain. @@ -463,17 +504,28 @@ async fn handle_notification( read_name, ) }; - let prompt_id = format!("bash-completed-{task_id}"); - let prompt_blocks = vec![acp::ContentBlock::Text(acp::TextContent::new(message))]; + let source = if is_monitor { + NotificationSource::MonitorCompleted { + task_id: task_id.clone(), + } + } else { + NotificationSource::BashTaskCompleted { + task_id: task_id.clone(), + } + }; let _ = config .session_cmd_tx .send(SessionCommand::InjectNotification { - prompt_id, - prompt_blocks, - priority: NotificationPriority::Later, - source: NotificationSource::BashTaskCompleted { - task_id: task_id.clone(), + prompt_id: if is_monitor { + format!("monitor-completed-{task_id}") + } else { + format!("bash-completed-{task_id}") }, + prompt_blocks: vec![acp::ContentBlock::Text(acp::TextContent::new( + message, + ))], + priority: NotificationPriority::Later, + source, }); } @@ -702,7 +754,7 @@ async fn handle_notification( // If this monitor already auto-woke via TaskCompleted, do not inject // model-facing notifications (avoids a second NotificationDrain turn // with the same ended signal). Pager UI still got the event above. - if config.auto_wake_delivered.contains(&event.task_id) { + if config.task_completion_reservations.contains(&event.task_id) { tracing::debug!( task_id = %event.task_id, "skipping model inject for monitor event: task already auto-woke via TaskCompleted" @@ -808,6 +860,38 @@ mod tests { use xai_grok_tools::computer::types::TaskKind; use xai_grok_tools::types::TaskSnapshot; + /// Drive the admission handshake inline so receiver assertions observe the + /// bridge's command order without racing a detached proxy task. + async fn handle_notification_with_admission( + config: &NotificationBridgeConfig, + notification: ToolNotification, + offsets: &mut HashMap, + cmd_rx: &mut mpsc::UnboundedReceiver, + accepted: bool, + ) { + let notification = handle_notification(config, notification, offsets); + tokio::pin!(notification); + + let mut command = tokio::select! { + _ = &mut notification => panic!("notification completed before requesting admission"), + command = cmd_rx.recv() => command.expect("expected task-wake prompt"), + }; + let SessionCommand::Prompt { admission, .. } = &mut command else { + panic!("expected task-wake prompt"); + }; + admission + .take() + .expect("expected task-wake admission request") + .respond_to + .send(accepted) + .expect("notification must still be awaiting admission"); + config + .session_cmd_tx + .send(command) + .expect("test command receiver must remain open"); + notification.await; + } + fn make_test_config() -> ( NotificationBridgeConfig, mpsc::UnboundedReceiver, @@ -816,15 +900,22 @@ mod tests { (config, session_cmd_rx) } - /// Same as [`make_test_config`] but also returns the gateway and - /// persistence receivers so a test can inspect the notifications - /// emitted by `handle_notification`. Use this for plan-mode tests. #[allow(clippy::type_complexity)] fn make_test_config_full() -> ( NotificationBridgeConfig, mpsc::UnboundedReceiver, mpsc::UnboundedReceiver, mpsc::UnboundedReceiver, + ) { + make_test_config_full_raw() + } + + #[allow(clippy::type_complexity)] + fn make_test_config_full_raw() -> ( + NotificationBridgeConfig, + mpsc::UnboundedReceiver, + mpsc::UnboundedReceiver, + mpsc::UnboundedReceiver, ) { let (gateway_tx, gateway_rx) = mpsc::unbounded_channel(); let gateway = xai_acp_lib::AcpAgentGatewaySender::new(gateway_tx); @@ -850,8 +941,10 @@ mod tests { crate::session::plan_mode::PromptMode::Agent, )), session_cmd_tx, - auto_wake_delivered: - xai_grok_tools::reminders::task_completion::AutoWakeDeliveredIds::default(), + task_completion_reservations: + xai_grok_tools::reminders::task_completion::TaskCompletionReservations::default(), + task_wake_suppressed: + xai_grok_tools::reminders::task_completion::TaskWakeSuppressed::default(), synthetic_trace_tx: Arc::new(std::sync::Mutex::new(None)), task_output_tool_name: Arc::new(std::sync::OnceLock::new()), read_tool_name: Arc::new(std::sync::OnceLock::new()), @@ -896,14 +989,11 @@ mod tests { let notification = ToolNotification::TaskCompleted(snapshot); let mut offsets = HashMap::new(); - handle_notification(&config, notification, &mut offsets).await; + handle_notification_with_admission(&config, notification, &mut offsets, &mut cmd_rx, true) + .await; - // Auto-wake sends CopyFile first, then Prompt (not InjectNotification). - let cmd1 = cmd_rx.try_recv().expect("expected CopyFile"); - assert!(matches!(cmd1, SessionCommand::CopyFile { .. })); - - let cmd2 = cmd_rx.try_recv().expect("expected Prompt"); - match cmd2 { + let command = cmd_rx.try_recv().expect("expected Prompt"); + match command { SessionCommand::Prompt { prompt_id, prompt_blocks, @@ -946,7 +1036,7 @@ mod tests { /// Gap 1: while a goal loop is active, a completed background bash task /// must NOT fire the synthetic auto-wake prompt — an async "task completed" /// wake mid-goal derails a weak model. It must also NOT be marked - /// auto-wake-delivered (so surface 2's `TaskCompletionReminder` is free to + /// reserved (so surface 2's `TaskCompletionReminder` is free to /// drain it). The pager's `x.ai/task_completed` notification still fires. #[tokio::test] async fn bash_task_completed_suppresses_auto_wake_during_goal_loop() { @@ -983,10 +1073,10 @@ mod tests { cmd_rx.try_recv().is_err(), "goal-loop-active bash completion must not inject auto-wake commands" ); - // Not marked auto-wake-delivered: surface 2 must be free to drain it. + // Not marked reserved: surface 2 must be free to drain it. assert!( - config.auto_wake_delivered.snapshot().is_empty(), - "goal-loop-active completion must not be marked auto-wake-delivered" + config.task_completion_reservations.snapshot().is_empty(), + "goal-loop-active completion must not be marked reserved" ); // The pager UI notification must still be emitted. let mut found_ext = false; @@ -1005,31 +1095,27 @@ mod tests { /// Gap 1 (preserve non-goal behavior): with the goal loop inactive — the /// default for a normal session — a completed bash task DOES fire the - /// synthetic auto-wake prompt AND is marked auto-wake-delivered so surface + /// synthetic auto-wake prompt AND is marked reserved so surface /// 2 suppresses the duplicate reminder. #[tokio::test] - async fn bash_task_completed_auto_wakes_and_marks_delivered_without_goal_loop() { + async fn bash_task_completed_auto_wakes_and_reserves_without_goal_loop() { let (config, mut cmd_rx) = make_test_config(); config .task_output_tool_name .set(Some("get_command_or_subagent_output".to_string())) .expect("slot is fresh in this test fixture"); - // goal_loop_active defaults to false (normal session). let snapshot = make_task_snapshot("bg-normal", TaskKind::Bash); let mut offsets = HashMap::new(); - handle_notification( + handle_notification_with_admission( &config, ToolNotification::TaskCompleted(snapshot), &mut offsets, + &mut cmd_rx, + true, ) .await; - // Auto-wake sends CopyFile, then the synthetic Prompt. - assert!(matches!( - cmd_rx.try_recv(), - Ok(SessionCommand::CopyFile { .. }) - )); assert!(matches!( cmd_rx.try_recv(), Ok(SessionCommand::Prompt { .. }) @@ -1038,14 +1124,12 @@ mod tests { cmd_rx.try_recv(), Ok(SessionCommand::DispatchNotificationHook { .. }) )); - // And the task IS marked auto-wake-delivered. assert_eq!( - config.auto_wake_delivered.snapshot(), + config.task_completion_reservations.snapshot(), vec!["bg-normal".to_string()], ); } - /// `will_wake` off the emitted `x.ai/task_completed` params. fn task_completed_will_wake( gateway_rx: &mut mpsc::UnboundedReceiver, ) -> Option { @@ -1065,56 +1149,209 @@ mod tests { /// follows, emit when nothing else will mark the moment). #[tokio::test] async fn task_completed_notification_stamps_will_wake() { - // Wake leg: auto-wake enabled, no suppression. - let (config, mut gateway_rx, _persistence_rx, _cmd_rx) = make_test_config_full(); + let (config, mut gateway_rx, _persistence_rx, mut cmd_rx) = make_test_config_full(); config .task_output_tool_name .set(Some("get_command_or_subagent_output".to_string())) .expect("slot is fresh in this test fixture"); + let (trace_tx, mut trace_rx) = mpsc::unbounded_channel(); + *config + .synthetic_trace_tx + .lock() + .unwrap_or_else(|e| e.into_inner()) = Some(trace_tx); let mut offsets = HashMap::new(); - handle_notification( + handle_notification_with_admission( &config, ToolNotification::TaskCompleted(make_task_snapshot("bg-wake", TaskKind::Bash)), &mut offsets, + &mut cmd_rx, + true, ) .await; + assert!(matches!( + cmd_rx.recv().await, + Some(SessionCommand::Prompt { .. }) + )); + match cmd_rx.recv().await { + Some(SessionCommand::CopyFile { respond_to }) => drop(respond_to), + _ => panic!("trace copy must follow accepted prompt admission"), + } assert_eq!( task_completed_will_wake(&mut gateway_rx), Some(true), "an auto-woken completion must stamp will_wake: true" ); + assert!( + trace_rx.try_recv().is_ok(), + "accepted admission must request a synthetic-turn trace" + ); - // Suppressed leg: goal loop active — no wake follows the chip. - let (config, mut gateway_rx, _persistence_rx, _cmd_rx) = make_test_config_full(); - config - .goal_loop_active - .store(true, std::sync::atomic::Ordering::Relaxed); + let (config, mut gateway_rx, mut persistence_rx, mut cmd_rx) = make_test_config_full(); + let (trace_tx, mut trace_rx) = mpsc::unbounded_channel(); + *config + .synthetic_trace_tx + .lock() + .unwrap_or_else(|e| e.into_inner()) = Some(trace_tx); let mut offsets = HashMap::new(); - handle_notification( + handle_notification_with_admission( &config, - ToolNotification::TaskCompleted(make_task_snapshot("bg-goal", TaskKind::Bash)), + ToolNotification::TaskCompleted(make_task_snapshot("bg-declined", TaskKind::Bash)), &mut offsets, + &mut cmd_rx, + false, ) .await; assert_eq!( task_completed_will_wake(&mut gateway_rx), Some(false), - "a suppressed completion must stamp will_wake: false" + "an actor-declined completion must stamp will_wake: false" + ); + assert!( + config.task_completion_reservations.contains("bg-declined"), + "the actor owns reservation release after queuing the deferred fallback" + ); + assert!( + trace_rx.try_recv().is_err(), + "declined admission must not request a synthetic-turn trace" + ); + assert!(matches!( + cmd_rx.try_recv(), + Ok(SessionCommand::Prompt { .. }) + )); + assert!(matches!( + cmd_rx.try_recv(), + Ok(SessionCommand::DispatchNotificationHook { .. }) + )); + let mut persisted = false; + while let Ok(message) = persistence_rx.try_recv() { + if let PersistenceMsg::Update(crate::session::storage::SessionUpdate::Xai(update)) = + message + && matches!( + &update.update, + crate::extensions::notification::SessionUpdate::TaskCompleted { .. } + ) + { + persisted = true; + } + } + assert!( + persisted, + "declined admission must still persist x.ai/task_completed" + ); + } + + #[tokio::test(start_paused = true)] + async fn stalled_admission_is_bounded_and_task_completion_still_emits() { + let (config, mut gateway_rx, mut persistence_rx, mut cmd_rx) = make_test_config_full_raw(); + config + .task_output_tool_name + .set(Some("get_command_or_subagent_output".to_string())) + .expect("slot is fresh in this test fixture"); + let mut offsets = HashMap::new(); + let notification = handle_notification( + &config, + ToolNotification::TaskCompleted(make_task_snapshot("bg-stalled", TaskKind::Bash)), + &mut offsets, + ); + tokio::pin!(notification); + + tokio::select! { + _ = &mut notification => panic!("admission should still be waiting"), + command = cmd_rx.recv() => assert!(matches!(command, Some(SessionCommand::Prompt { .. }))), + } + tokio::time::advance(TASK_WAKE_ADMISSION_TIMEOUT + std::time::Duration::from_millis(1)) + .await; + tokio::task::yield_now().await; + notification.await; + + assert_eq!(task_completed_will_wake(&mut gateway_rx), Some(false)); + assert!( + config.task_completion_reservations.contains("bg-stalled"), + "a timed-out admission may still be handled and deferred by the actor" + ); + let mut persisted_completion = false; + while let Ok(message) = persistence_rx.try_recv() { + if let PersistenceMsg::Update(crate::session::storage::SessionUpdate::Xai(update)) = + message + && matches!( + &update.update, + crate::extensions::notification::SessionUpdate::TaskCompleted { .. } + ) + { + persisted_completion = true; + } + } + assert!(persisted_completion); + } + + #[tokio::test(start_paused = true)] + async fn timed_out_monitor_admission_queues_one_fallback_and_late_actor_drops_prompt() { + let (config, mut gateway_rx, _persistence_rx, mut cmd_rx) = make_test_config_full_raw(); + config + .task_output_tool_name + .set(Some("get_command_or_subagent_output".to_string())) + .expect("slot is fresh in this test fixture"); + let mut offsets = HashMap::new(); + let notification = handle_notification( + &config, + ToolNotification::TaskCompleted(make_task_snapshot("mon-timeout", TaskKind::Monitor)), + &mut offsets, + ); + tokio::pin!(notification); + let prompt = tokio::select! { + _ = &mut notification => panic!("admission should still be waiting"), + command = cmd_rx.recv() => command.expect("prompt command"), + }; + tokio::time::advance(TASK_WAKE_ADMISSION_TIMEOUT + std::time::Duration::from_millis(1)) + .await; + tokio::task::yield_now().await; + notification.await; + + let SessionCommand::Prompt { + admission: Some(admission), + respond_to, + .. + } = prompt + else { + panic!("expected task wake prompt"); + }; + assert!(matches!( + admission.fallback.source, + NotificationSource::MonitorCompleted { ref task_id } if task_id == "mon-timeout" + )); + assert!(admission.respond_to.send(true).is_err()); + let _ = respond_to.send(Ok(crate::session::commands::PromptTurnOk { + stop_reason: acp::StopReason::Cancelled, + total_tokens: 0, + turn_snapshot: None, + completion_kind: crate::session::commands::PromptCompletionKind::RemovedFromQueue, + structured_output: None, + usage: None, + })); + + assert!(matches!( + cmd_rx.try_recv(), + Ok(SessionCommand::DispatchNotificationHook { .. }) + )); + assert!(cmd_rx.try_recv().is_err()); + assert_eq!(task_completed_will_wake(&mut gateway_rx), Some(false)); + assert!( + config.task_completion_reservations.contains("mon-timeout"), + "the late actor fallback retains the reservation until user delivery" ); } - /// Dead session actor: the synthetic Prompt enqueue fails, so no wake will - /// ever run — the notification must stamp `will_wake: false`, not promise a - /// wake the send didn't queue (the pager would suppress its between-turns - /// status line for a wake that never comes). #[tokio::test] async fn task_completed_stamps_will_wake_false_when_session_channel_closed() { - let (config, mut gateway_rx, _persistence_rx, cmd_rx) = make_test_config_full(); + let (config, mut gateway_rx, _persistence_rx, cmd_rx) = make_test_config_full_raw(); config .task_output_tool_name .set(Some("get_command_or_subagent_output".to_string())) .expect("slot is fresh in this test fixture"); drop(cmd_rx); + config + .task_completion_reservations + .reserve("bg-dead".into()); let mut offsets = HashMap::new(); handle_notification( &config, @@ -1127,6 +1364,9 @@ mod tests { Some(false), "a completion whose wake prompt could not be enqueued must stamp will_wake: false" ); + assert!(config.task_completion_reservations.contains("bg-dead")); + config.task_completion_reservations.release("bg-dead"); + assert!(!config.task_completion_reservations.contains("bg-dead")); } /// Gap 1 (adjacent branch): the goal-loop arm sits BEFORE the @@ -1165,8 +1405,7 @@ mod tests { cmd_rx.try_recv().is_err(), "goal-loop-active completion must not InjectNotification with auto-wake disabled" ); - // And not marked auto-wake-delivered. - assert!(config.auto_wake_delivered.snapshot().is_empty()); + assert!(config.task_completion_reservations.snapshot().is_empty()); } /// Natural monitor exit (including exit code 0) must immediate-auto-wake @@ -1186,27 +1425,15 @@ mod tests { snapshot.exit_code = Some(0); let mut offsets = HashMap::new(); - handle_notification( + handle_notification_with_admission( &config, ToolNotification::TaskCompleted(snapshot), &mut offsets, + &mut cmd_rx, + true, ) .await; - // Drop queued pipeline events first (sole-wake guarantee). - match cmd_rx - .try_recv() - .expect("expected DropMonitorNotifications") - { - SessionCommand::DropMonitorNotifications { task_id } => { - assert_eq!(task_id, "mon-456"); - } - _ => panic!("expected DropMonitorNotifications before auto-wake Prompt"), - } - assert!(matches!( - cmd_rx.try_recv(), - Ok(SessionCommand::CopyFile { .. }) - )); let cmd = cmd_rx.try_recv().expect("expected Prompt auto-wake"); match cmd { SessionCommand::Prompt { @@ -1236,22 +1463,79 @@ mod tests { } _ => panic!("expected Prompt auto-wake for natural monitor exit"), } + match cmd_rx + .try_recv() + .expect("accepted monitor wake must drop pipeline notifications") + { + SessionCommand::DropMonitorNotifications { task_id } => { + assert_eq!(task_id, "mon-456"); + } + _ => panic!("expected DropMonitorNotifications after accepted Prompt"), + } assert!(matches!( cmd_rx.try_recv(), Ok(SessionCommand::DispatchNotificationHook { .. }) )); assert_eq!( - config.auto_wake_delivered.snapshot(), + config.task_completion_reservations.snapshot(), vec!["mon-456".to_string()], ); } - /// After TaskCompleted auto-wake marked the task delivered, late pipeline + #[tokio::test] + async fn declined_quiet_monitor_wake_queues_canonical_deferred_completion() { + let (config, _gateway_rx, mut persistence_rx, mut cmd_rx) = make_test_config_full(); + config + .task_output_tool_name + .set(Some("get_command_or_subagent_output".to_string())) + .expect("slot is fresh in this test fixture"); + let mut offsets = HashMap::new(); + + handle_notification_with_admission( + &config, + ToolNotification::TaskCompleted(make_task_snapshot("mon-declined", TaskKind::Monitor)), + &mut offsets, + &mut cmd_rx, + false, + ) + .await; + + assert!(matches!( + cmd_rx.try_recv(), + Ok(SessionCommand::Prompt { .. }) + )); + assert!(matches!( + cmd_rx.try_recv(), + Ok(SessionCommand::DispatchNotificationHook { .. }) + )); + assert!(cmd_rx.try_recv().is_err()); + let mut persisted_completion = false; + while let Ok(message) = persistence_rx.try_recv() { + if let PersistenceMsg::Update(crate::session::storage::SessionUpdate::Xai(update)) = + message + && matches!( + &update.update, + crate::extensions::notification::SessionUpdate::TaskCompleted { .. } + ) + { + persisted_completion = true; + } + } + assert!(persisted_completion); + assert!( + config.task_completion_reservations.contains("mon-declined"), + "the actor owns reservation release after queuing the deferred fallback" + ); + } + + /// After TaskCompleted auto-wake reserves the task, late pipeline /// MonitorEvents must not inject another model-facing notification. #[tokio::test] async fn monitor_event_skipped_after_task_completed_auto_wake() { let (config, mut cmd_rx) = make_test_config(); - config.auto_wake_delivered.insert("mon-done".into()); + config + .task_completion_reservations + .reserve("mon-done".into()); let mut offsets = HashMap::new(); handle_notification( @@ -1303,7 +1587,7 @@ mod tests { cmd_rx.try_recv().is_err(), "explicitly-killed monitor must not auto-wake" ); - assert!(config.auto_wake_delivered.snapshot().is_empty()); + assert!(config.task_completion_reservations.snapshot().is_empty()); } /// Goal-loop suppression applies to monitor completions too. @@ -1336,7 +1620,7 @@ mod tests { cmd_rx.try_recv().is_err(), "goal-loop-active monitor completion must not auto-wake" ); - assert!(config.auto_wake_delivered.snapshot().is_empty()); + assert!(config.task_completion_reservations.snapshot().is_empty()); } #[tokio::test] @@ -1808,16 +2092,14 @@ mod tests { #[tokio::test] async fn bash_completion_uses_single_task_id_clone() { - // Verify the task_id appears in the prompt_id (auto-wake path). let (config, mut cmd_rx) = make_test_config(); let snapshot = make_task_snapshot("unique-id-789", TaskKind::Bash); let notification = ToolNotification::TaskCompleted(snapshot); let mut offsets = HashMap::new(); - handle_notification(&config, notification, &mut offsets).await; + handle_notification_with_admission(&config, notification, &mut offsets, &mut cmd_rx, true) + .await; - // Skip CopyFile - let _ = cmd_rx.try_recv().unwrap(); let cmd = cmd_rx.try_recv().unwrap(); if let SessionCommand::Prompt { prompt_id, .. } = cmd { assert_eq!(prompt_id, "task-completed-unique-id-789"); @@ -2044,7 +2326,6 @@ mod tests { /// Extract the auto-wake prompt text emitted on the session command channel. fn auto_wake_prompt_text(cmd_rx: &mut mpsc::UnboundedReceiver) -> String { - let _ = cmd_rx.try_recv().expect("expected CopyFile"); let cmd = cmd_rx.try_recv().expect("expected Prompt"); match cmd { SessionCommand::Prompt { prompt_blocks, .. } => match &prompt_blocks[0] { @@ -2087,10 +2368,12 @@ mod tests { .expect("fresh slot"); let snapshot = make_large_bash_snapshot("bg-disk-1", output_file.clone()); let mut offsets = HashMap::new(); - handle_notification( + handle_notification_with_admission( &config_auto, ToolNotification::TaskCompleted(snapshot), &mut offsets, + &mut cmd_rx_auto, + true, ) .await; let prompt = auto_wake_prompt_text(&mut cmd_rx_auto); diff --git a/crates/codegen/xai-grok-shell/src/tools/tool_context.rs b/crates/codegen/xai-grok-shell/src/tools/tool_context.rs index fe48cf2..a59fdf7 100644 --- a/crates/codegen/xai-grok-shell/src/tools/tool_context.rs +++ b/crates/codegen/xai-grok-shell/src/tools/tool_context.rs @@ -77,10 +77,10 @@ pub struct ToolContext { /// synthetic user message before the next sampling step. pub monitor_event_buffer: Option, - /// Shared set of IDs delivered via auto-wake synthetic prompts. - /// Used by `TaskCompletionReminder` to suppress duplicate reminders. - pub auto_wake_delivered: - Option, + pub task_completion_reservations: + Option, + pub task_wake_suppressed: + Option, /// Channel for requesting trace uploads for synthetic auto-wake turns. pub(crate) synthetic_trace_tx: Option>, @@ -143,7 +143,8 @@ impl ToolContext { lsp_server_names: Vec::new(), is_turn_active: None, monitor_event_buffer: None, - auto_wake_delivered: None, + task_completion_reservations: None, + task_wake_suppressed: None, synthetic_trace_tx: None, synthetic_trace_tx_shared: None, task_output_tool_name: @@ -179,7 +180,8 @@ impl ToolContext { lsp_server_names: Vec::new(), is_turn_active: None, monitor_event_buffer: None, - auto_wake_delivered: None, + task_completion_reservations: None, + task_wake_suppressed: None, synthetic_trace_tx: None, synthetic_trace_tx_shared: None, task_output_tool_name: @@ -235,7 +237,8 @@ mod tests { lsp_server_names: Vec::new(), is_turn_active: None, monitor_event_buffer: None, - auto_wake_delivered: None, + task_completion_reservations: None, + task_wake_suppressed: None, synthetic_trace_tx: None, synthetic_trace_tx_shared: None, task_output_tool_name: diff --git a/crates/codegen/xai-grok-shell/src/util/config/resolve/compaction.rs b/crates/codegen/xai-grok-shell/src/util/config/resolve/compaction.rs index 0c7a4a6..4a128cb 100644 --- a/crates/codegen/xai-grok-shell/src/util/config/resolve/compaction.rs +++ b/crates/codegen/xai-grok-shell/src/util/config/resolve/compaction.rs @@ -1,6 +1,38 @@ /// Default auto-compact threshold (% of context window) when no source sets it. pub const DEFAULT_AUTO_COMPACT_THRESHOLD_PERCENT: u8 = 85; +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum CompactionToolChoice { + #[default] + Auto, + None, +} + +impl std::str::FromStr for CompactionToolChoice { + type Err = (); + + fn from_str(s: &str) -> Result { + match s.trim().to_ascii_lowercase().as_str() { + "auto" => Ok(Self::Auto), + "none" => Ok(Self::None), + _ => Err(()), + } + } +} + +pub(crate) const ENV_COMPACTION_TOOL_CHOICE: &str = "GROK_COMPACTION_TOOL_CHOICE"; + +pub fn resolve_compaction_tool_choice_from( + env: Option<&str>, + config: Option<&str>, + remote: Option<&str>, +) -> CompactionToolChoice { + env.and_then(|s| s.parse().ok()) + .or_else(|| config.and_then(|s| s.parse().ok())) + .or_else(|| remote.and_then(|s| s.parse().ok())) + .unwrap_or_default() +} + /// Env-var override for `auto_compact_threshold_percent`. Parsed as `u8`; /// out-of-range or unparseable values are ignored. pub(crate) const ENV_AUTO_COMPACT_THRESHOLD_PERCENT: &str = "GROK_AUTO_COMPACT_THRESHOLD_PERCENT"; @@ -140,3 +172,48 @@ mod compaction_wall_clock_budget_tests { assert_eq!(resolve(Some(5)), 5); // low values pass through (warned, not clamped) } } + +#[cfg(test)] +mod compaction_tool_choice_tests { + use super::{CompactionToolChoice, resolve_compaction_tool_choice_from as resolve}; + + #[test] + fn default_is_auto() { + assert_eq!(resolve(None, None, None), CompactionToolChoice::Auto); + } + + #[test] + fn precedence_env_over_config_over_remote() { + assert_eq!( + resolve(Some("none"), Some("auto"), Some("auto")), + CompactionToolChoice::None + ); + assert_eq!( + resolve(None, Some("none"), Some("auto")), + CompactionToolChoice::None + ); + assert_eq!( + resolve(None, None, Some("none")), + CompactionToolChoice::None + ); + } + + #[test] + fn garbage_falls_through() { + assert_eq!( + resolve(Some("garbage"), None, Some("none")), + CompactionToolChoice::None + ); + assert_eq!( + resolve(Some("garbage"), Some("also-bad"), None), + CompactionToolChoice::Auto + ); + } + + #[test] + fn from_str_case_insensitive() { + assert_eq!("AUTO".parse(), Ok(CompactionToolChoice::Auto)); + assert_eq!(" None ".parse(), Ok(CompactionToolChoice::None)); + assert!("required".parse::().is_err()); + } +} diff --git a/crates/codegen/xai-grok-shell/src/util/config/resolve/toolset.rs b/crates/codegen/xai-grok-shell/src/util/config/resolve/toolset.rs index 225435d..c5f7b6d 100644 --- a/crates/codegen/xai-grok-shell/src/util/config/resolve/toolset.rs +++ b/crates/codegen/xai-grok-shell/src/util/config/resolve/toolset.rs @@ -62,6 +62,137 @@ fn resolve_search_tool_enabled( env.or(config).or(managed).unwrap_or(true) } +const ENV_LOGIN_SHELL_CAPTURE: &str = "GROK_LOGIN_ENV"; + +fn login_shell_capture_from_toml(v: Option<&TomlValue>) -> Option { + v?.get("toolset")? + .get("bash")? + .get("login_shell_capture")? + .as_bool() +} + +pub fn resolve_login_shell_capture(remote: Option) -> bool { + let requirements = crate::config::load_merged_requirements(); + let layers = match crate::config::ConfigLayers::load() { + Ok(l) => Some(l), + Err(e) => { + tracing::warn!(error = %e, "login_shell_capture: failed to load config layers"); + None + } + }; + resolve_login_shell_capture_tiers( + requirements.as_ref(), + layers.as_ref().map(|l| &l.user), + layers.as_ref().map(|l| &l.managed), + layers.as_ref().map(|l| &l.system_managed), + remote, + ) +} + +fn resolve_login_shell_capture_tiers( + requirements: Option<&TomlValue>, + user: Option<&TomlValue>, + managed: Option<&TomlValue>, + system_managed: Option<&TomlValue>, + remote: Option, +) -> bool { + use crate::agent::config::BoolFlag; + BoolFlag::env(ENV_LOGIN_SHELL_CAPTURE) + .requirement(login_shell_capture_from_toml(requirements)) + .config(login_shell_capture_from_toml(user)) + .managed( + login_shell_capture_from_toml(managed) + .or_else(|| login_shell_capture_from_toml(system_managed)), + ) + .feature_flag(remote) + .default(true) + .resolve() + .value +} + +#[cfg(test)] +mod login_shell_capture_tests { + use super::{ENV_LOGIN_SHELL_CAPTURE, resolve_login_shell_capture_tiers}; + use toml::Value as TomlValue; + + static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + fn guard() -> std::sync::MutexGuard<'static, ()> { + let g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + unsafe { std::env::remove_var(ENV_LOGIN_SHELL_CAPTURE) }; + g + } + + fn cfg(enabled: bool) -> TomlValue { + toml::from_str(&format!( + "[toolset.bash]\nlogin_shell_capture = {enabled}\n" + )) + .unwrap() + } + + #[test] + fn defaults_on() { + let _g = guard(); + assert!(resolve_login_shell_capture_tiers( + None, None, None, None, None + )); + } + + #[test] + fn remote_flag_can_disable() { + let _g = guard(); + assert!(!resolve_login_shell_capture_tiers( + None, + None, + None, + None, + Some(false) + )); + } + + #[test] + fn user_config_beats_remote() { + let _g = guard(); + assert!(resolve_login_shell_capture_tiers( + None, + Some(&cfg(true)), + None, + None, + Some(false) + )); + assert!(!resolve_login_shell_capture_tiers( + None, + Some(&cfg(false)), + None, + None, + Some(true) + )); + } + + #[test] + fn env_beats_config_and_remote() { + let _g = guard(); + unsafe { std::env::set_var(ENV_LOGIN_SHELL_CAPTURE, "0") }; + let off = resolve_login_shell_capture_tiers(None, Some(&cfg(true)), None, None, Some(true)); + unsafe { std::env::remove_var(ENV_LOGIN_SHELL_CAPTURE) }; + assert!(!off); + } + + #[test] + fn requirements_win_outright() { + let _g = guard(); + unsafe { std::env::set_var(ENV_LOGIN_SHELL_CAPTURE, "1") }; + let off = resolve_login_shell_capture_tiers( + Some(&cfg(false)), + Some(&cfg(true)), + None, + None, + Some(true), + ); + unsafe { std::env::remove_var(ENV_LOGIN_SHELL_CAPTURE) }; + assert!(!off); + } +} + /// Env override for `[toolset.ask_user_question] timeout_enabled` (parsed by /// the shared [`xai_grok_config::env_bool`] via `BoolFlag`). The secs env var /// lives in the tools crate (`RESPONSE_TIMEOUT_ENV`), parsed once there. diff --git a/crates/codegen/xai-grok-shell/src/util/config/settings_writes.rs b/crates/codegen/xai-grok-shell/src/util/config/settings_writes.rs index c791945..f019010 100644 --- a/crates/codegen/xai-grok-shell/src/util/config/settings_writes.rs +++ b/crates/codegen/xai-grok-shell/src/util/config/settings_writes.rs @@ -23,6 +23,10 @@ pub async fn set_show_timeline(value: bool) -> Result<()> { update_config(|cfg| cfg.ui.show_timeline = Some(value)).await } +pub async fn set_page_flip_on_send(value: bool) -> Result<()> { + update_config(|cfg| cfg.ui.page_flip_on_send = Some(value)).await +} + /// Persist `[ui].simple_mode` via `update_config`. Same `Option` /// shape as `show_timestamps`. pub async fn set_simple_mode(value: bool) -> Result<()> { diff --git a/crates/codegen/xai-grok-shell/src/util/mod.rs b/crates/codegen/xai-grok-shell/src/util/mod.rs index bb13ab2..44b67a8 100644 --- a/crates/codegen/xai-grok-shell/src/util/mod.rs +++ b/crates/codegen/xai-grok-shell/src/util/mod.rs @@ -8,6 +8,33 @@ pub mod hooks; // unchanged. pub use xai_grok_shell_base::util::*; +pub(crate) fn is_user_instruction_path( + path: &std::path::Path, + grok_home: &std::path::Path, + vendor_homes: &[(std::path::PathBuf, bool)], + workspace_root: Option<&std::path::Path>, +) -> bool { + let parent = path.parent(); + let grok_rules = grok_home.join("rules"); + let is_exact_home_surface = parent + .is_some_and(|parent| parent == grok_home || parent == grok_rules) + || vendor_homes.iter().any(|(vendor_home, named_enabled)| { + parent.is_some_and(|parent| { + (*named_enabled && parent == vendor_home) || parent == vendor_home.join("rules") + }) + }); + if is_exact_home_surface { + return true; + } + if workspace_root.is_some_and(|root| path.starts_with(root)) { + return false; + } + path.starts_with(grok_home) + || vendor_homes + .iter() + .any(|(vendor_home, _)| path.starts_with(vendor_home)) +} + /// Aborts the wrapped tokio task when dropped. /// /// Use to tie a spawned helper task's lifetime to an async scope so that @@ -22,3 +49,35 @@ impl Drop for AbortOnDrop { self.0.abort(); } } + +#[cfg(test)] +mod is_user_instruction_path_tests { + use super::is_user_instruction_path; + use std::path::Path; + + #[test] + fn grok_home_named_file_nested_in_workspace_is_user_scoped() { + assert!(is_user_instruction_path( + Path::new("/repo/config/AGENTS.md"), + Path::new("/repo/config"), + &[], + Some(Path::new("/repo")), + )); + assert!(!is_user_instruction_path( + Path::new("/repo/config/src/AGENTS.md"), + Path::new("/repo/config"), + &[], + Some(Path::new("/repo")), + )); + } + + #[test] + fn workspace_descendants_under_grok_home_stay_project_scoped() { + assert!(!is_user_instruction_path( + Path::new("/custom/grok/worktrees/repo/src/AGENTS.md"), + Path::new("/custom/grok"), + &[], + Some(Path::new("/custom/grok/worktrees/repo")), + )); + } +} diff --git a/crates/codegen/xai-grok-shell/tests/test_built_binary_e2e.rs b/crates/codegen/xai-grok-shell/tests/test_built_binary_e2e.rs index 26ac17f..deaa17b 100644 --- a/crates/codegen/xai-grok-shell/tests/test_built_binary_e2e.rs +++ b/crates/codegen/xai-grok-shell/tests/test_built_binary_e2e.rs @@ -41,6 +41,8 @@ where tokio::task::LocalSet::new().run_until(f()).await; } +const CHAT_COMPLETIONS_MODEL: &str = "chat-completions-model"; + /// Start a mock server with one model named `model` on the given API backend. async fn single_model_server(model: &str, backend: &str) -> MockInferenceServer { MockInferenceServer::start_with_models(vec![ @@ -468,19 +470,19 @@ async fn test_headless_streaming_json_output() { async fn test_headless_json_reports_server_cost() { use xai_grok_test_support::scripted::SseEvent; - let server = single_model_server("grok-4.5", "chat_completions").await; + let server = single_model_server(CHAT_COMPLETIONS_MODEL, "chat_completions").await; let chunk = |body: serde_json::Value| SseEvent::data(body.to_string()); server.enqueue_response( "/v1/chat/completions", xai_grok_test_support::scripted::ScriptedResponse::sse(vec![ chunk(serde_json::json!({ "id": "chatcmpl-cost", "object": "chat.completion.chunk", "created": 0, - "model": "grok-4.5", + "model": CHAT_COMPLETIONS_MODEL, "choices": [{ "index": 0, "delta": { "content": "4" }, "finish_reason": "stop" }] })), chunk(serde_json::json!({ "id": "chatcmpl-cost", "object": "chat.completion.chunk", "created": 0, - "model": "grok-4.5", "choices": [], + "model": CHAT_COMPLETIONS_MODEL, "choices": [], "usage": { "prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15, "cost_in_usd_ticks": 1_234_500_000_i64 @@ -498,7 +500,7 @@ async fn test_headless_json_reports_server_cost() { "what is 2+2", "--yolo", "--model", - "grok-4.5", + CHAT_COMPLETIONS_MODEL, "--max-turns", "1", "--output-format", @@ -528,7 +530,7 @@ async fn test_headless_json_reports_server_cost() { #[tokio::test] #[ignore] // requires pre-built binary; run with --ignored async fn test_headless_json_reports_usage_on_max_turns() { - let server = single_model_server("grok-4.5", "chat_completions").await; + let server = single_model_server(CHAT_COMPLETIONS_MODEL, "chat_completions").await; server.enqueue_response( "/v1/chat/completions", xai_grok_test_support::scripted::ScriptedResponse::sse( @@ -537,7 +539,7 @@ async fn test_headless_json_reports_usage_on_max_turns() { "call-1", "read_file", r#"{"path":"README.md"}"#, - "grok-4.5", + CHAT_COMPLETIONS_MODEL, ), ), ); @@ -550,7 +552,7 @@ async fn test_headless_json_reports_usage_on_max_turns() { "read the readme", "--yolo", "--model", - "grok-4.5", + CHAT_COMPLETIONS_MODEL, "--max-turns", "1", "--output-format", @@ -569,7 +571,7 @@ async fn test_headless_json_reports_usage_on_max_turns() { #[tokio::test] #[ignore] // requires pre-built binary; run with --ignored async fn test_headless_streaming_json_usage() { - let server = single_model_server("grok-4.5", "chat_completions").await; + let server = single_model_server(CHAT_COMPLETIONS_MODEL, "chat_completions").await; let workdir = git_workdir(); let result = run_headless( &server, @@ -578,7 +580,7 @@ async fn test_headless_streaming_json_usage() { "say hello", "--yolo", "--model", - "grok-4.5", + CHAT_COMPLETIONS_MODEL, "--output-format", "streaming-json", ], @@ -604,7 +606,7 @@ async fn test_headless_streaming_json_usage() { #[tokio::test] #[ignore] // requires pre-built binary; run with --ignored async fn headless_json_schema_chat_completions_uses_response_format() { - let server = single_model_server("grok-4.5", "chat_completions").await; + let server = single_model_server(CHAT_COMPLETIONS_MODEL, "chat_completions").await; server.set_response(r#"{"name":"Alice","age":30}"#); let workdir = git_workdir(); @@ -615,7 +617,7 @@ async fn headless_json_schema_chat_completions_uses_response_format() { "extract name and age", "--yolo", "--model", - "grok-4.5", + CHAT_COMPLETIONS_MODEL, "--json-schema", r#"{"type":"object","properties":{"name":{"type":"string"},"age":{"type":"integer"}},"required":["name","age"],"additionalProperties":false}"#, "--max-turns", @@ -887,7 +889,7 @@ async fn headless_json_schema_messages_retries_on_schema_violation() { #[tokio::test] #[ignore] // requires pre-built binary; run with --ignored async fn invalid_json_schema_disables_structured_output_and_surfaces_error() { - let server = single_model_server("grok-4.5", "chat_completions").await; + let server = single_model_server(CHAT_COMPLETIONS_MODEL, "chat_completions").await; server.set_response(r#"{"name":"Alice","age":30}"#); let workdir = git_workdir(); @@ -898,7 +900,7 @@ async fn invalid_json_schema_disables_structured_output_and_surfaces_error() { "extract name and age", "--yolo", "--model", - "grok-4.5", + CHAT_COMPLETIONS_MODEL, // Valid JSON object, but `pattern` is an invalid regex → schema // compilation (`jsonschema::validator_for`) fails. "--json-schema", @@ -1310,7 +1312,7 @@ impl ConfigTestHarness { // ── Enterprise managed config tests ──────────────────────────────────────── -/// Enterprise BYOK: managed_config.toml overrides grok-build with a custom +/// Enterprise BYOK: managed_config.toml overrides the default model with a custom /// endpoint + env_key. Mock rejects unauthenticated requests with 401. /// Regression guard for the 0.1.220 authentication regression. #[tokio::test] @@ -1332,7 +1334,7 @@ async fn test_headless_managed_config_byok_sends_authorized_requests() { deployment_key = "test-deployment-key" xai_api_base_url = "{url}" -[model.grok-build] +[model."grok-4.5"] api_backend = "responses" base_url = "{url}" context_window = 500000 @@ -1369,7 +1371,7 @@ default = "grok-4.5" #[ignore] // requires pre-built binary; run with --ignored async fn headless_reasoning_efforts_payload_parses_and_legacy_effort_rides_wire() { let server = MockInferenceServer::start_with_models(vec![ - MockModelEntry::new("grok-4.5") + MockModelEntry::new(CHAT_COMPLETIONS_MODEL) .with_api_backend("chat_completions") .with_supports_reasoning_effort(true) .with_reasoning_effort("xhigh") @@ -1390,7 +1392,7 @@ async fn headless_reasoning_efforts_payload_parses_and_legacy_effort_rides_wire( "hi", "--yolo", "--model", - "grok-4.5", + CHAT_COMPLETIONS_MODEL, "--max-turns", "1", ], diff --git a/crates/codegen/xai-grok-shell/tests/test_mcp_permission_persistence.rs b/crates/codegen/xai-grok-shell/tests/test_mcp_permission_persistence.rs index 4fa14e8..8963c0b 100644 --- a/crates/codegen/xai-grok-shell/tests/test_mcp_permission_persistence.rs +++ b/crates/codegen/xai-grok-shell/tests/test_mcp_permission_persistence.rs @@ -157,6 +157,7 @@ async fn request(handle: &PermissionHandle, access: AccessKind, id: &str) -> Dec let cmd = PermissionCommand::Request { access, tool_call_update: tool_call_update(id, "mcp"), + edit_path_context: None, respond_to: tx, session_id: None, subagent_type: None, diff --git a/crates/codegen/xai-grok-shell/tests/test_registry_churn.rs b/crates/codegen/xai-grok-shell/tests/test_registry_churn.rs new file mode 100644 index 0000000..22d64c9 --- /dev/null +++ b/crates/codegen/xai-grok-shell/tests/test_registry_churn.rs @@ -0,0 +1,295 @@ +//! Registry-churn regression gate: a real in-process `MvpAgent` on duplex +//! ACP pipes churns sessions through create, prompt, and close, then +//! asserts via `x.ai/debug/agent` that every registry count returns +//! to its pre-churn baseline. Deterministic counts, no memory thresholds. +//! Counts the echo workload never populates are pinned at their zero +//! baseline only. +use agent_client_protocol::{self as acp, Agent as _}; +use serde_json::json; +use std::sync::Arc; +use std::time::Duration; +use tempfile::TempDir; +use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; +use xai_acp_lib::{ + AcpAgentGatewayReceiver as GatewayReceiver, AcpAgentGatewaySender as GatewaySender, + LineBufferedRead, +}; +use xai_grok_shell::agent::config::Config as AgentConfig; +use xai_grok_shell::agent::mvp_agent::MvpAgent; +use xai_grok_test_support::MockInferenceServer; +/// Matches production's `MAX_BUFFER_SIZE` in `agent::app`. +const DUPLEX_BUFFER_BYTES: usize = 8 * 1024 * 1024; +/// Enough that a per-cycle leak is unambiguous; well under a minute +/// against the loopback mock. +const CHURN_SESSIONS: usize = 15; +const CONCURRENT_SESSIONS: usize = 4; +const RPC_TIMEOUT: Duration = Duration::from_secs(60); +/// Field names are the wire contract (`RegistrySnapshot` in +/// `agent/mvp_agent/session_lifecycle.rs`); `deny_unknown_fields` forces a +/// new server-side count to be mirrored and asserted here. +#[derive(Debug, PartialEq, Eq, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct Counts { + sessions: usize, + session_threads: usize, + dispatch_locks: usize, + session_turn_numbers: usize, + permission_event_receivers: usize, + model_unavailable_sessions: usize, + session_live_state: usize, + session_index_claims: usize, + require_gateway_sessions: usize, + subagent_pending: usize, + subagent_active: usize, + subagent_completed: usize, + workspace_bindings: Option, +} +struct AutoApproveClient; +#[async_trait::async_trait(?Send)] +impl acp::Client for AutoApproveClient { + async fn request_permission( + &self, + args: acp::RequestPermissionRequest, + ) -> acp::Result { + let outcome = args + .options + .iter() + .find(|o| o.kind == acp::PermissionOptionKind::AllowOnce) + .or(args.options.first()) + .map(|o| { + acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome::new( + o.option_id.clone(), + )) + }) + .unwrap_or(acp::RequestPermissionOutcome::Cancelled); + Ok(acp::RequestPermissionResponse::new(outcome)) + } + async fn session_notification(&self, _args: acp::SessionNotification) -> acp::Result<()> { + Ok(()) + } +} +async fn ext_method( + conn: &acp::ClientSideConnection, + method: &str, + params: serde_json::Value, +) -> serde_json::Value { + let raw = + serde_json::value::RawValue::from_string(params.to_string()).expect("serialize ext params"); + let resp = tokio::time::timeout( + RPC_TIMEOUT, + conn.ext_method(acp::ExtRequest::new(method, Arc::from(raw))), + ) + .await + .unwrap_or_else(|_| panic!("{method} timed out")) + .unwrap_or_else(|e| panic!("{method} failed: {e}")); + serde_json::from_str(resp.0.get()).unwrap_or_else(|e| panic!("{method}: bad response: {e}")) +} +async fn read_counts(conn: &acp::ClientSideConnection) -> Counts { + let resp = ext_method(conn, "x.ai/debug/agent", json!({})).await; + serde_json::from_value(resp["result"]["registries"].clone()) + .unwrap_or_else(|e| panic!("x.ai/debug/agent: bad registries payload: {e}\n{resp}")) +} +async fn new_session(conn: &acp::ClientSideConnection, cwd: &std::path::Path) -> acp::SessionId { + tokio::time::timeout( + RPC_TIMEOUT, + conn.new_session( + acp::NewSessionRequest::new(cwd.to_path_buf()) + .meta(json!({ "modelId" : "test-model" }).as_object().cloned()), + ), + ) + .await + .expect("session/new timed out") + .expect("session/new failed") + .session_id +} +async fn prompt_turn(conn: &acp::ClientSideConnection, session_id: &acp::SessionId, text: &str) { + let resp = tokio::time::timeout( + RPC_TIMEOUT, + conn.prompt(acp::PromptRequest::new( + session_id.clone(), + vec![acp::ContentBlock::Text(acp::TextContent::new( + text.to_owned(), + ))], + )), + ) + .await + .unwrap_or_else(|_| panic!("prompt on {} timed out", session_id.0)) + .unwrap_or_else(|e| panic!("prompt on {} failed: {e}", session_id.0)); + assert!( + matches!(resp.stop_reason, acp::StopReason::EndTurn), + "expected EndTurn on {}, got {:?}", + session_id.0, + resp.stop_reason + ); +} +async fn close_session(conn: &acp::ClientSideConnection, session_id: &acp::SessionId) { + let resp = ext_method( + conn, + "x.ai/session/close", + json!({ "sessionId" : session_id.0.as_ref() }), + ) + .await; + assert_eq!( + resp["result"]["success"], + json!(true), + "x.ai/session/close on {} failed: {resp}", + session_id.0 + ); +} +async fn churn_one(conn: &acp::ClientSideConnection, cwd: &std::path::Path, label: usize) { + let sid = new_session(conn, cwd).await; + prompt_turn(conn, &sid, &format!("churn ping {label}")).await; + close_session(conn, &sid).await; +} +/// Builds the in-process agent from the environment and returns an +/// initialized, authenticated client connection over duplex pipes. IO +/// tasks spawn on the current `LocalSet`. +async fn connect_and_auth() -> acp::ClientSideConnection { + let agent_config = AgentConfig::default(); + let auth_manager = Arc::new(agent_config.create_auth_manager()); + let (gw_tx, gw_rx) = tokio::sync::mpsc::unbounded_channel(); + let gateway = GatewaySender::new(gw_tx); + let agent = MvpAgent::new(gateway, &agent_config, auth_manager, None).expect("valid config"); + let (c2a_a, c2a_b) = tokio::io::duplex(DUPLEX_BUFFER_BYTES); + let (a2c_a, a2c_b) = tokio::io::duplex(DUPLEX_BUFFER_BYTES); + let agent_incoming = LineBufferedRead::spawn_local(c2a_b.compat()); + let (agent_conn, agent_io) = + acp::AgentSideConnection::new(agent, a2c_a.compat_write(), agent_incoming, |fut| { + tokio::task::spawn_local(fut); + }); + tokio::task::spawn_local( + GatewayReceiver::new(gw_rx, agent_conn) + .with_on_meta(xai_file_utils::trace_context::span_from_meta_traceparent) + .run(), + ); + tokio::task::spawn_local(agent_io); + let client_incoming = LineBufferedRead::spawn_local(a2c_b.compat()); + let (client_conn, client_io) = acp::ClientSideConnection::new( + AutoApproveClient, + c2a_a.compat_write(), + client_incoming, + |fut| { + tokio::task::spawn_local(fut); + }, + ); + tokio::task::spawn_local(client_io); + let init = tokio::time::timeout( + RPC_TIMEOUT, + client_conn.initialize( + acp::InitializeRequest::new(acp::ProtocolVersion::V1) + .client_capabilities( + acp::ClientCapabilities::new() + .fs(acp::FileSystemCapabilities::new()) + .terminal(false), + ) + .meta( + json!( + { "startupHints" : { "nonInteractive" : true, + "skipGitStatus" : true, "skipProjectLayout" : true, }, + "clientType" : "registry-churn-test", "clientVersion" : + "0.0-test", } + ) + .as_object() + .cloned(), + ), + ), + ) + .await + .expect("initialize timed out") + .expect("initialize failed"); + let method = init + .auth_methods + .iter() + .find(|m| &*m.id().0 == "xai.api_key") + .expect("xai.api_key auth method not advertised"); + tokio::time::timeout( + RPC_TIMEOUT, + client_conn.authenticate( + acp::AuthenticateRequest::new(method.id().clone()) + .meta(json!({ "headless" : true }).as_object().cloned()), + ), + ) + .await + .expect("authenticate timed out") + .expect("authenticate failed"); + client_conn +} +/// Single `#[test]` in this binary: the env mutation below relies on +/// nothing else running concurrently (same safety argument as +/// `git_contention_e2e`). +#[test] +fn session_churn_returns_registry_snapshot_to_baseline() { + let _ = rustls::crypto::ring::default_provider().install_default(); + let mock_rt = tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .enable_all() + .build() + .expect("mock runtime"); + let server = mock_rt + .block_on(MockInferenceServer::start()) + .expect("mock server"); + let grok_home = TempDir::new().expect("grok home"); + let workdir = TempDir::new().expect("workdir"); + unsafe { + std::env::set_var("GROK_HOME", grok_home.path()); + std::env::set_var("GROK_CLI_CHAT_PROXY_BASE_URL", server.url()); + std::env::set_var("GROK_XAI_API_BASE_URL", server.url()); + std::env::set_var("XAI_API_KEY", "test-key-for-ci"); + std::env::set_var("GROK_TELEMETRY_ENABLED", "false"); + std::env::set_var("GROK_FEEDBACK_ENABLED", "false"); + std::env::set_var("GROK_TRACE_UPLOAD", "false"); + } + let agent_rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("agent runtime"); + let local = tokio::task::LocalSet::new(); + agent_rt.block_on(local.run_until(async move { + let client_conn = connect_and_auth().await; + churn_one(&client_conn, workdir.path(), 0).await; + let baseline = read_counts(&client_conn).await; + assert_eq!( + baseline.sessions, 0, + "warmup session must be fully removed before baseline" + ); + assert_eq!( + baseline.workspace_bindings, + Some(0), + "warmup must have built the local workspace and released its binding" + ); + assert_eq!( + ( + baseline.subagent_pending, + baseline.subagent_active, + baseline.subagent_completed + ), + (0, 0, 0), + "baseline must have no subagent entries" + ); + for i in 1..=CHURN_SESSIONS { + churn_one(&client_conn, workdir.path(), i).await; + } + let conn = &client_conn; + let cwd = workdir.path(); + let concurrent: Vec = + futures::future::join_all((0..CONCURRENT_SESSIONS).map(|_| new_session(conn, cwd))) + .await; + let mid = read_counts(&client_conn).await; + assert_eq!( + mid.sessions, CONCURRENT_SESSIONS, + "the snapshot must observe the open concurrent sessions" + ); + futures::future::join_all(concurrent.iter().enumerate().map(|(i, sid)| async move { + prompt_turn(conn, sid, &format!("concurrent ping {i}")).await; + })) + .await; + futures::future::join_all(concurrent.iter().map(|sid| close_session(conn, sid))).await; + let after = read_counts(&client_conn).await; + assert_eq!( + after, baseline, + "session churn must return every registry count to baseline \ + (a growing count means a spawn-time map is missing its \ + remove_session release)" + ); + })); +} diff --git a/crates/codegen/xai-grok-tools/src/bridge.rs b/crates/codegen/xai-grok-tools/src/bridge.rs index bf02a20..bdf2a5a 100644 --- a/crates/codegen/xai-grok-tools/src/bridge.rs +++ b/crates/codegen/xai-grok-tools/src/bridge.rs @@ -594,8 +594,12 @@ impl ToolBridge { /// Drain newly-completed bash background tasks not yet reported. /// Marks returned tasks in [`ReportedTaskCompletions`] to prevent - /// duplicate reminders from [`TaskCompletionReminder`]. - pub async fn drain_between_turn_bash_completions(&self) -> Vec { + /// duplicate reminders from [`TaskCompletionReminder`]. Reserved IDs stay + /// unreported for a later genuine user turn. + pub async fn drain_between_turn_bash_completions( + &self, + reserved_ids: &[String], + ) -> Vec { let tasks = match self.list_tasks().await { Some(t) => t, None => return Vec::new(), @@ -625,6 +629,7 @@ impl ToolBridge { completed .into_iter() .filter(|t| task_owned_by_session(t, my_owner.as_deref())) + .filter(|t| !reserved_ids.contains(&t.task_id)) .filter(|t| state.mark_reported(&t.task_id)) .collect() } @@ -817,7 +822,7 @@ mod tests { terminal: Some(backend), }; - let drained = bridge.drain_between_turn_bash_completions().await; + let drained = bridge.drain_between_turn_bash_completions(&[]).await; let ids: Vec<&str> = drained.iter().map(|t| t.task_id.as_str()).collect(); assert!(ids.contains(&"mine-task"), "own task must drain: {ids:?}"); @@ -830,4 +835,36 @@ mod tests { "another session's task must NOT leak into this session: {ids:?}" ); } + + #[tokio::test] + async fn between_turn_bash_completions_skip_reserved_ids_without_reporting_them() { + let toolset = FinalizedToolset::empty_for_test(); + { + let mut res = toolset.resources.lock().await; + res.register_state::(); + } + let backend: Arc = Arc::new(MockTerminal { + tasks: vec![completed_task("reserved", None)], + }); + let bridge = ToolBridge { + registry: Arc::new(toolset), + terminal: Some(backend), + }; + + assert!( + bridge + .drain_between_turn_bash_completions(&["reserved".to_string()]) + .await + .is_empty() + ); + assert_eq!( + bridge + .drain_between_turn_bash_completions(&[]) + .await + .into_iter() + .map(|task| task.task_id) + .collect::>(), + vec!["reserved".to_string()] + ); + } } diff --git a/crates/codegen/xai-grok-tools/src/computer/local/mod.rs b/crates/codegen/xai-grok-tools/src/computer/local/mod.rs index f46447e..b3c527e 100644 --- a/crates/codegen/xai-grok-tools/src/computer/local/mod.rs +++ b/crates/codegen/xai-grok-tools/src/computer/local/mod.rs @@ -5,6 +5,8 @@ pub mod file_system; pub mod mock_fs; #[cfg(unix)] pub mod shell_state; +#[cfg(unix)] +pub mod static_shell; pub mod terminal; pub use cgroup::{CgroupMemoryConfig, PROCESS_OOM_EXIT_CODE}; diff --git a/crates/codegen/xai-grok-tools/src/computer/local/shell_state.rs b/crates/codegen/xai-grok-tools/src/computer/local/shell_state.rs index 680c62d..ecad81f 100644 --- a/crates/codegen/xai-grok-tools/src/computer/local/shell_state.rs +++ b/crates/codegen/xai-grok-tools/src/computer/local/shell_state.rs @@ -104,8 +104,10 @@ dump_bash_state() { env_vars=$(builtin export -p 2>/dev/null | command grep -viE '_proxy=|GROK_SANDBOX|GROK_AGENT=|SUDO_ASKPASS|GROK_ASKPASS|ELECTRON_RUN_AS_NODE|SSH_AUTH_SOCK|DBUS_SESSION_BUS_ADDRESS|XDG_RUNTIME_DIR|WAYLAND_DISPLAY|GPG_TTY' || true) _emit_encoded "$env_vars" "ENV_VARS_B64" + # errexit/pipefail here are this function's own `set -euo pipefail` (set is + # shell-global in bash); replaying them would abort later user commands. local posix_opts - posix_opts=$(builtin shopt -po 2>/dev/null | command grep -v '^set -o nounset$' | command grep -v '^set +o nounset$' || true) + posix_opts=$(builtin shopt -po 2>/dev/null | command grep -vE '^set [-+]o (nounset|errexit|pipefail)$' || true) _emit_encoded "$posix_opts" "POSIX_OPTS_B64" local bash_opts @@ -158,8 +160,11 @@ function dump_zsh_state() { env_vars=$(builtin typeset -xp 2>/dev/null | command grep -viE '_proxy=|GROK_SANDBOX|GROK_AGENT=|SUDO_ASKPASS|GROK_ASKPASS|ELECTRON_RUN_AS_NODE|SSH_AUTH_SOCK|DBUS_SESSION_BUS_ADDRESS|XDG_RUNTIME_DIR|WAYLAND_DISPLAY|GPG_TTY' || true) _emit_encoded "$env_vars" "ENV_VARS_B64" + # errreturn/pipefail here are this function's own `emulate -L` options + # (setopt lists them while inside); replaying them would abort later user + # commands. local zsh_opts - zsh_opts=$(setopt 2>/dev/null | command grep -v '^nounset$' | command awk '{printf "builtin setopt %s 2>/dev/null || true\n", $0}' || true) + zsh_opts=$(setopt 2>/dev/null | command grep -vE '^(nounset|errexit|errreturn|pipefail)$' | command awk '{printf "builtin setopt %s 2>/dev/null || true\n", $0}' || true) _emit_encoded "$zsh_opts" "ZSH_OPTS_B64" local all_functions @@ -361,6 +366,7 @@ impl ShellState { user_command: &str, cwd_override: Option<&Path>, search_shadows: super::SearchShadowConfig, + spawn_notice: Option<&str>, ) -> std::io::Result { let dump_script = self.shell.dump_script(); let dump_fn = self.shell.dump_function_name(); @@ -408,6 +414,7 @@ impl ShellState { builtin export GROK_AGENT=1; \ builtin export PWD=\"$(builtin pwd)\"; \ builtin shopt -s expand_aliases 2>/dev/null; {sudo_inject}{search_inject}\ + builtin printf '%s' \"${{2:-}}\"; \ builtin eval \"$1\" 2>&1; }}; \ COMMAND_EXIT_CODE=$?; {dump_fn} >&4; builtin exit $COMMAND_EXIT_CODE" ), @@ -423,6 +430,7 @@ impl ShellState { builtin export GROK_AGENT=1; \ builtin export PWD=\"$(builtin pwd)\"; \ builtin setopt aliases 2>/dev/null; {sudo_inject}{search_inject}\ + builtin printf '%s' \"${{2:-}}\"; \ builtin eval \"$1\" 2>&1; }}; \ COMMAND_EXIT_CODE=$?; {dump_fn} >&4; builtin exit $COMMAND_EXIT_CODE" ), @@ -430,7 +438,7 @@ impl ShellState { let effective_cwd = cwd_override.unwrap_or(&self.cwd); - let args: Vec = match self.shell { + let mut args: Vec = match self.shell { ShellKind::Bash => vec![ "-O".into(), "extglob".into(), @@ -441,6 +449,9 @@ impl ShellState { ], ShellKind::Zsh => vec!["-c".into(), wrapper, "--".into(), user_command.into()], }; + if let Some(notice) = spawn_notice { + args.push(notice.into()); + } let fd_mappings = vec![ FdMapping { @@ -855,6 +866,7 @@ mod tests { "export GROK_TEST_VAR=hello", None, crate::computer::local::SearchShadowConfig::default(), + None, ) .unwrap(); @@ -914,6 +926,7 @@ mod tests { command, None, crate::computer::local::SearchShadowConfig::default(), + None, ) .unwrap(); let mut cmd = tokio::process::Command::new(&prep.binary); @@ -1040,6 +1053,7 @@ mod tests { "true", None, crate::computer::local::SearchShadowConfig::default(), + None, ) .unwrap(); let wrapper = prep @@ -1124,7 +1138,7 @@ mod tests { let cwd = std::env::current_dir().unwrap(); let mut state = ShellState::init(ShellKind::Bash, &cwd).await.unwrap(); - let prep = state.prepare_command("true", None, shadows).unwrap(); + let prep = state.prepare_command("true", None, shadows, None).unwrap(); // Shadows enabled → the self-resolving find/grep functions are always // installed (they fall back to the OS binary if bfs/ugrep aren't found). assert!( diff --git a/crates/codegen/xai-grok-tools/src/computer/local/static_shell.rs b/crates/codegen/xai-grok-tools/src/computer/local/static_shell.rs new file mode 100644 index 0000000..6b3af54 --- /dev/null +++ b/crates/codegen/xai-grok-tools/src/computer/local/static_shell.rs @@ -0,0 +1,311 @@ +//! Static (replay-only) login-shell capture for the non-persistent bash path. +//! +//! Sources the user's rc once at init and captures function and alias +//! definitions; every command replays that fixed snapshot in a fresh shell. +//! Nothing is ever written back: no state dump, no tracked cwd, no +//! persistence across calls. Env vars are deliberately not captured here — +//! the host-side login env capture applies them with fill-gaps precedence. +//! +//! Self-contained by design: independent of the cursor persistent shell's +//! `shell_state` machinery so changes to either path cannot affect the other. + +use std::os::unix::io::{AsRawFd, FromRawFd, OwnedFd}; +use std::path::Path; +use std::process::Stdio; +use std::time::Duration; + +use command_fds::FdMapping; +use nix::libc; +use tokio::io::AsyncReadExt; + +pub use xai_grok_config::shell::UnixShellKind; + +const INIT_MARKER: &str = "__GROK_STATIC_SHELL_MARKER__"; +const INIT_TIMEOUT: Duration = Duration::from_secs(15); + +/// A fixed snapshot of rc-defined functions and aliases, captured once. +#[derive(Debug, Clone)] +pub struct StaticShellSnapshot { + pub snapshot: String, + pub shell: UnixShellKind, +} + +fn shell_binary(shell: UnixShellKind) -> &'static str { + xai_grok_config::shell::unix_shell_path(shell) +} + +fn rc_file_name(shell: UnixShellKind) -> &'static str { + match shell { + UnixShellKind::Bash => ".bashrc", + UnixShellKind::Zsh => ".zshrc", + } +} + +fn sudo_alias_injection() -> String { + match std::env::var("SUDO_ASKPASS") { + Ok(val) if !val.is_empty() => "alias sudo='sudo -A'; ".to_string(), + _ => String::new(), + } +} + +impl StaticShellSnapshot { + /// Source the rc once in a login shell and capture alias and function + /// definitions between SOH markers. Returns an empty snapshot on any + /// failure or timeout, degrading to a plain shell. + pub async fn init(cwd: &Path) -> Self { + let shell = xai_grok_config::shell::detect_unix_shell_kind(); + + let capture = match shell { + UnixShellKind::Bash => "builtin alias -p 2>/dev/null; builtin declare -f 2>/dev/null", + UnixShellKind::Zsh => { + "{ builtin alias -L; builtin alias -gL; builtin alias -sL } 2>/dev/null; \ + builtin typeset -f 2>/dev/null" + } + }; + let script = format!( + "source \"$HOME/{rc}\" 2>/dev/null; \ + printf '\\x01'; {capture}; printf '\\x01'", + rc = rc_file_name(shell) + ); + + let result = tokio::time::timeout(INIT_TIMEOUT, async { + let mut cmd = tokio::process::Command::new(shell_binary(shell)); + cmd.args(["-lc", &script]) + .current_dir(cwd) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true); + crate::util::detach_command(&mut cmd); + cmd.envs(crate::util::pager_env()); + let mut child = cmd.spawn().ok()?; + + let mut stdout_buf = Vec::new(); + if let Some(ref mut stdout) = child.stdout { + stdout.read_to_end(&mut stdout_buf).await.ok(); + } + let status = child.wait().await.ok()?; + if !status.success() { + return None; + } + + let stdout = String::from_utf8_lossy(&stdout_buf); + let parts: Vec<&str> = stdout.split('\x01').collect(); + parts.get(1).map(|s| s.to_string()) + }) + .await; + + let snapshot = match result { + Ok(Some(s)) => s, + Ok(None) => { + tracing::warn!("static shell capture failed; using empty snapshot"); + String::new() + } + Err(_) => { + tracing::warn!( + "static shell capture timed out after {}s; using empty snapshot", + INIT_TIMEOUT.as_secs() + ); + String::new() + } + }; + let _ = INIT_MARKER; + Self { snapshot, shell } + } + + /// Build the replay wrapper: read the snapshot from fd 3, eval it (alias + /// and function definitions), then eval the user command; the shell exits + /// with the user command's status. A failing snapshot replay does not + /// abort the command. + pub fn prepare_command( + &self, + user_command: &str, + search_shadows: super::SearchShadowConfig, + ) -> std::io::Result { + let sudo_inject = sudo_alias_injection(); + let search_inject = super::embedded_search_tools::search_injection(search_shadows); + + let (state_in_read, state_in_write) = os_pipe()?; + set_cloexec(&state_in_write)?; + + let wrapper = match self.shell { + UnixShellKind::Bash => format!( + "snap=$(command cat <&3); builtin shopt -s extglob 2>/dev/null; \ + builtin shopt -s expand_aliases 2>/dev/null; \ + builtin eval -- \"$snap\"; \ + builtin export GROK_AGENT=1; \ + builtin export PWD=\"$(builtin pwd)\"; {sudo_inject}{search_inject}\ + builtin eval \"$1\" 2>&1" + ), + UnixShellKind::Zsh => format!( + "snap=$(command cat <&3); \ + builtin setopt nonomatch 2>/dev/null; \ + builtin eval \"$snap\"; \ + builtin export GROK_AGENT=1; \ + builtin export PWD=\"$(builtin pwd)\"; \ + builtin setopt aliases 2>/dev/null; {sudo_inject}{search_inject}\ + builtin eval \"$1\" 2>&1" + ), + }; + + let args: Vec = match self.shell { + UnixShellKind::Bash => vec![ + "-O".into(), + "extglob".into(), + "-c".into(), + wrapper, + "--".into(), + user_command.into(), + ], + UnixShellKind::Zsh => vec!["-c".into(), wrapper, "--".into(), user_command.into()], + }; + + Ok(PreparedStaticCommand { + binary: shell_binary(self.shell).to_string(), + args, + fd_mappings: vec![FdMapping { + parent_fd: state_in_read, + child_fd: 3, + }], + state_in_write, + }) + } +} + +pub struct PreparedStaticCommand { + pub binary: String, + pub args: Vec, + pub fd_mappings: Vec, + pub state_in_write: OwnedFd, +} + +/// Write the snapshot to the pipe, then close the fd so the child sees EOF. +pub async fn write_snapshot_to_pipe(snapshot: &str, fd: OwnedFd) -> std::io::Result<()> { + let data = snapshot.to_string(); + tokio::task::spawn_blocking(move || { + use std::io::Write; + // Safety: we own the fd. + let mut file = unsafe { std::fs::File::from_raw_fd(fd.as_raw_fd()) }; + std::mem::forget(fd); + file.write_all(data.as_bytes())?; + file.flush()?; + drop(file); + Ok(()) + }) + .await + .map_err(std::io::Error::other)? +} + +fn os_pipe() -> std::io::Result<(OwnedFd, OwnedFd)> { + #[cfg(target_os = "linux")] + { + nix::unistd::pipe2(nix::fcntl::OFlag::O_CLOEXEC) + .map_err(|e| std::io::Error::from_raw_os_error(e as i32)) + } + + #[cfg(not(target_os = "linux"))] + { + let (read_fd, write_fd) = + nix::unistd::pipe().map_err(|e| std::io::Error::from_raw_os_error(e as i32))?; + let _ = set_cloexec(&read_fd); + let _ = set_cloexec(&write_fd); + Ok((read_fd, write_fd)) + } +} + +fn set_cloexec(fd: &OwnedFd) -> std::io::Result<()> { + let raw = fd.as_raw_fd(); + let flags = unsafe { libc::fcntl(raw, libc::F_GETFD) }; + if flags < 0 { + return Err(std::io::Error::last_os_error()); + } + let ret = unsafe { libc::fcntl(raw, libc::F_SETFD, flags | libc::FD_CLOEXEC) }; + if ret < 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use command_fds::CommandFdExt; + + fn bash_available() -> bool { + std::path::Path::new("/bin/bash").exists() + } + + async fn run_static(snapshot: &str, command: &str) -> std::process::Output { + let state = StaticShellSnapshot { + snapshot: snapshot.to_string(), + shell: UnixShellKind::Bash, + }; + let prep = state + .prepare_command( + command, + crate::computer::local::SearchShadowConfig::default(), + ) + .unwrap(); + let mut cmd = tokio::process::Command::new(&prep.binary); + cmd.args(&prep.args) + .current_dir(std::env::current_dir().unwrap()) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + cmd.fd_mappings(prep.fd_mappings).unwrap(); + let child = cmd.spawn().unwrap(); + drop(cmd); + + let snap = state.snapshot.clone(); + let write_handle = + tokio::spawn(async move { write_snapshot_to_pipe(&snap, prep.state_in_write).await }); + let output = child.wait_with_output().await.unwrap(); + write_handle.await.unwrap().unwrap(); + output + } + + #[tokio::test] + async fn replays_aliases_and_functions() { + if !bash_available() { + return; + } + let output = run_static( + "alias grok_alias_probe='echo ALIAS_OK'\ngrok_fn_probe() { echo FN_OK; }\n", + "grok_alias_probe && grok_fn_probe", + ) + .await; + assert!(output.status.success(), "command failed: {output:?}"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("ALIAS_OK") && stdout.contains("FN_OK"), + "alias and function must be replayed: {stdout:?}" + ); + } + + #[tokio::test] + async fn user_command_exit_code_propagates_past_bad_snapshot() { + if !bash_available() { + return; + } + let output = run_static("this-is-not-a-command 2>/dev/null\n", "exit 7").await; + assert_eq!( + output.status.code(), + Some(7), + "user command exit code must propagate: {output:?}" + ); + } + + #[tokio::test] + async fn empty_snapshot_runs_plain() { + if !bash_available() { + return; + } + let output = run_static("", "echo PLAIN_OK").await; + assert!(output.status.success()); + assert!( + String::from_utf8_lossy(&output.stdout).contains("PLAIN_OK"), + "empty snapshot must degrade to a plain shell" + ); + } +} diff --git a/crates/codegen/xai-grok-tools/src/computer/local/terminal.rs b/crates/codegen/xai-grok-tools/src/computer/local/terminal.rs index b5948b8..e31a22c 100644 --- a/crates/codegen/xai-grok-tools/src/computer/local/terminal.rs +++ b/crates/codegen/xai-grok-tools/src/computer/local/terminal.rs @@ -150,8 +150,14 @@ enum TerminalCommand { reply: oneshot::Sender>, }, + WarmShell { + cwd: PathBuf, + }, + /// Kill all running foreground processes owned by a specific session. - KillForegroundCommandsByOwner { owner_session_id: String }, + KillForegroundCommandsByOwner { + owner_session_id: String, + }, /// Kill all running background tasks owned by a specific session. KillTasksByOwner { @@ -502,6 +508,8 @@ struct LocalTerminalActor { /// Whether persistent shell state is enabled. persistent_shell: bool, + login_shell_capture: bool, + /// Per-backend `find`→`bfs` / `grep`→`ugrep` shadow enable state, resolved /// once by the host and baked in at construction. Passed to /// `search_injection` per command rather than read from a process-global, so @@ -513,12 +521,12 @@ struct LocalTerminalActor { #[cfg(unix)] shell_state: Option, - /// Captured login-shell PATH for the non-persistent path. - /// Lazily initialized on first command when `persistent_shell` is false. - /// Ensures CLI tools from rc files are discoverable even without a full - /// shell snapshot. + /// Static alias/function snapshot for the non-persistent path. #[cfg(unix)] - login_path_env: Option>, + static_shell: Option, + + #[cfg(unix)] + login_env: Option>, } impl LocalTerminalActor { @@ -528,6 +536,7 @@ impl LocalTerminalActor { cgroup_guard: CgroupGuard, memory_monitor: MemoryMonitor, persistent_shell: bool, + login_shell_capture: bool, search_shadows: SearchShadowConfig, completed_task_ttl: Duration, foreground_block_budget: Duration, @@ -547,11 +556,14 @@ impl LocalTerminalActor { _cgroup_guard: cgroup_guard, memory_monitor, persistent_shell, + login_shell_capture, search_shadows, #[cfg(unix)] shell_state: None, #[cfg(unix)] - login_path_env: None, + static_shell: None, + #[cfg(unix)] + login_env: None, } } @@ -570,15 +582,19 @@ impl LocalTerminalActor { return self.spawn_persistent_command(command, cwd, env).await; } - // Lazy-init: capture the user's login-shell PATH on first command so - // CLI tools from rc files (.bashrc, .zshrc, virtualenvs) are visible. #[cfg(unix)] - if self.login_path_env.is_none() { - self.login_path_env = Some(capture_login_path().await); + if self.login_shell_capture && login_env_capture_enabled() { + self.ensure_static_shell_initialized(cwd).await; + return self.spawn_static_command(command, cwd, env).await; } #[cfg(unix)] - let login_env = self.login_path_env.as_ref(); + if self.login_env.is_none() { + self.login_env = Some(capture_login_env().await); + } + + #[cfg(unix)] + let login_env = self.login_env.as_ref(); #[cfg(not(unix))] let login_env: Option<&HashMap> = None; @@ -591,6 +607,134 @@ impl LocalTerminalActor { }) } + #[cfg(unix)] + async fn ensure_static_shell_initialized(&mut self, cwd: &std::path::Path) { + if self.static_shell.is_some() && self.login_env.is_some() { + return; + } + let (snapshot, login_env) = tokio::join!( + async { + if self.static_shell.is_none() { + Some(super::static_shell::StaticShellSnapshot::init(cwd).await) + } else { + None + } + }, + async { + if self.login_env.is_none() { + Some(capture_login_env().await) + } else { + None + } + } + ); + if let Some(snapshot) = snapshot { + self.static_shell = Some(snapshot); + } + if let Some(env) = login_env { + self.login_env = Some(env); + } + } + + #[cfg(unix)] + async fn spawn_static_command( + &mut self, + command: &str, + cwd: &std::path::Path, + env: &HashMap, + ) -> Result { + use command_fds::CommandFdExt; + + let static_shell = self.static_shell.as_ref().unwrap(); + let prep = static_shell + .prepare_command(command, self.search_shadows) + .map_err(|e| ComputerError::io(format!("prepare static command: {e}")))?; + + let mut cmd = tokio::process::Command::new(&prep.binary); + cmd.args(&prep.args) + .current_dir(cwd) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + + if let Some(login) = self.login_env.as_ref() { + for (key, value) in login { + if key != "PATH" && std::env::var_os(key).is_none() { + cmd.env(key, value); + } + } + } + cmd.envs(shell_state::shell_env_overrides()); + for (key, value) in env { + cmd.env(key, value); + } + cmd.envs(crate::util::pager_env()); + if let Some(path) = self.login_env.as_ref().and_then(|l| l.get("PATH")) { + cmd.env("PATH", path); + } + crate::util::apply_grok_agent_marker(&mut cmd); + + cmd.fd_mappings(prep.fd_mappings) + .map_err(|e| ComputerError::io(format!("fd mapping: {e}")))?; + + unsafe { + cmd.pre_exec(crate::util::detach_from_tty); + } + + #[cfg(target_os = "linux")] + if xai_grok_sandbox::should_restrict_child_network() { + unsafe { + cmd.pre_exec(|| xai_grok_sandbox::child_net::install_child_network_filter()); + } + } + + let child = cmd.spawn().map_err(|e| { + ComputerError::io_with_kind(format!("spawn shell in {}: {e}", cwd.display()), e.kind()) + })?; + drop(cmd); + + let mut process_group = crate::util::ProcessGroup::new() + .map_err(|e| ComputerError::io(format!("ProcessGroup::new: {e}")))?; + if let Err(e) = process_group.attach(&child) { + tracing::debug!("Failed to attach static-shell child to ProcessGroup: {e}"); + } + + let snapshot = static_shell.snapshot.clone(); + tokio::spawn(async move { + if let Err(e) = + super::static_shell::write_snapshot_to_pipe(&snapshot, prep.state_in_write).await + { + tracing::debug!("failed to write static shell snapshot to pipe: {e}"); + } + }); + + Ok(SpawnResult { + child, + process_group, + state_dump_handle: None, + }) + } + + #[cfg(unix)] + async fn ensure_persistent_shell_initialized(&mut self, cwd: &std::path::Path) { + if self.shell_state.is_some() { + return; + } + let shell = shell_state::ShellKind::detect(); + match shell_state::ShellState::init(shell, cwd).await { + Ok(state) => self.shell_state = Some(state), + Err(e) => { + tracing::warn!("persistent shell init failed, using empty state: {e}"); + self.shell_state = Some(shell_state::ShellState { + cwd: cwd.to_path_buf(), + snapshot: String::new(), + shell, + }); + } + } + } + /// Spawn a command with persistent shell state: restore the prior snapshot /// via fd 3, run the user command, dump the new state to fd 4. #[cfg(unix)] @@ -602,41 +746,41 @@ impl LocalTerminalActor { ) -> Result { use command_fds::CommandFdExt; - if self.shell_state.is_none() { - let shell = shell_state::ShellKind::detect(); - match shell_state::ShellState::init(shell, cwd).await { - Ok(state) => self.shell_state = Some(state), - Err(e) => { - tracing::warn!("persistent shell init failed, using empty state: {e}"); - self.shell_state = Some(shell_state::ShellState { - cwd: cwd.to_path_buf(), - snapshot: String::new(), - shell, - }); - } - } - } + self.ensure_persistent_shell_initialized(cwd).await; let shell_state = self.shell_state.as_ref().unwrap(); - // When the persistent shell already tracks a - // model-set cwd (the model ran a `cd`), honor it unconditionally. - // The bash tool always populates `request.working_directory` with - // the workspace's resolved Cwd, even when no per-call override is - // intended; treating that as "explicit override and reset" was the - // bug that made `cd` not persist across consecutive Shell calls. - // - // Per-call working_directory overrides arrive through the - // shell adapter, which prefixes a subshell `(cd && - // …)` to the command string — that mechanism is local to a single - // call and does NOT mutate the parent shell's `$PWD`, so we never - // need to surface it as a `cwd_override` here. - let cwd_override: Option<&std::path::Path> = None; - // Silence the unused-binding lint on the inbound `cwd` parameter: - // it's still threaded into `spawn_command` (the non-persistent - // fallback path) below. - let _ = cwd; + let tracked_cwd_alive = match tokio::fs::metadata(&shell_state.cwd).await { + Ok(m) => m.is_dir(), + Err(e) => !matches!( + e.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory + ), + }; + let (cwd_override, spawn_notice): (Option<&std::path::Path>, Option) = + if tracked_cwd_alive { + (None, None) + } else { + tracing::warn!( + tracked_cwd = %shell_state.cwd.display(), + fallback = %cwd.display(), + "persistent shell cwd no longer exists; falling back to request working directory" + ); + ( + Some(cwd), + Some(format!( + "warning: shell working directory {} no longer exists; this command ran in {} instead\n", + shell_state.cwd.display(), + cwd.display() + )), + ) + }; let prep = shell_state - .prepare_command(command, cwd_override, self.search_shadows) + .prepare_command( + command, + cwd_override, + self.search_shadows, + spawn_notice.as_deref(), + ) .map_err(|e| ComputerError::io(format!("prepare persistent command: {e}")))?; let mut cmd = tokio::process::Command::new(&prep.binary); @@ -673,7 +817,12 @@ impl LocalTerminalActor { } } - let child = cmd.spawn().map_err(ComputerError::from)?; + let child = cmd.spawn().map_err(|e| { + ComputerError::io_with_kind( + format!("spawn shell in {}: {e}", prep.cwd.display()), + e.kind(), + ) + })?; // Drop cmd to release the FdMapping OwnedFds held in its pre_exec closure. // Without this, the parent keeps the write-end of the state-out pipe open, // preventing the dump reader from seeing EOF. @@ -792,11 +941,28 @@ impl LocalTerminalActor { } TerminalCommand::GetShellCwd { reply } => { #[cfg(unix)] - let cwd = self.shell_state.as_ref().map(|s| s.cwd.clone()); + let cwd = if self.persistent_shell { + self.shell_state.as_ref().map(|s| s.cwd.clone()) + } else { + None + }; #[cfg(not(unix))] let cwd = None; let _ = reply.send(cwd); } + TerminalCommand::WarmShell { cwd } => { + #[cfg(unix)] + if self.persistent_shell { + // Cursor's persistent shell initializes lazily on first + // command; warming is only for the static capture path. + } else if self.login_shell_capture && login_env_capture_enabled() { + self.ensure_static_shell_initialized(&cwd).await; + } else if self.login_env.is_none() { + self.login_env = Some(capture_login_env().await); + } + #[cfg(not(unix))] + let _ = cwd; + } TerminalCommand::KillForegroundCommands => { self.kill_foreground_commands().await; } @@ -1401,7 +1567,7 @@ impl LocalTerminalActor { // branch — see the comment there). This pass must still fire // `send_task_complete` unconditionally for newly-completed // background tasks so the pager UI, persistence, and - // `AutoWakeDeliveredIds` bookkeeping all still get the snapshot. + // `TaskCompletionReservations` bookkeeping all still get the snapshot. for task_id in newly_completed { if let Some(process) = self.processes.get(&task_id) { let snapshot = process.to_task_snapshot(&task_id).await; @@ -1968,7 +2134,7 @@ impl LocalTerminalBackend { /// If `memory_config` is provided, a cgroupv2 memory limit is enforced on /// all spawned commands (Linux only; silently degrades to no-op elsewhere). pub fn new() -> Self { - Self::new_inner(None, false, false, SearchShadowConfig::default()) + Self::new_inner(None, false, false, true, SearchShadowConfig::default()) } /// Create a new LocalTerminalBackend with persistent shell state. @@ -1977,19 +2143,31 @@ impl LocalTerminalBackend { /// and shell options persist across command invocations. The user's login shell /// (bash or zsh) is detected and its rc files are loaded once on first command. pub fn with_persistent_shell() -> Self { - Self::new_inner(None, false, true, SearchShadowConfig::default()) + Self::new_inner(None, false, true, true, SearchShadowConfig::default()) } /// Create a new LocalTerminalBackend with cgroup memory limits. /// /// See [`CgroupMemoryConfig`] for details on the soft/hard limit model. pub fn with_memory_limit(config: CgroupMemoryConfig) -> Self { - Self::new_inner(Some(config), false, false, SearchShadowConfig::default()) + Self::new_inner( + Some(config), + false, + false, + true, + SearchShadowConfig::default(), + ) } /// Create a new LocalTerminalBackend with both memory limits and persistent shell. pub fn with_memory_limit_and_persistent_shell(config: CgroupMemoryConfig) -> Self { - Self::new_inner(Some(config), false, true, SearchShadowConfig::default()) + Self::new_inner( + Some(config), + false, + true, + true, + SearchShadowConfig::default(), + ) } /// Create a new LocalTerminalBackend using spawn_local (for single-threaded runtimes). @@ -1997,7 +2175,14 @@ impl LocalTerminalBackend { /// `search_shadows` is the host-resolved `find`→`bfs` / `grep`→`ugrep` enable /// state, baked into this backend (see [`SearchShadowConfig`]). pub fn new_local(search_shadows: SearchShadowConfig) -> Self { - Self::new_inner(None, true, false, search_shadows) + Self::new_inner(None, true, false, true, search_shadows) + } + + pub fn new_local_with_login_shell_capture( + search_shadows: SearchShadowConfig, + login_shell_capture: bool, + ) -> Self { + Self::new_inner(None, true, false, login_shell_capture, search_shadows) } /// Create a new LocalTerminalBackend using spawn_local with persistent shell. @@ -2005,12 +2190,18 @@ impl LocalTerminalBackend { /// `search_shadows` is the host-resolved `find`→`bfs` / `grep`→`ugrep` enable /// state, baked into this backend (see [`SearchShadowConfig`]). pub fn new_local_with_persistent_shell(search_shadows: SearchShadowConfig) -> Self { - Self::new_inner(None, true, true, search_shadows) + Self::new_inner(None, true, true, true, search_shadows) } /// Create a new LocalTerminalBackend using spawn_local with memory limits. pub fn new_local_with_memory_limit(config: CgroupMemoryConfig) -> Self { - Self::new_inner(Some(config), true, false, SearchShadowConfig::default()) + Self::new_inner( + Some(config), + true, + false, + true, + SearchShadowConfig::default(), + ) } /// Test-only: a spawn_local backend that enrolls spawned children into @@ -2025,6 +2216,7 @@ impl LocalTerminalBackend { None, true, false, + true, search_shadows, COMPLETED_TASK_TTL, FOREGROUND_BLOCK_BUDGET, @@ -2040,6 +2232,7 @@ impl LocalTerminalBackend { None, false, false, + true, SearchShadowConfig::default(), ttl, FOREGROUND_BLOCK_BUDGET, @@ -2055,6 +2248,7 @@ impl LocalTerminalBackend { None, false, false, + true, SearchShadowConfig::default(), COMPLETED_TASK_TTL, budget, @@ -2070,6 +2264,7 @@ impl LocalTerminalBackend { None, false, false, + true, SearchShadowConfig::default(), COMPLETED_TASK_TTL, FOREGROUND_BLOCK_BUDGET, @@ -2082,12 +2277,14 @@ impl LocalTerminalBackend { memory_config: Option, use_spawn_local: bool, persistent_shell: bool, + login_shell_capture: bool, search_shadows: SearchShadowConfig, ) -> Self { Self::new_with_ttl( memory_config, use_spawn_local, persistent_shell, + login_shell_capture, search_shadows, COMPLETED_TASK_TTL, foreground_block_budget_from_env(), @@ -2100,6 +2297,7 @@ impl LocalTerminalBackend { memory_config: Option, use_spawn_local: bool, persistent_shell: bool, + login_shell_capture: bool, search_shadows: SearchShadowConfig, completed_task_ttl: Duration, foreground_block_budget: Duration, @@ -2125,6 +2323,7 @@ impl LocalTerminalBackend { cgroup_guard, memory_monitor, persistent_shell, + login_shell_capture, search_shadows, completed_task_ttl, foreground_block_budget, @@ -2261,6 +2460,15 @@ impl TerminalBackend for LocalTerminalBackend { reply_rx.await.ok().flatten() } + async fn warm_shell(&self, cwd: &std::path::Path) { + let _ = self + .cmd_tx + .send(TerminalCommand::WarmShell { + cwd: cwd.to_path_buf(), + }) + .await; + } + async fn kill_foreground_commands(&self) { let _ = self .cmd_tx @@ -2591,17 +2799,63 @@ async fn open_output_file(path: &std::path::Path) -> std::io::Result { .await } -/// Capture the user's login-shell PATH so CLI tools from rc files are discoverable. -/// -/// Non-interactive shells (`/bin/bash -c`) don't source rc files, so tools -/// installed via `.bashrc`/`.zshrc`/virtualenvs are invisible. This runs the -/// detected shell with `-lc` plus an explicit `source` of the rc file, extracts -/// PATH using SOH byte markers, and merges it with the current process PATH. -/// -/// Returns a `HashMap` with a single `PATH` key, or an empty map on failure. -/// A 5-second timeout kills the child if rc files hang (conda init, nvm, etc.). #[cfg(unix)] -async fn capture_login_path() -> HashMap { +const ENV_LOGIN_ENV: &str = "GROK_LOGIN_ENV"; + +#[cfg(unix)] +fn login_env_capture_enabled() -> bool { + !matches!( + std::env::var(ENV_LOGIN_ENV).as_deref(), + Ok("0") | Ok("false") + ) +} + +#[cfg(unix)] +fn login_env_var_excluded(key: &str) -> bool { + matches!( + key, + "PWD" + | "OLDPWD" + | "SHLVL" + | "_" + | "TERM" + | "GROK_AGENT" + | "SUDO_ASKPASS" + | "GROK_ASKPASS" + | "ELECTRON_RUN_AS_NODE" + | "SSH_AUTH_SOCK" + | "DBUS_SESSION_BUS_ADDRESS" + | "XDG_RUNTIME_DIR" + | "WAYLAND_DISPLAY" + | "GPG_TTY" + ) || key.to_ascii_lowercase().ends_with("_proxy") + || key.starts_with("GROK_SANDBOX") +} + +#[cfg(unix)] +fn parse_login_env_capture(stdout: &str) -> (Option, HashMap) { + let parts: Vec<&str> = stdout.split('\x01').collect(); + let login_path = parts + .get(1) + .map(|p| p.trim().to_string()) + .filter(|p| !p.is_empty()); + let mut env_map = HashMap::new(); + if let Some(blob) = parts.get(2) { + for pair in blob.split('\0') { + if let Some((key, value)) = pair.split_once('=') + && !key.is_empty() + && key != "PATH" + && !login_env_var_excluded(key) + { + env_map.insert(key.to_string(), value.to_string()); + } + } + } + (login_path, env_map) +} + +#[cfg(unix)] +async fn capture_login_env() -> HashMap { use tokio::io::AsyncReadExt; let shell = shell_state::ShellKind::detect(); @@ -2609,7 +2863,9 @@ async fn capture_login_path() -> HashMap { // Use $HOME inside the script (not interpolated from Rust) to avoid // shell injection if HOME contains special characters. - let script = format!("source \"$HOME/{rc_file}\" 2>/dev/null; printf '\\x01%s\\x01' \"$PATH\""); + let script = format!( + "source \"$HOME/{rc_file}\" 2>/dev/null; printf '\\x01%s\\x01' \"$PATH\"; command env -0 2>/dev/null; printf '\\x01'" + ); let result = tokio::time::timeout(Duration::from_secs(5), async { let mut cmd = tokio::process::Command::new(shell.binary_path()); @@ -2633,11 +2889,11 @@ async fn capture_login_path() -> HashMap { } let stdout = String::from_utf8_lossy(&stdout_buf); - let parts: Vec<&str> = stdout.split('\x01').collect(); - let login_path = (parts.len() >= 3).then(|| parts[1].trim())?; + let (login_path, mut env_map) = parse_login_env_capture(&stdout); + let login_path = login_path?; - if login_path.is_empty() { - return None; + if !login_env_capture_enabled() { + env_map.clear(); } // Merge: login PATH first, then current-process entries not already present. @@ -2648,16 +2904,17 @@ async fn capture_login_path() -> HashMap { .chain(current_path.split(':')) .filter(|e| !e.is_empty() && seen.insert(*e)) .collect(); + env_map.insert("PATH".to_string(), merged.join(":")); - Some(merged.join(":")) + Some(env_map) }) .await; match result { - Ok(Some(path)) => HashMap::from([("PATH".to_string(), path)]), + Ok(Some(env_map)) => env_map, Ok(None) => HashMap::new(), Err(_) => { - tracing::warn!("login-shell PATH capture timed out after 5s"); + tracing::warn!("login-shell env capture timed out after 5s"); HashMap::new() } } @@ -2709,6 +2966,13 @@ fn spawn_shell_command( // detach_from_tty() handles both session and process group creation. .kill_on_drop(true); + if let Some(login) = login_env { + for (key, value) in login { + if key != "PATH" && std::env::var_os(key).is_none() { + cmd.env(key, value); + } + } + } // Apply env vars from the request (e.g., .envrc, color vars, ACP-provided vars). cmd.envs(shell_state::shell_env_overrides()); for (key, value) in env { @@ -2721,8 +2985,8 @@ fn spawn_shell_command( // request env often carries a copy of the parent process's PATH which // doesn't include rc-file additions — applying login PATH after the // request env ensures those additions aren't clobbered. - if let Some(login) = login_env { - cmd.envs(login); + if let Some(path) = login_env.and_then(|l| l.get("PATH")) { + cmd.env("PATH", path); } // Agent marker must win over request/login env. crate::util::apply_grok_agent_marker(&mut cmd); @@ -2792,7 +3056,9 @@ fn spawn_shell_command( #[cfg(unix)] let mut group = crate::util::ProcessGroup::new()?; #[cfg(unix)] - let child = cmd.spawn()?; + let child = cmd.spawn().map_err(|e| { + std::io::Error::new(e.kind(), format!("spawn shell in {}: {e}", cwd.display())) + })?; #[cfg(not(unix))] let (child, mut group) = { @@ -4235,6 +4501,150 @@ mod tests { ); } + #[tokio::test] + async fn test_persistent_shell_deleted_cwd_falls_back_to_request_cwd() { + let backend = LocalTerminalBackend::with_persistent_shell(); + + let scratch = tempfile::TempDir::new().unwrap(); + let result = backend + .run(make_request(&format!("cd {}", scratch.path().display()))) + .await + .unwrap(); + assert_eq!(result.exit_code, Some(0)); + drop(scratch); + + let result = backend.run(make_request("pwd")).await.unwrap(); + assert_eq!(result.exit_code, Some(0)); + let output = &result.combined_output; + assert!( + output.contains("no longer exists"), + "fallback warning must be in the command output, got: {output:?}" + ); + let pwd = output.lines().last().unwrap_or_default().trim(); + assert!( + pwd == "/tmp" || pwd == "/private/tmp", + "command must run in the request working directory, got: {pwd:?}" + ); + + let result = backend.run(make_request("pwd")).await.unwrap(); + assert_eq!(result.exit_code, Some(0)); + assert!( + !result.combined_output.contains("no longer exists"), + "state must heal after the fallback, got: {:?}", + result.combined_output + ); + } + + #[tokio::test] + async fn test_persistent_shell_spawn_error_names_missing_cwd() { + let backend = LocalTerminalBackend::with_persistent_shell(); + + let scratch = tempfile::TempDir::new().unwrap(); + let result = backend + .run(make_request(&format!("cd {}", scratch.path().display()))) + .await + .unwrap(); + assert_eq!(result.exit_code, Some(0)); + drop(scratch); + + let gone = tempfile::TempDir::new().unwrap(); + let gone_path = gone.path().to_path_buf(); + drop(gone); + let mut req = make_request("pwd"); + req.working_directory = gone_path.clone(); + + let Err(err) = backend.run(req).await else { + panic!("spawn must fail when both directories are missing"); + }; + let msg = err.to_string(); + assert!( + msg.contains("spawn shell in") && msg.contains(&gone_path.display().to_string()), + "error must name the spawn directory, got: {msg}" + ); + } + + #[tokio::test] + async fn test_persistent_shell_does_not_inherit_dump_errexit() { + let backend = LocalTerminalBackend::with_persistent_shell(); + + let result = backend.run(make_request("true")).await.unwrap(); + assert_eq!(result.exit_code, Some(0)); + + let result = backend + .run(make_request("false; echo STILL_ALIVE")) + .await + .unwrap(); + assert_eq!( + result.exit_code, + Some(0), + "a failing statement must not abort the command: {:?}", + result.combined_output + ); + assert!( + result.combined_output.contains("STILL_ALIVE"), + "execution must continue past a failing statement: {:?}", + result.combined_output + ); + } + + #[tokio::test] + async fn test_non_persistent_shell_unaffected_by_deleted_cd_target() { + let backend = LocalTerminalBackend::new(); + + let scratch = tempfile::TempDir::new().unwrap(); + let result = backend + .run(make_request(&format!("cd {}", scratch.path().display()))) + .await + .unwrap(); + assert_eq!(result.exit_code, Some(0)); + drop(scratch); + + let result = backend.run(make_request("pwd")).await.unwrap(); + assert_eq!(result.exit_code, Some(0)); + let pwd = result.combined_output.trim(); + assert!( + pwd == "/tmp" || pwd == "/private/tmp", + "spawns must use the request cwd, got: {pwd:?}" + ); + } + + #[test] + fn test_parse_login_env_capture() { + let stdout = "motd noise\n\x01/opt/rc/bin:/usr/bin\x01\ + XDG_CONFIG_HOME=/Users/u/.config\0\ + GH_CONFIG_DIR=/Users/u/.config/gh\0\ + MULTILINE=a\nb\0\ + PATH=/login/path\0\ + PWD=/somewhere\0\ + SHLVL=2\0\ + GPG_TTY=/dev/ttys001\0\ + http_proxy=http://p:3128\0\x01"; + let (path, env) = parse_login_env_capture(stdout); + assert_eq!(path.as_deref(), Some("/opt/rc/bin:/usr/bin")); + assert_eq!( + env.get("XDG_CONFIG_HOME").map(String::as_str), + Some("/Users/u/.config") + ); + assert_eq!( + env.get("GH_CONFIG_DIR").map(String::as_str), + Some("/Users/u/.config/gh") + ); + assert_eq!(env.get("MULTILINE").map(String::as_str), Some("a\nb")); + for excluded in ["PATH", "PWD", "SHLVL", "GPG_TTY", "http_proxy"] { + assert!( + !env.contains_key(excluded), + "{excluded} must be filtered from the captured login env" + ); + } + } + + #[test] + fn test_parse_login_env_capture_path_only() { + let (path, env) = parse_login_env_capture("\x01/usr/bin\x01"); + assert_eq!(path.as_deref(), Some("/usr/bin")); + assert!(env.is_empty()); + } + #[tokio::test] async fn test_non_persistent_shell_no_state() { // Verify the default (non-persistent) mode doesn't carry state. diff --git a/crates/codegen/xai-grok-tools/src/computer/types.rs b/crates/codegen/xai-grok-tools/src/computer/types.rs index ad13267..75fd3ce 100644 --- a/crates/codegen/xai-grok-tools/src/computer/types.rs +++ b/crates/codegen/xai-grok-tools/src/computer/types.rs @@ -292,6 +292,8 @@ pub trait TerminalBackend: Send + Sync { /// only the subagent's own tasks are killed — not the parent's. async fn kill_all_background_tasks_by_owner(&self, _owner_session_id: &str) {} + async fn warm_shell(&self, _cwd: &std::path::Path) {} + /// Reparent notification handles for all tasks owned by `old_owner_session_id`. /// Swaps the dead child session's notification handle with the parent's /// live handle so events from surviving processes route correctly. diff --git a/crates/codegen/xai-grok-tools/src/implementations/codex/grep_files/tool.rs b/crates/codegen/xai-grok-tools/src/implementations/codex/grep_files/tool.rs index b8d1909..c84c9a9 100644 --- a/crates/codegen/xai-grok-tools/src/implementations/codex/grep_files/tool.rs +++ b/crates/codegen/xai-grok-tools/src/implementations/codex/grep_files/tool.rs @@ -27,8 +27,7 @@ const COMMAND_TIMEOUT: Duration = Duration::from_secs(30); // ─── Description ──────────────────────────────────────────────────── -const DESCRIPTION: &str = - "Finds files whose contents match the pattern and lists them by modification time."; +const DESCRIPTION: &str = "Finds files whose contents match the ${{ params.search.pattern }} and lists them by modification time."; // ─── Input ────────────────────────────────────────────────────────── diff --git a/crates/codegen/xai-grok-tools/src/implementations/grok_build/bash/mod.rs b/crates/codegen/xai-grok-tools/src/implementations/grok_build/bash/mod.rs index 1810418..b4d0791 100644 --- a/crates/codegen/xai-grok-tools/src/implementations/grok_build/bash/mod.rs +++ b/crates/codegen/xai-grok-tools/src/implementations/grok_build/bash/mod.rs @@ -1425,8 +1425,8 @@ impl BashTool { r#"Run a ${%- if is_windows %} shell command${%- else %} bash command${%- endif %} and return its output. Usage notes: - - You can specify an optional timeout in milliseconds (up to ${{ max_timeout_ms | default(300000) }}ms). ${%- if auto_background_on_timeout %} If not specified, commands exceeding the default timeout will be automatically backgrounded instead of killed. You will receive a task_id to check output later.${%- else %} If not specified, commands will timeout after ${{ default_timeout_ms | default(120000) }}ms.${%- endif %} - - Timeout enforcement: when the timeout fires, the wrapper${%- if is_windows %} terminates the child's Job Object, killing every descendant process immediately (no graceful-termination grace period).${%- else %} kills the child process group (SIGTERM, escalated to SIGKILL after a ~1s grace period). Descendants that did not detach via `setsid` / `nohup` will also be killed.${%- endif %} `timeout: 0` in `${%- if params is defined and params.execute is defined and params.execute.is_background %}${{ params.execute.is_background }}${%- else %}background${%- endif %}: true` mode disables the wrapper timeout entirely; the child's lifetime is owned by the model via ${{ tools.by_kind.kill_task_action }}. + - You can specify an optional ${{ params.execute.timeout }} in milliseconds (up to ${{ max_timeout_ms | default(300000) }}ms). ${%- if auto_background_on_timeout %} If not specified, commands exceeding the default timeout will be automatically backgrounded instead of killed. You will receive a task_id to check output later.${%- else %} If not specified, commands will timeout after ${{ default_timeout_ms | default(120000) }}ms.${%- endif %} + - Timeout enforcement: when the timeout fires, the wrapper${%- if is_windows %} terminates the child's Job Object, killing every descendant process immediately (no graceful-termination grace period).${%- else %} kills the child process group (SIGTERM, escalated to SIGKILL after a ~1s grace period). Descendants that did not detach via `setsid` / `nohup` will also be killed.${%- endif %} `${{ params.execute.timeout }}: 0` in `${%- if params is defined and params.execute is defined and params.execute.is_background %}${{ params.execute.is_background }}${%- else %}background${%- endif %}: true` mode disables the wrapper timeout entirely; the child's lifetime is owned by the model via ${{ tools.by_kind.kill_task_action }}. - If the output exceeds {max_output_bytes} characters, output will be truncated before being returned to you. - You can use the ${{ params.execute.is_background }} parameter to run the command in the background (e.g., dev servers, long builds): it returns a task_id immediately and keeps running in the background. You are notified on completion, so do not poll or sleep-wait for it.${%- if has_unix_utilities %} You do not need to use '&' at the end of the command when using this parameter.${%- endif %} ${%- if shell_uses_semicolon %} @@ -1441,7 +1441,7 @@ ${%- endif %}"# r#"Run a ${%- if is_windows %} shell command${%- else %} bash command${%- endif %} and return its output. Usage notes: - - You can specify an optional timeout in milliseconds (up to ${{ max_timeout_ms | default(300000) }}ms). If not specified, commands will timeout after ${{ default_timeout_ms | default(120000) }}ms. + - You can specify an optional ${{ params.execute.timeout }} in milliseconds (up to ${{ max_timeout_ms | default(300000) }}ms). If not specified, commands will timeout after ${{ default_timeout_ms | default(120000) }}ms. - Timeout enforcement: when the timeout fires, the wrapper${%- if is_windows %} terminates the child's Job Object, killing every descendant process immediately (no graceful-termination grace period).${%- else %} kills the child process group (SIGTERM, escalated to SIGKILL after a ~1s grace period).${%- endif %} - If the output exceeds {max_output_bytes} characters, output will be truncated before being returned to you. ${%- if shell_uses_semicolon %} @@ -4668,6 +4668,40 @@ mod tests { renderer.render_with_extra(template, &extras).unwrap() } + #[test] + fn description_tracks_renamed_timeout() { + let renderer = TemplateRenderer::new( + HashMap::from([ + (ToolKind::Execute, "run_terminal_cmd".to_string()), + (ToolKind::KillTaskAction, "kill_task".to_string()), + ]), + HashMap::from([( + ToolKind::Execute, + HashMap::from([ + ("timeout".to_string(), "max_wait".to_string()), + ("is_background".to_string(), "is_background".to_string()), + ]), + )]), + ); + let extras = serde_json::json!({ + "auto_background_on_timeout": true, + "is_windows": false, + "shell_uses_semicolon": false, + "has_unix_utilities": true, + }); + let out = renderer + .render_with_extra(BashTool::default_description_template_enabled(), &extras) + .unwrap(); + assert!( + out.contains("optional max_wait in milliseconds") && out.contains("`max_wait: 0`"), + "renamed timeout must appear:\n{out}" + ); + assert!( + !out.contains("optional timeout in milliseconds") && !out.contains("`timeout: 0`"), + "canonical timeout must not remain after rename:\n{out}" + ); + } + #[test] fn unix_shell_omits_utility_and_chaining_notes() { let out = render(BashTool::default_description_template_enabled(), true); diff --git a/crates/codegen/xai-grok-tools/src/implementations/grok_build/grep/mod.rs b/crates/codegen/xai-grok-tools/src/implementations/grok_build/grep/mod.rs index 1ad8b09..30e550e 100644 --- a/crates/codegen/xai-grok-tools/src/implementations/grok_build/grep/mod.rs +++ b/crates/codegen/xai-grok-tools/src/implementations/grok_build/grep/mod.rs @@ -249,9 +249,9 @@ impl crate::types::tool_metadata::ToolMetadata for GrepTool { r#"Search file contents with regular expressions (ripgrep). - Full regex syntax, so escape literal special characters: `functionCall\(`, or `interface\{\}` to find interface{} in Go. -- Pass the pattern as a raw regex string — no surrounding quotes. +- Pass ${{ params.search.pattern }} as a raw regex string — no surrounding quotes. - Respects .gitignore unless you pass a broad glob like '--glob *'. -- Only filter by 'type' or 'glob' when you are sure of the file type; import paths may not match source file types (.js vs .ts). +- Only filter by '${{ params.search.type }}' or '${{ params.search.glob }}' when you are sure of the file type; import paths may not match source file types (.js vs .ts). - Output is ripgrep-style: ':' marks match lines, '-' marks context lines, grouped by file. Large results are capped and report "at least" counts."# } } @@ -1639,6 +1639,39 @@ mod tests { assert!(tool.description_template().contains("regex")); } + #[test] + fn description_template_tracks_renamed_search_params() { + use crate::types::template_renderer::TemplateRenderer; + use crate::types::tool::ToolKind; + use crate::types::tool_metadata::ToolMetadata; + use std::collections::HashMap; + + let tools = HashMap::from([(ToolKind::Search, "grep".to_string())]); + let params = HashMap::from([( + ToolKind::Search, + HashMap::from([ + ("pattern".to_string(), "query".to_string()), + ("type".to_string(), "filetype".to_string()), + ("glob".to_string(), "include".to_string()), + ]), + )]); + let rendered = TemplateRenderer::new(tools, params) + .render(ToolMetadata::description_template(&GrepTool)) + .unwrap(); + assert!( + rendered.contains("Pass query as a raw regex") + && rendered.contains("'filetype'") + && rendered.contains("'include'"), + "renamed search params must appear:\n{rendered}" + ); + assert!( + !rendered.contains("Pass pattern as") + && !rendered.contains("'type'") + && !rendered.contains("'glob'"), + "canonical search param names must not remain after rename:\n{rendered}" + ); + } + #[tokio::test] async fn tool_grep_no_matches() { let tmp = TempDir::new().unwrap(); diff --git a/crates/codegen/xai-grok-tools/src/implementations/grok_build/kill_task/mod.rs b/crates/codegen/xai-grok-tools/src/implementations/grok_build/kill_task/mod.rs index 167d677..35fd0e5 100644 --- a/crates/codegen/xai-grok-tools/src/implementations/grok_build/kill_task/mod.rs +++ b/crates/codegen/xai-grok-tools/src/implementations/grok_build/kill_task/mod.rs @@ -85,6 +85,7 @@ impl crate::types::tool_metadata::ToolMetadata for KillTaskTool { subagent_present: true, bash_present: true, is_windows: cfg!(not(unix)), + task_id_param: "task_id", }) }); &DESC @@ -147,6 +148,9 @@ fn kill_task_description( subagent_present: renderer.tool_for_kind(ToolKind::Task).is_some(), bash_present: renderer.tool_for_kind(ToolKind::Execute).is_some(), is_windows: cfg!(not(unix)), + task_id_param: renderer + .param_for_kind(ToolKind::KillTaskAction, "task_id") + .unwrap_or("task_id"), }) } @@ -437,6 +441,32 @@ mod tests { } } + #[test] + fn description_tracks_renamed_task_id() { + use crate::types::template_renderer::TemplateRenderer; + use crate::types::tool::ToolKind; + use std::collections::HashMap; + + let tools = HashMap::from([ + (ToolKind::Execute, "run_terminal_command".to_string()), + (ToolKind::Monitor, "monitor".to_string()), + (ToolKind::KillTaskAction, "kill_task".to_string()), + ]); + let params = HashMap::from([( + ToolKind::KillTaskAction, + HashMap::from([("task_id".to_string(), "id".to_string())]), + )]); + let rendered = kill_task_description(&TemplateRenderer::new(tools, params), None); + assert!( + rendered.contains("Pass its id (a monitor's id is returned by monitor)"), + "renamed task_id must appear in pass-line and monitor aside:\n{rendered}" + ); + assert!( + !rendered.contains("task_id"), + "canonical task_id must not remain after rename:\n{rendered}" + ); + } + /// The kill mechanism is OS-level: Windows describes Job Object termination, /// Unix/Git Bash describe SIGTERM/SIGKILL. #[test] diff --git a/crates/codegen/xai-grok-tools/src/implementations/grok_build/kill_task/terminal_command.rs b/crates/codegen/xai-grok-tools/src/implementations/grok_build/kill_task/terminal_command.rs index 69a3d5d..49b5987 100644 --- a/crates/codegen/xai-grok-tools/src/implementations/grok_build/kill_task/terminal_command.rs +++ b/crates/codegen/xai-grok-tools/src/implementations/grok_build/kill_task/terminal_command.rs @@ -26,7 +26,7 @@ impl crate::types::tool_metadata::ToolMetadata for KillTerminalCommandTool { r#"Terminate a running background terminal command${%- if tools.by_kind.monitor %} or monitor${%- endif %}. Usage notes: -- Pass its task_id${%- if tools.by_kind.monitor %} (a monitor's task_id is returned by ${{ tools.by_kind.monitor }})${%- endif %}. +- Pass its ${{ params.kill_task_action.task_id }}${%- if tools.by_kind.monitor %} (a monitor's ${{ params.kill_task_action.task_id }} is returned by ${{ tools.by_kind.monitor }})${%- endif %}. - ${%- if is_windows %} Terminates the Job Object of${%- else %} Sends SIGTERM/SIGKILL to${%- endif %} a background command${%- if tools.by_kind.monitor %} or monitor${%- endif %}. - Returns success if the command was killed or had already exited."# } @@ -148,6 +148,36 @@ mod tests { ); } + #[test] + fn description_template_tracks_renamed_task_id() { + use crate::types::template_renderer::TemplateRenderer; + use crate::types::tool::ToolKind; + use std::collections::HashMap; + + let tools = HashMap::from([ + (ToolKind::Monitor, "monitor".to_string()), + ( + ToolKind::KillTaskAction, + "kill_terminal_command".to_string(), + ), + ]); + let params = HashMap::from([( + ToolKind::KillTaskAction, + HashMap::from([("task_id".to_string(), "id".to_string())]), + )]); + let rendered = TemplateRenderer::new(tools, params) + .render(ToolMetadata::description_template(&KillTerminalCommandTool)) + .unwrap(); + assert!( + rendered.contains("Pass its id (a monitor's id is returned by monitor)"), + "renamed task_id must appear in pass-line and monitor aside:\n{rendered}" + ); + assert!( + !rendered.contains("task_id"), + "canonical task_id must not remain after rename:\n{rendered}" + ); + } + #[tokio::test] async fn delegates_kill_killed() { let resources = resources_with_terminal(KillOutcome::Killed); diff --git a/crates/codegen/xai-grok-tools/src/implementations/grok_build/read_file/mod.rs b/crates/codegen/xai-grok-tools/src/implementations/grok_build/read_file/mod.rs index 7e76279..81c6196 100644 --- a/crates/codegen/xai-grok-tools/src/implementations/grok_build/read_file/mod.rs +++ b/crates/codegen/xai-grok-tools/src/implementations/grok_build/read_file/mod.rs @@ -103,7 +103,7 @@ fn extract_pptx_text(file_bytes: Vec) -> Result { pub(crate) const DESCRIPTION_FULL: &str = r#"Read a file. Usage: -- The target_file parameter can be a relative path in the workspace or an absolute path +- The ${{ params.read.target_file }} parameter can be a relative path in the workspace or an absolute path - By default, it reads up to {max_lines_read} lines starting from the beginning of the file - Results are returned with line numbers starting at 1. The format is: LINE_NUMBER→LINE_CONTENT - This tool can read PDF files (.pdf), PowerPoint files (.pptx), Jupyter notebooks (.ipynb files), and image files (e.g. PNG, JPG, etc). diff --git a/crates/codegen/xai-grok-tools/src/implementations/grok_build/task_output/mod.rs b/crates/codegen/xai-grok-tools/src/implementations/grok_build/task_output/mod.rs index 4e4673f..a237039 100644 --- a/crates/codegen/xai-grok-tools/src/implementations/grok_build/task_output/mod.rs +++ b/crates/codegen/xai-grok-tools/src/implementations/grok_build/task_output/mod.rs @@ -682,6 +682,9 @@ impl crate::types::tool_metadata::ToolMetadata for TaskOutputTool { read_tool: Some("read_file"), bash_background_param: Some("is_background"), subagent_background_param: Some("run_in_background"), + task_ids_param: "task_ids", + timeout_ms_param: "timeout_ms", + task_id_param: "task_id", }) }); &DESC @@ -739,6 +742,16 @@ fn task_output_description( read_tool: renderer.tool_for_kind(ToolKind::Read), bash_background_param: renderer.param_for_kind(ToolKind::Execute, "is_background"), subagent_background_param: renderer.param_for_kind(ToolKind::Task, "run_in_background"), + task_ids_param: renderer + .param_for_kind(ToolKind::BackgroundTaskAction, "task_ids") + .unwrap_or("task_ids"), + timeout_ms_param: renderer + .param_for_kind(ToolKind::BackgroundTaskAction, "timeout_ms") + .unwrap_or("timeout_ms"), + // Same singular id name kill_task uses in its monitor aside. + task_id_param: renderer + .param_for_kind(ToolKind::KillTaskAction, "task_id") + .unwrap_or("task_id"), }) } @@ -1070,6 +1083,59 @@ mod tests { } } + #[test] + fn description_tracks_renamed_task_ids_and_timeout_ms() { + use crate::types::template_renderer::TemplateRenderer; + use std::collections::HashMap; + + let tools = HashMap::from([ + (ToolKind::Execute, "run_terminal_command".to_string()), + (ToolKind::Monitor, "monitor".to_string()), + ( + ToolKind::BackgroundTaskAction, + "get_task_output".to_string(), + ), + (ToolKind::KillTaskAction, "kill_task".to_string()), + ]); + let params = HashMap::from([ + ( + ToolKind::Execute, + HashMap::from([("is_background".to_string(), "is_background".to_string())]), + ), + ( + ToolKind::BackgroundTaskAction, + HashMap::from([ + ("task_ids".to_string(), "process_ids".to_string()), + ("timeout_ms".to_string(), "max_wait".to_string()), + ]), + ), + ( + ToolKind::KillTaskAction, + HashMap::from([("task_id".to_string(), "id".to_string())]), + ), + ]); + let rendered = task_output_description(&TemplateRenderer::new(tools, params), None); + assert!( + rendered.contains("Pass process_ids with"), + "renamed task_ids must appear:\n{rendered}" + ); + assert!( + rendered.contains("Omit max_wait or pass 0") + && rendered.contains("positive max_wait wait"), + "renamed timeout_ms must appear:\n{rendered}" + ); + assert!( + rendered.contains("a monitor's id is returned by monitor"), + "renamed kill_task task_id must appear in monitor aside:\n{rendered}" + ); + assert!( + !rendered.contains("task_ids") + && !rendered.contains("timeout_ms") + && !rendered.contains("task_id"), + "canonical param names must not remain after rename:\n{rendered}" + ); + } + #[tokio::test] async fn get_task_running() { let snapshot = make_snapshot("task-1", false, None); diff --git a/crates/codegen/xai-grok-tools/src/implementations/grok_build/task_output/terminal_command.rs b/crates/codegen/xai-grok-tools/src/implementations/grok_build/task_output/terminal_command.rs index 35a785e..fa3013d 100644 --- a/crates/codegen/xai-grok-tools/src/implementations/grok_build/task_output/terminal_command.rs +++ b/crates/codegen/xai-grok-tools/src/implementations/grok_build/task_output/terminal_command.rs @@ -25,8 +25,8 @@ impl crate::types::tool_metadata::ToolMetadata for GetTerminalCommandOutputTool r#"Get output and status from a background terminal command${%- if tools.by_kind.monitor %} or monitor${%- endif %}. Usage notes: -- Pass task_ids with one or more ids from ${{ params.execute.is_background }}=true commands${%- if tools.by_kind.monitor %} (a monitor's task_id is returned by ${{ tools.by_kind.monitor }})${%- endif %}; for a single task use a one-element array. Multiple ids with a positive timeout_ms wait until all complete -- Omit timeout_ms or pass 0 for a non-blocking status snapshot; set a positive timeout_ms to wait up to that many milliseconds, capped at ~10 min +- Pass ${{ params.background_task_action.task_ids }} with one or more ids from ${{ params.execute.is_background }}=true commands${%- if tools.by_kind.monitor %} (a monitor's ${{ params.kill_task_action.task_id }} is returned by ${{ tools.by_kind.monitor }})${%- endif %}; for a single task use a one-element array. Multiple ids with a positive ${{ params.background_task_action.timeout_ms }} wait until all complete +- Omit ${{ params.background_task_action.timeout_ms }} or pass 0 for a non-blocking status snapshot; set a positive ${{ params.background_task_action.timeout_ms }} to wait up to that many milliseconds, capped at ~10 min - Returns current output, status, and exit code if completed${%- if tools.by_kind.read %} - If output is large, use ${{ tools.by_kind.read }} on the output_file path${%- endif %}"# } diff --git a/crates/codegen/xai-grok-tools/src/implementations/grok_build/web_fetch/client.rs b/crates/codegen/xai-grok-tools/src/implementations/grok_build/web_fetch/client.rs index 7031cd7..6546427 100644 --- a/crates/codegen/xai-grok-tools/src/implementations/grok_build/web_fetch/client.rs +++ b/crates/codegen/xai-grok-tools/src/implementations/grok_build/web_fetch/client.rs @@ -94,12 +94,19 @@ impl WebFetchClient { } } - // SSRF check. - ssrf::check_ssrf(&url).await?; + // SSRF check (policy from tool params — not process env at call time). + ssrf::check_ssrf(&url, self.params.allow_local()).await?; // Make request and build output. let http = self.http.get_or_rebuild()?; - let result = match fetch_url(&http, &url, self.params.max_content_length()).await { + let result = match fetch_url( + &http, + &url, + self.params.max_content_length(), + self.params.allow_local(), + ) + .await + { Ok(result) => result, Err(e @ WebFetchError::HttpRequest(_)) => { self.http.invalidate(); @@ -301,6 +308,9 @@ fn validate_url(raw: &str) -> Result { if let Some(host) = parsed.host_str() && host.split('.').count() < 2 + // `localhost` is a single-label name; SSRF still requires + // allow_local for explicit local hosts. + && !ssrf::is_explicit_local_host(host) { return Err(WebFetchError::SingleLabelHost { host: host.to_string(), @@ -310,11 +320,20 @@ fn validate_url(raw: &str) -> Result { Ok(parsed) } -/// Upgrade `http://` to `https://`. +/// Upgrade `http://` to `https://`, except for explicit loopback hosts. +/// +/// Local dev servers almost always speak plain HTTP; forcing TLS would break +/// `http://127.0.0.1` / `http://localhost` when local binding is opted in. fn upgrade_to_https(url: &mut Url) { - if url.scheme() == "http" { - let _ = url.set_scheme("https"); + if url.scheme() != "http" { + return; } + if let Some(host) = url.host_str() + && ssrf::is_explicit_local_host(host) + { + return; + } + let _ = url.set_scheme("https"); } // ─────────────────────────────────────────────────────────────────────────── @@ -335,16 +354,25 @@ enum FetchResult { } /// Fetch a URL with manual same-host redirect handling. +/// +/// Re-runs SSRF checks on every hop so DNS rebinding between redirects cannot +/// sneak a previously-blocked address past the initial check (partial TOCTOU +/// mitigation; peer IP on the live TCP connection is not available from reqwest). async fn fetch_url( client: &reqwest::Client, url: &Url, max_content_length: usize, + allow_local: bool, ) -> Result { let mut current_url = url.clone(); let mut hops = 0; // Loop to follow redirects under the same host. loop { + // Re-check on every hop (including the first) so a rebinding name that + // was public at the pre-fetch check cannot become loopback/private here. + ssrf::check_ssrf(¤t_url, allow_local).await?; + let resp = client .get(current_url.as_str()) .header(USER_AGENT, USER_AGENT_STRING) @@ -367,10 +395,15 @@ async fn fetch_url( // Follow same host; break on cross-host. if let Some(location) = resp.headers().get("location") { let location_str = location.to_str().unwrap_or(""); - let next_url = current_url + let mut next_url = current_url .join(location_str) .map_err(|e| WebFetchError::InvalidRedirect(format!("{e}")))?; if is_same_host(¤t_url, &next_url) { + // Re-apply https upgrade on every hop: Location may be + // absolute `http://…` and would otherwise silently + // downgrade an https fetch. Local hosts still skip TLS. + upgrade_to_https(&mut next_url); + // check_ssrf runs at the top of the next loop iteration. current_url = next_url; continue; } @@ -407,13 +440,11 @@ async fn fetch_url( } } +/// Exact host equality — no `www.` stripping. Distinct DNS labels (even when +/// one is a `www` subdomain of the other) have independent A records and must +/// surface as cross-host redirects rather than auto-follow. fn is_same_host(a: &Url, b: &Url) -> bool { - fn strip_www(h: &str) -> &str { - h.strip_prefix("www.").unwrap_or(h) - } - let host_a = a.host_str().unwrap_or(""); - let host_b = b.host_str().unwrap_or(""); - strip_www(host_a) == strip_www(host_b) + a.host_str() == b.host_str() } // ─────────────────────────────────────────────────────────────────────────── @@ -877,11 +908,28 @@ mod tests { #[test] fn validate_url_rejects_single_label_hosts() { - assert!(validate_url("http://localhost:8080/foo").is_err()); + // localhost is an explicit local host; SSRF still blocks it unless + // allow_local is set on tool params. + assert!(validate_url("http://localhost:8080/foo").is_ok()); assert!(validate_url("http://intranet/foo").is_err()); assert!(validate_url("http://metadata/computeMetadata").is_err()); } + #[test] + fn upgrade_to_https_skips_explicit_local_hosts() { + let mut local = Url::parse("http://127.0.0.1:8080/").unwrap(); + upgrade_to_https(&mut local); + assert_eq!(local.scheme(), "http"); + + let mut localhost = Url::parse("http://localhost:3000/").unwrap(); + upgrade_to_https(&mut localhost); + assert_eq!(localhost.scheme(), "http"); + + let mut public = Url::parse("http://example.com/").unwrap(); + upgrade_to_https(&mut public); + assert_eq!(public.scheme(), "https"); + } + #[test] fn validate_url_rejects_credentials() { assert!(validate_url("https://user:pass@example.com/foo").is_err()); @@ -931,11 +979,11 @@ mod tests { } #[test] - fn same_host_www_stripping() { + fn www_subdomain_is_cross_host() { let a = Url::parse("https://example.com/a").unwrap(); let c = Url::parse("https://www.example.com/a").unwrap(); - assert!(is_same_host(&a, &c)); - assert!(is_same_host(&c, &a)); + assert!(!is_same_host(&a, &c)); + assert!(!is_same_host(&c, &a)); } #[test] @@ -945,6 +993,19 @@ mod tests { assert!(!is_same_host(&a, &d)); } + #[test] + fn same_host_redirect_location_reupgrades_http() { + // Absolute http Location on an https origin must not stay http when + // followed as a same-host hop (upgrade_to_https reapplied each hop). + let origin = Url::parse("https://example.com/start").unwrap(); + let mut next = origin.join("http://example.com/next").unwrap(); + assert_eq!(next.scheme(), "http"); + assert!(is_same_host(&origin, &next)); + upgrade_to_https(&mut next); + assert_eq!(next.scheme(), "https"); + assert_eq!(next.as_str(), "https://example.com/next"); + } + // ── Content type detection ────────────────────────────────────────── #[test] diff --git a/crates/codegen/xai-grok-tools/src/implementations/grok_build/web_fetch/config.rs b/crates/codegen/xai-grok-tools/src/implementations/grok_build/web_fetch/config.rs index 15b58d1..1d559d4 100644 --- a/crates/codegen/xai-grok-tools/src/implementations/grok_build/web_fetch/config.rs +++ b/crates/codegen/xai-grok-tools/src/implementations/grok_build/web_fetch/config.rs @@ -40,6 +40,12 @@ pub struct WebFetchParams { /// routed through this URL. #[serde(default)] pub proxy_endpoint: Option, + /// When true, allow fetches to **explicit** loopback hosts only + /// (`localhost`, `127.0.0.0/8`, `::1`). Private/metadata stay blocked. + /// Default: `false` (fail closed). Set via `[toolset.web_fetch] + /// allow_local = true` or `GROK_WEB_FETCH_ALLOW_LOCAL=1`. + #[serde(default)] + pub allow_local: Option, } register_resource!("grok_build", "WebFetch", WebFetchParams); @@ -71,6 +77,10 @@ impl WebFetchParams { self.context_window_tokens.unwrap_or(128_000) } + pub fn allow_local(&self) -> bool { + self.allow_local.unwrap_or(false) + } + pub fn allowed_domains(&self) -> Vec { match &self.allowed_domains { Some(v) => v.clone(), diff --git a/crates/codegen/xai-grok-tools/src/implementations/grok_build/web_fetch/ssrf.rs b/crates/codegen/xai-grok-tools/src/implementations/grok_build/web_fetch/ssrf.rs index b9f1688..dffac94 100644 --- a/crates/codegen/xai-grok-tools/src/implementations/grok_build/web_fetch/ssrf.rs +++ b/crates/codegen/xai-grok-tools/src/implementations/grok_build/web_fetch/ssrf.rs @@ -1,87 +1,133 @@ //! SSRF (Server-Side Request Forgery) protection for `web_fetch`. //! -//! Validates that resolved IP addresses are not in private, link-local, or -//! cloud metadata ranges before allowing outbound HTTP requests. +//! Policy: +//! - Non-public addresses (loopback, RFC 1918, link-local, CGNAT, TEST-NET, +//! multicast, etc.) are blocked by default. +//! - Local access is opt-in via tool params (`WebFetchParams::allow_local`, +//! set from `[toolset.web_fetch] allow_local` or `GROK_WEB_FETCH_ALLOW_LOCAL=1`). +//! Even when enabled, only **explicit** loopback hosts are allowed +//! (`localhost`, `127.0.0.0/8` literals, `::1`). A public hostname that +//! resolves to loopback/private stays blocked. //! //! Reference: [IANA IPv4 Special-Purpose Address Registry](https://www.iana.org/assignments/iana-ipv4-special-registry/) -use std::net::IpAddr; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use url::Url; use super::error::WebFetchError; -/// Returns `true` if an IP address is in a private, link-local, or cloud -/// metadata range that should be blocked to prevent SSRF attacks. -/// -/// **Allowed:** loopback (`127.x` / `::1`) for local development. -/// **Blocked:** RFC 1918, link-local, CGNAT/cloud metadata, unspecified. -pub(crate) fn is_blocked_ip(ip: &IpAddr) -> bool { +/// Hostnames/IP literals that may reach loopback when local binding is +/// enabled. Public names that *resolve* to loopback are not included — that +/// closes DNS rebinding through a non-local hostname. +pub(crate) fn is_explicit_local_host(host: &str) -> bool { + let host = host.trim().trim_end_matches('.').to_ascii_lowercase(); + let host = host + .strip_prefix('[') + .and_then(|h| h.strip_suffix(']')) + .unwrap_or(&host); + // Drop IPv6 zone id if present (`fe80::1%lo0`). + let host = host.split('%').next().unwrap_or(host); + + if host == "localhost" { + return true; + } + if let Ok(ip) = host.parse::() { + return ip.is_loopback(); + } + false +} + +/// Returns `true` if an IP is not globally routable and should be treated as +/// local/private for SSRF. +pub(crate) fn is_non_public_ip(ip: IpAddr) -> bool { match ip { - IpAddr::V4(v4) => { - let octets = v4.octets(); - // Loopback (127.0.0.0/8) — allowed for local dev servers. - if octets[0] == 127 { - return false; - } - // RFC 1918: 10.0.0.0/8 — private network. - if octets[0] == 10 { - return true; - } - // RFC 1918: 172.16.0.0/12 — private network. - if octets[0] == 172 && (16..=31).contains(&octets[1]) { - return true; - } - // RFC 1918: 192.168.0.0/16 — private network. - if octets[0] == 192 && octets[1] == 168 { - return true; - } - // RFC 3927: 169.254.0.0/16 — link-local. - // Includes AWS/GCP/Azure metadata endpoint 169.254.169.254. - if octets[0] == 169 && octets[1] == 254 { - return true; - } - // RFC 6598: 100.64.0.0/10 — CGNAT / shared address space. - // Used by some cloud providers for internal metadata services. - if octets[0] == 100 && (64..=127).contains(&octets[1]) { - return true; - } - // 0.0.0.0 — unspecified address. - if v4.is_unspecified() { - return true; - } - false - } - IpAddr::V6(v6) => { - // ::1 — loopback, allowed for local dev. - if v6.is_loopback() { - return false; - } - // :: — unspecified. - if v6.is_unspecified() { - return true; - } - // IPv4-mapped IPv6 (::ffff:x.x.x.x) — delegate to v4 checks. - if let Some(v4) = v6.to_ipv4_mapped() { - return is_blocked_ip(&IpAddr::V4(v4)); - } - let segments = v6.segments(); - // RFC 4291: fe80::/10 — link-local unicast. - if segments[0] & 0xffc0 == 0xfe80 { - return true; - } - // RFC 4193: fc00::/7 — unique local address (ULA). - if segments[0] & 0xfe00 == 0xfc00 { - return true; - } - false - } + IpAddr::V4(v4) => is_non_public_ipv4(v4), + IpAddr::V6(v6) => is_non_public_ipv6(v6), } } +fn is_non_public_ipv4(ip: Ipv4Addr) -> bool { + ip.is_loopback() + || ip.is_private() + || ip.is_link_local() + || ip.is_unspecified() + || ip.is_multicast() + || ip.is_broadcast() + // "This network" (RFC 1122) 0.0.0.0/8 + || ipv4_in_cidr(ip, [0, 0, 0, 0], 8) + // CGNAT (RFC 6598) 100.64.0.0/10 — cloud metadata-ish + || ipv4_in_cidr(ip, [100, 64, 0, 0], 10) + // IETF Protocol Assignments (RFC 6890) 192.0.0.0/24 + || ipv4_in_cidr(ip, [192, 0, 0, 0], 24) + // TEST-NET-1 (RFC 5737) + || ipv4_in_cidr(ip, [192, 0, 2, 0], 24) + // Benchmarking (RFC 2544) + || ipv4_in_cidr(ip, [198, 18, 0, 0], 15) + // TEST-NET-2 / TEST-NET-3 + || ipv4_in_cidr(ip, [198, 51, 100, 0], 24) + || ipv4_in_cidr(ip, [203, 0, 113, 0], 24) + // Reserved (RFC 6890) 240.0.0.0/4 + || ipv4_in_cidr(ip, [240, 0, 0, 0], 4) +} + +fn ipv4_in_cidr(ip: Ipv4Addr, base: [u8; 4], prefix: u8) -> bool { + let ip = u32::from(ip); + let base = u32::from(Ipv4Addr::from(base)); + let mask = if prefix == 0 { + 0 + } else { + u32::MAX << (32 - prefix) + }; + (ip & mask) == (base & mask) +} + +fn is_non_public_ipv6(ip: Ipv6Addr) -> bool { + if let Some(v4) = ip.to_ipv4_mapped() { + return is_non_public_ipv4(v4); + } + // Anything not globally routable: loopback, ULA, link-local, unspecified, multicast. + ip.is_loopback() + || ip.is_unspecified() + || ip.is_multicast() + || ip.is_unique_local() + || ip.is_unicast_link_local() +} + +/// Loopback including IPv4-mapped forms (`::ffff:127.0.0.1`). +/// +/// `IpAddr::is_loopback` is false for mapped addresses even when the embedded +/// v4 is loopback, so local opt-in must use this helper. +fn is_loopback_addr(ip: IpAddr) -> bool { + if ip.is_loopback() { + return true; + } + match ip { + IpAddr::V6(v6) => v6.to_ipv4_mapped().is_some_and(|v4| v4.is_loopback()), + IpAddr::V4(_) => false, + } +} + +/// Whether a resolved address is blocked for this request host. +/// +/// Dual-gate: even with local binding allowed, only explicit loopback hosts +/// may use loopback IPs; private/link-local never open via this flag. +pub(crate) fn is_blocked_for_host(ip: IpAddr, host: &str, allow_local: bool) -> bool { + if !is_non_public_ip(ip) { + return false; + } + if allow_local && is_loopback_addr(ip) && is_explicit_local_host(host) { + return false; + } + true +} + /// Resolve hostname via DNS and verify none of the resolved addresses are -/// in blocked private/link-local ranges. -pub(crate) async fn check_ssrf(url: &Url) -> Result<(), WebFetchError> { +/// blocked under the SSRF policy. +/// +/// `allow_local` comes from tool config (`WebFetchParams::allow_local`); it is +/// not read from the environment here so the agent cannot flip the policy. +pub(crate) async fn check_ssrf(url: &Url, allow_local: bool) -> Result<(), WebFetchError> { let host = url .host_str() .ok_or_else(|| WebFetchError::SingleLabelHost { @@ -90,7 +136,7 @@ pub(crate) async fn check_ssrf(url: &Url) -> Result<(), WebFetchError> { // If the host is already a literal IP, check it directly. if let Ok(ip) = host.parse::() { - if is_blocked_ip(&ip) { + if is_blocked_for_host(ip, host, allow_local) { return Err(WebFetchError::SsrfBlocked { host: host.to_string(), ip, @@ -114,9 +160,12 @@ pub(crate) async fn check_ssrf(url: &Url) -> Result<(), WebFetchError> { return Err(WebFetchError::DnsEmpty(host.to_string())); } + // Any non-public address blocks the request. When allow_local is on, + // only *explicit* loopback hosts may use loopback IPs — a rebinding name + // that resolves to 127.0.0.1 stays blocked. addrs .iter() - .find(|addr| is_blocked_ip(&addr.ip())) + .find(|addr| is_blocked_for_host(addr.ip(), host, allow_local)) .map_or(Ok(()), |addr| { Err(WebFetchError::SsrfBlocked { host: host.to_string(), @@ -133,82 +182,206 @@ mod tests { #[test] fn blocks_rfc1918_10x() { - assert!(is_blocked_ip(&"10.0.0.1".parse().unwrap())); - assert!(is_blocked_ip(&"10.255.255.255".parse().unwrap())); + assert!(is_non_public_ip("10.0.0.1".parse().unwrap())); + assert!(is_blocked_for_host( + "10.0.0.1".parse().unwrap(), + "10.0.0.1", + true + )); } #[test] fn blocks_rfc1918_172x() { - assert!(is_blocked_ip(&"172.16.0.1".parse().unwrap())); - assert!(is_blocked_ip(&"172.31.255.255".parse().unwrap())); - assert!(!is_blocked_ip(&"172.15.0.1".parse().unwrap())); - assert!(!is_blocked_ip(&"172.32.0.1".parse().unwrap())); + assert!(is_non_public_ip("172.16.0.1".parse().unwrap())); + assert!(is_non_public_ip("172.31.255.255".parse().unwrap())); + assert!(!is_non_public_ip("172.15.0.1".parse().unwrap())); + assert!(!is_non_public_ip("172.32.0.1".parse().unwrap())); } #[test] fn blocks_rfc1918_192168() { - assert!(is_blocked_ip(&"192.168.0.1".parse().unwrap())); - assert!(is_blocked_ip(&"192.168.255.255".parse().unwrap())); + assert!(is_non_public_ip("192.168.0.1".parse().unwrap())); + assert!(is_non_public_ip("192.168.255.255".parse().unwrap())); } #[test] fn blocks_link_local() { - assert!(is_blocked_ip(&"169.254.0.1".parse().unwrap())); - assert!(is_blocked_ip(&"169.254.169.254".parse().unwrap())); + assert!(is_non_public_ip("169.254.0.1".parse().unwrap())); + assert!(is_non_public_ip("169.254.169.254".parse().unwrap())); } #[test] fn blocks_cgnat_cloud_metadata() { - assert!(is_blocked_ip(&"100.64.0.1".parse().unwrap())); - assert!(is_blocked_ip(&"100.127.255.255".parse().unwrap())); - assert!(!is_blocked_ip(&"100.63.0.1".parse().unwrap())); - assert!(!is_blocked_ip(&"100.128.0.1".parse().unwrap())); + assert!(is_non_public_ip("100.64.0.1".parse().unwrap())); + assert!(is_non_public_ip("100.127.255.255".parse().unwrap())); + assert!(!is_non_public_ip("100.63.0.1".parse().unwrap())); + assert!(!is_non_public_ip("100.128.0.1".parse().unwrap())); } #[test] fn blocks_unspecified() { - assert!(is_blocked_ip(&"0.0.0.0".parse().unwrap())); - assert!(is_blocked_ip(&"::".parse().unwrap())); + assert!(is_non_public_ip("0.0.0.0".parse().unwrap())); + assert!(is_non_public_ip("::".parse().unwrap())); } #[test] - fn allows_loopback() { - assert!(!is_blocked_ip(&"127.0.0.1".parse().unwrap())); - assert!(!is_blocked_ip(&"127.0.0.2".parse().unwrap())); - assert!(!is_blocked_ip(&"::1".parse().unwrap())); + fn blocks_testnet_reserved_and_this_network() { + // TEST-NET / reserved / this-network special-purpose ranges + assert!(is_non_public_ip("192.0.2.1".parse().unwrap())); + assert!(is_non_public_ip("198.51.100.1".parse().unwrap())); + assert!(is_non_public_ip("203.0.113.1".parse().unwrap())); + assert!(is_non_public_ip("240.0.0.1".parse().unwrap())); + assert!(is_non_public_ip("0.1.2.3".parse().unwrap())); + assert!(is_non_public_ip("198.18.0.1".parse().unwrap())); + } + + #[test] + fn blocks_loopback_by_default() { + assert!(is_blocked_for_host( + "127.0.0.1".parse().unwrap(), + "127.0.0.1", + false + )); + assert!(is_blocked_for_host( + "127.0.0.2".parse().unwrap(), + "127.0.0.2", + false + )); + assert!(is_blocked_for_host("::1".parse().unwrap(), "::1", false)); + assert!(is_blocked_for_host( + "127.0.0.1".parse().unwrap(), + "localhost", + false + )); + } + + #[test] + fn allows_explicit_loopback_when_local_binding_enabled() { + assert!(!is_blocked_for_host( + "127.0.0.1".parse().unwrap(), + "127.0.0.1", + true + )); + assert!(!is_blocked_for_host( + "127.0.0.2".parse().unwrap(), + "127.0.0.2", + true + )); + assert!(!is_blocked_for_host("::1".parse().unwrap(), "::1", true)); + assert!(!is_blocked_for_host( + "127.0.0.1".parse().unwrap(), + "localhost", + true + )); + assert!(!is_blocked_for_host( + "127.0.0.1".parse().unwrap(), + "localhost.", + true + )); + // IPv4-mapped loopback (common dual-stack DNS result for localhost). + assert!(!is_blocked_for_host( + "::ffff:127.0.0.1".parse().unwrap(), + "localhost", + true + )); + assert!(!is_blocked_for_host( + "::ffff:127.0.0.1".parse().unwrap(), + "127.0.0.1", + true + )); + // Metadata / private ranges stay blocked even with the opt-in. + assert!(is_blocked_for_host( + "169.254.169.254".parse().unwrap(), + "169.254.169.254", + true + )); + assert!(is_blocked_for_host( + "10.0.0.1".parse().unwrap(), + "10.0.0.1", + true + )); + // Mapped private is still blocked under local opt-in. + assert!(is_blocked_for_host( + "::ffff:10.0.0.1".parse().unwrap(), + "localhost", + true + )); + } + + #[test] + fn rebinding_hostname_to_loopback_stays_blocked() { + // Hostnames that resolve to local IPs stay blocked even when local + // binding is allowed — only explicit local hosts open loopback. + assert!(is_blocked_for_host( + "127.0.0.1".parse().unwrap(), + "evil.example.com", + true + )); + assert!(is_blocked_for_host( + "127.0.0.1".parse().unwrap(), + "localtest.me", + true + )); + assert!(is_blocked_for_host( + "::1".parse().unwrap(), + "attacker.test", + true + )); + } + + #[test] + fn explicit_local_host_detection() { + assert!(is_explicit_local_host("localhost")); + assert!(is_explicit_local_host("LOCALHOST.")); + assert!(is_explicit_local_host("127.0.0.1")); + assert!(is_explicit_local_host("127.1.2.3")); + assert!(is_explicit_local_host("::1")); + assert!(is_explicit_local_host("[::1]")); + assert!(!is_explicit_local_host("example.com")); + assert!(!is_explicit_local_host("10.0.0.1")); + assert!(!is_explicit_local_host("notlocalhost")); } #[test] fn allows_public_ips() { - assert!(!is_blocked_ip(&"1.1.1.1".parse().unwrap())); - assert!(!is_blocked_ip(&"8.8.8.8".parse().unwrap())); - assert!(!is_blocked_ip(&"142.250.80.46".parse().unwrap())); + assert!(!is_non_public_ip("1.1.1.1".parse().unwrap())); + assert!(!is_non_public_ip("8.8.8.8".parse().unwrap())); + assert!(!is_non_public_ip("142.250.80.46".parse().unwrap())); + assert!(!is_blocked_for_host( + "1.1.1.1".parse().unwrap(), + "1.1.1.1", + false + )); } // ── IPv6 ──────────────────────────────────────────────────────────── #[test] fn blocks_ipv6_link_local() { - assert!(is_blocked_ip(&"fe80::1".parse().unwrap())); + assert!(is_non_public_ip("fe80::1".parse().unwrap())); } #[test] fn blocks_ipv6_unique_local() { - assert!(is_blocked_ip(&"fc00::1".parse().unwrap())); - assert!(is_blocked_ip(&"fd00::1".parse().unwrap())); + assert!(is_non_public_ip("fc00::1".parse().unwrap())); + assert!(is_non_public_ip("fd00::1".parse().unwrap())); } #[test] fn blocks_ipv4_mapped_ipv6_private() { - assert!(is_blocked_ip(&"::ffff:10.0.0.1".parse::().unwrap())); - assert!(is_blocked_ip( - &"::ffff:192.168.1.1".parse::().unwrap() + assert!(is_non_public_ip( + "::ffff:10.0.0.1".parse::().unwrap() + )); + assert!(is_non_public_ip( + "::ffff:192.168.1.1".parse::().unwrap() )); } #[test] fn allows_ipv4_mapped_ipv6_public() { - assert!(!is_blocked_ip(&"::ffff:8.8.8.8".parse::().unwrap())); + assert!(!is_non_public_ip( + "::ffff:8.8.8.8".parse::().unwrap() + )); } // ── check_ssrf integration ────────────────────────────────────────── @@ -216,15 +389,28 @@ mod tests { #[tokio::test] async fn ssrf_blocks_ip_literal_private() { let url = Url::parse("https://10.0.0.1/secret").unwrap(); - let result = check_ssrf(&url).await; + let result = check_ssrf(&url, false).await; assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("private")); } + #[tokio::test] + async fn ssrf_blocks_loopback_literal_by_default() { + let url = Url::parse("http://127.0.0.1:8080/").unwrap(); + let result = check_ssrf(&url, false).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn ssrf_allows_loopback_literal_when_opted_in() { + let url = Url::parse("http://127.0.0.1:8080/").unwrap(); + assert!(check_ssrf(&url, true).await.is_ok()); + } + #[tokio::test] async fn ssrf_allows_ip_literal_public() { let url = Url::parse("https://1.1.1.1/").unwrap(); - let result = check_ssrf(&url).await; + let result = check_ssrf(&url, false).await; assert!(result.is_ok()); } } diff --git a/crates/codegen/xai-grok-tools/src/implementations/grok_build_hashline/grep.rs b/crates/codegen/xai-grok-tools/src/implementations/grok_build_hashline/grep.rs index afc843a..c0897d9 100644 --- a/crates/codegen/xai-grok-tools/src/implementations/grok_build_hashline/grep.rs +++ b/crates/codegen/xai-grok-tools/src/implementations/grok_build_hashline/grep.rs @@ -146,10 +146,10 @@ Content output format: {grep_context} ← context (-) Usage: -- Pattern is a regex: `log.*Error`, `function\s+\w+`, `TODO` +- ${{ params.search.pattern }} is a regex: `log.*Error`, `function\s+\w+`, `TODO` - Output modes: "content" (default, with anchors), "files_with_matches", "count" - Use -A, -B, -C for context lines around matches -- Only use 'type' or 'glob' when certain of the file type +- Only use '${{ params.search.type }}' or '${{ params.search.glob }}' when certain of the file type - Results are capped; truncated results show "at least" counts"#; /// `hashline_grep` — searches with anchor-annotated results. diff --git a/crates/codegen/xai-grok-tools/src/implementations/grok_build_hashline/read_file.rs b/crates/codegen/xai-grok-tools/src/implementations/grok_build_hashline/read_file.rs index 153c579..860a900 100644 --- a/crates/codegen/xai-grok-tools/src/implementations/grok_build_hashline/read_file.rs +++ b/crates/codegen/xai-grok-tools/src/implementations/grok_build_hashline/read_file.rs @@ -84,7 +84,7 @@ Anchors are valid only for the file state at read time — after any edit, use the fresh anchors returned by ${{ tools.by_kind.edit }} or re-read the file.${%- endif %} Usage: -- The file_path parameter must be an absolute path, not a relative path +- The ${{ params.read.target_file }} parameter must be an absolute path, not a relative path - By default reads up to {max_lines_read} lines from the beginning - Optionally specify offset and limit for large files - Can read images (PNG, JPG, etc.) and PDF files (each page rendered as an image; use `pages` parameter for PDFs with more than 10 pages, max 20 per call) diff --git a/crates/codegen/xai-grok-tools/src/implementations/opencode/bash/mod.rs b/crates/codegen/xai-grok-tools/src/implementations/opencode/bash/mod.rs index aae88dd..3ba86db 100644 --- a/crates/codegen/xai-grok-tools/src/implementations/opencode/bash/mod.rs +++ b/crates/codegen/xai-grok-tools/src/implementations/opencode/bash/mod.rs @@ -65,7 +65,7 @@ Before executing the command, please follow these steps: Usage notes: - The command argument is required. - - You can specify an optional timeout in milliseconds. If not specified, commands will use the default timeout. + - You can specify an optional ${{ params.execute.timeout }} in milliseconds. If not specified, commands will use the default timeout. - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - If the output exceeds {max_output_bytes} characters, output will be truncated before being returned to you. ${%- if tools.by_kind.list or tools.by_kind.search or tools.by_kind.read or tools.by_kind.edit or tools.by_kind.write %} @@ -572,6 +572,31 @@ mod tests { resources } + #[test] + fn description_template_tracks_renamed_timeout() { + use crate::types::template_renderer::TemplateRenderer; + use crate::types::tool::ToolKind; + use crate::types::tool_metadata::ToolMetadata; + use std::collections::HashMap; + + let tools = HashMap::from([(ToolKind::Execute, "bash".to_string())]); + let params = HashMap::from([( + ToolKind::Execute, + HashMap::from([("timeout".to_string(), "max_wait".to_string())]), + )]); + let rendered = TemplateRenderer::new(tools, params) + .render(ToolMetadata::description_template(&BashTool)) + .unwrap(); + assert!( + rendered.contains("optional max_wait in milliseconds"), + "renamed timeout must appear:\n{rendered}" + ); + assert!( + !rendered.contains("optional timeout in milliseconds"), + "canonical timeout must not remain after rename:\n{rendered}" + ); + } + fn make_input(command: &str) -> BashInput { BashInput { command: command.to_string(), @@ -1106,8 +1131,12 @@ mod tests { (ToolKind::Read, "read_file".to_string()), (ToolKind::Edit, "search_replace".to_string()), (ToolKind::Write, "write".to_string()), + (ToolKind::Execute, "bash".to_string()), ]), - HashMap::new(), + HashMap::from([( + ToolKind::Execute, + HashMap::from([("timeout".to_string(), "timeout".to_string())]), + )]), ) } diff --git a/crates/codegen/xai-grok-tools/src/implementations/opencode/edit/mod.rs b/crates/codegen/xai-grok-tools/src/implementations/opencode/edit/mod.rs index c7d0da4..3da4f08 100644 --- a/crates/codegen/xai-grok-tools/src/implementations/opencode/edit/mod.rs +++ b/crates/codegen/xai-grok-tools/src/implementations/opencode/edit/mod.rs @@ -42,15 +42,19 @@ use crate::types::tool::{ToolKind, ToolNamespace}; // Description // ─────────────────────────────────────────────────────────────────────────── +// NOTE: OpenCode's `EditInput` serializes camelCase (`oldString`, `newString`, +// `replaceAll`), so param refs must use the camelCase schema property names — +// the snake_case `params.edit.old_string` keys of the grok_build twin resolve +// to "" here (the kind-params map is keyed by schema property names). const DESCRIPTION: &str = r#"Performs exact string replacements in files. Usage: - You must use your `${{ tools.by_kind.read }}` tool at least once in the conversation before editing. -- When editing text from ${{ tools.by_kind.read }} tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: line number + →. Everything after that → separator is the actual file content to match. Never include any part of the line number prefix in the ${{ params.edit.old_string }} or ${{ params.edit.new_string }}. +- When editing text from ${{ tools.by_kind.read }} tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: line number + →. Everything after that → separator is the actual file content to match. Never include any part of the line number prefix in the ${{ params.edit.oldString }} or ${{ params.edit.newString }}. - ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. -- The edit will FAIL if `${{ params.edit.old_string }}` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use `${{ params.edit.replace_all }}` to change every instance of `${{ params.edit.old_string }}`. -- Use `${{ params.edit.replace_all }}` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance. -- To create a new file, set ${{ params.edit.old_string }} to an empty string. +- The edit will FAIL if `${{ params.edit.oldString }}` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use `${{ params.edit.replaceAll }}` to change every instance of `${{ params.edit.oldString }}`. +- Use `${{ params.edit.replaceAll }}` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance. +- To create a new file, set ${{ params.edit.oldString }} to an empty string. - Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked."#; // ─────────────────────────────────────────────────────────────────────────── @@ -70,7 +74,9 @@ pub struct EditInput { pub old_string: String, /// The replacement text (must differ from old_string). - #[schemars(description = "The text to replace it with (must be different from old_string)")] + #[schemars( + description = "The text to replace it with (must be different from ${{ params.edit.oldString }})" + )] pub new_string: String, /// When true, replace every occurrence of `old_string` (default false). @@ -78,7 +84,9 @@ pub struct EditInput { default, deserialize_with = "crate::types::schema::deserialize_lenient_option_bool" )] - #[schemars(description = "Replace all occurrences of old_string (default false)")] + #[schemars( + description = "Replace all occurrences of ${{ params.edit.oldString }} (default false)" + )] pub replace_all: Option, } @@ -373,7 +381,7 @@ async fn handle_replacement( if positions.len() > 1 && !replace_all { let replace_all_name = crate::types::template_renderer::TemplateRenderer::resolve( &resources, - "${{ params.edit.replace_all }}", + "${{ params.edit.replaceAll }}", ) .await?; return Ok(SearchReplaceOutput::MultipleMatchesFound(format!( @@ -493,10 +501,12 @@ mod tests { resources.insert(FileSystem(Arc::new(LocalFs))); resources.insert(NotificationHandle(ToolNotificationHandle::noop())); + // Keys mirror finalize-time seeding: schema property names, which are + // camelCase for OpenCode's EditInput. let edit_params = std::collections::HashMap::from([ - ("old_string".to_string(), "old_string".to_string()), - ("new_string".to_string(), "new_string".to_string()), - ("replace_all".to_string(), "replaceAll".to_string()), + ("oldString".to_string(), "oldString".to_string()), + ("newString".to_string(), "newString".to_string()), + ("replaceAll".to_string(), "replaceAll".to_string()), ]); resources.insert(TemplateRenderer::new( std::collections::HashMap::from([(ToolKind::Read, "read_file".to_string())]), @@ -765,8 +775,10 @@ mod tests { std::collections::HashMap::from([(ToolKind::Read, "file_reader".to_string())]), std::collections::HashMap::from([( ToolKind::Edit, + // Keyed by the camelCase schema property name (finalize seeds + // kind params from schema properties). std::collections::HashMap::from([( - "replace_all".to_string(), + "replaceAll".to_string(), "replaceEverything".to_string(), )]), )]), diff --git a/crates/codegen/xai-grok-tools/src/implementations/opencode/grep/mod.rs b/crates/codegen/xai-grok-tools/src/implementations/opencode/grep/mod.rs index cd1cdd2..e7a8d66 100644 --- a/crates/codegen/xai-grok-tools/src/implementations/opencode/grep/mod.rs +++ b/crates/codegen/xai-grok-tools/src/implementations/opencode/grep/mod.rs @@ -34,10 +34,10 @@ Usage: - Prefer ${{ tools.by_kind.search }} for exact symbol/string searches. Whenever possible, use this instead of terminal grep/rg. This tool is faster and respects .gitignore - Supports full regex syntax, e.g. `log.*Error`, `function\s+\w+`. Ensure you escape special chars to get exact matches, e.g. `functionCall\(` - Avoid overly broad glob patterns (e.g., '--glob *') as they bypass .gitignore rules and may be slow -- The pattern field is a raw regex string: do NOT wrap it in quotes or add trailing quote characters unnecessarily -- Only use 'include' when certain of the file type needed. Note: import paths may not match source file types (.js vs .ts) +- The ${{ params.search.pattern }} field is a raw regex string: do NOT wrap it in quotes or add trailing quote characters unnecessarily +- Only use '${{ params.search.include }}' when certain of the file type needed. Note: import paths may not match source file types (.js vs .ts) - Results are capped for responsiveness; truncated results show "at least" counts. -- Filter files by pattern with the include parameter (e.g. "*.js", "*.{ts,tsx}") +- Filter files by pattern with the ${{ params.search.include }} parameter (e.g. "*.js", "*.{ts,tsx}") - Returns file paths and line numbers with at least one match sorted by modification time - Use this tool when you need to find files containing specific patterns"#; diff --git a/crates/codegen/xai-grok-tools/src/implementations/opencode/read/mod.rs b/crates/codegen/xai-grok-tools/src/implementations/opencode/read/mod.rs index 36b7c3e..0fc36cc 100644 --- a/crates/codegen/xai-grok-tools/src/implementations/opencode/read/mod.rs +++ b/crates/codegen/xai-grok-tools/src/implementations/opencode/read/mod.rs @@ -38,7 +38,7 @@ const DESCRIPTION: &str = r#"Reads a file from the local filesystem. You can acc Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned. Usage: -- The file_path parameter must be an absolute path, not a relative path +- The ${{ params.read.filePath }} parameter must be an absolute path, not a relative path - By default, it reads up to {max_lines_read} lines starting from the beginning of the file - You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters - Any lines longer than {max_chars_per_line} characters will be truncated diff --git a/crates/codegen/xai-grok-tools/src/reminders/task_completion.rs b/crates/codegen/xai-grok-tools/src/reminders/task_completion.rs index fb50c81..a53452f 100644 --- a/crates/codegen/xai-grok-tools/src/reminders/task_completion.rs +++ b/crates/codegen/xai-grok-tools/src/reminders/task_completion.rs @@ -21,7 +21,7 @@ use crate::types::output::ToolOutput; use crate::types::resources::{SharedResources, State, Terminal}; use crate::types::tool::{Reminder, ToolKind}; use crate::util::truncate::{PREVIEW_SIZE, truncate_with_preview}; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use xai_tool_types::KillTaskOutput; use xai_tool_types::SubagentCompletedOutput; @@ -33,56 +33,55 @@ pub const DEFAULT_TASK_OUTPUT_TOOL: &str = "get_task_output"; /// disk-backed output file) are never truncated -- the inline branch is /// their only chance to see the output. const MAX_INLINE_COMPLETION_BYTES: usize = 4_000; -/// Shared set of IDs that have already been delivered via auto-wake synthetic -/// prompts. `TaskCompletionReminder` drains this set on each reminder pass -/// and extends its suppress list, preventing duplicate reminders for -/// completions that already triggered an auto-wake turn. #[derive(Clone, Debug, Default)] -pub struct AutoWakeDeliveredIds(pub Arc>>); -impl AutoWakeDeliveredIds { - /// Insert an ID into the delivered set. - pub fn insert(&self, id: String) { - self.0.lock().unwrap_or_else(|e| e.into_inner()).insert(id); +pub struct TaskCompletionReservations(pub Arc>>); +impl TaskCompletionReservations { + pub fn reserve(&self, id: String) { + let mut ids = self.0.lock().unwrap_or_else(|e| e.into_inner()); + *ids.entry(id).or_default() += 1; } - /// Remove a single ID from the delivered set (e.g. when a synthetic - /// prompt is preempted or cancelled before being processed). - pub fn remove(&self, id: &str) { - self.0.lock().unwrap_or_else(|e| e.into_inner()).remove(id); + pub fn release(&self, id: &str) { + let mut ids = self.0.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(count) = ids.get_mut(id) { + if *count > 1 { + *count -= 1; + } else { + ids.remove(id); + } + } } - /// Return `true` if `id` is currently marked as delivered, without - /// draining the set. Preferred over [`snapshot`](Self::snapshot) for a - /// single-membership check on a hot path (e.g. per monitor stdout event): - /// it avoids cloning every ID into a `Vec`. pub fn contains(&self, id: &str) -> bool { self.0 .lock() .unwrap_or_else(|e| e.into_inner()) - .contains(id) + .contains_key(id) } - /// Drain all IDs from the set, returning them. - pub fn drain(&self) -> Vec { - let mut guard = self.0.lock().unwrap_or_else(|e| e.into_inner()); - guard.drain().collect() - } - /// Return a snapshot of the currently-marked IDs **without** draining them. - /// - /// Used by the between-turn completion drain in `xai-grok-shell` to - /// suppress completions already delivered via auto-wake synthetic prompts. - /// Unlike [`drain`](Self::drain) (the per-tool-call surface's consumption - /// point), this is read-only so the existing drain/un-mark lifecycle — - /// `TaskCompletionReminder` draining on each tool call and the - /// preempt/cancel paths un-marking dropped synthetic prompts — stays the - /// single source of truth for the set's contents. pub fn snapshot(&self) -> Vec { self.0 .lock() .unwrap_or_else(|e| e.into_inner()) - .iter() + .keys() .cloned() .collect() } } -crate::register_resource!("grok_build", "AutoWakeDeliveredIds", AutoWakeDeliveredIds); +crate::register_resource!( + "grok_build", + "TaskCompletionReservations", + TaskCompletionReservations +); +#[derive(Clone, Debug, Default)] +pub struct TaskWakeSuppressed(pub Arc); +impl TaskWakeSuppressed { + pub fn set(&self, suppressed: bool) { + self.0 + .store(suppressed, std::sync::atomic::Ordering::Release); + } + pub fn get(&self) -> bool { + self.0.load(std::sync::atomic::Ordering::Acquire) + } +} +crate::register_resource!("grok_build", "TaskWakeSuppressed", TaskWakeSuppressed); /// Set of task IDs whose completion has already been surfaced as a /// ``. Persisted via `State` so it survives across /// tool calls within a session. @@ -621,16 +620,28 @@ impl Reminder for TaskCompletionReminder { resources: SharedResources, tool_output: &ToolOutput, ) -> Vec { - let mut suppress: Vec = consumed_completion_ids(tool_output) + let consumed_ids: Vec = consumed_completion_ids(tool_output) .into_iter() .map(str::to_string) .collect(); - { + let reserved_ids = { let res = resources.lock().await; - if let Some(auto_wake) = res.get::() { - suppress.extend(auto_wake.drain()); + if res + .get::() + .is_some_and(TaskWakeSuppressed::get) + { + tracing::debug!("task wake reminder suppressed"); + return Vec::new(); } - } + res.get::() + .map(TaskCompletionReservations::snapshot) + .unwrap_or_default() + }; + let suppress_ids = consumed_ids + .iter() + .chain(&reserved_ids) + .cloned() + .collect::>(); let (terminal, event_sender) = { let res = resources.lock().await; ( @@ -673,7 +684,7 @@ impl Reminder for TaskCompletionReminder { .map(str::to_string) }); let state = res.get_or_default::>(); - for id in &suppress { + for id in &consumed_ids { state.reported.insert(id.clone()); } if surface_reminders { @@ -681,7 +692,9 @@ impl Reminder for TaskCompletionReminder { tasks .iter() .filter(|task| { - task.completed && state.reported.insert(task.task_id.clone()) + task.completed + && !reserved_ids.contains(&task.task_id) + && state.reported.insert(task.task_id.clone()) }) .map(|task| { format_bash_completion( @@ -693,7 +706,7 @@ impl Reminder for TaskCompletionReminder { ); } else { for task in &tasks { - if task.completed { + if task.completed && !reserved_ids.contains(&task.task_id) { state.reported.insert(task.task_id.clone()); } } @@ -716,7 +729,7 @@ impl Reminder for TaskCompletionReminder { if sender .0 .send(SubagentEvent::Completions(SubagentCompletionsRequest { - suppress_ids: suppress, + suppress_ids, respond_to: tx, })) .is_err() @@ -1197,6 +1210,14 @@ mod tests { res.register_state::(); res.into_shared() } + fn shared_with_gate(tasks: Vec, gate: TaskWakeSuppressed) -> SharedResources { + let mut res = Resources::new(); + let backend: Arc = Arc::new(MockTerminal { tasks }); + res.insert(Terminal(backend)); + res.insert(gate); + res.register_state::(); + res.into_shared() + } /// Like `shared_with` but inserts `BashParams` with /// `surface_bg_completion_reminders = false` so the /// reminder is suppressed. @@ -1301,11 +1322,46 @@ mod tests { truncation_hint: String::new(), raw_output_bytes: 4, })); - let r = reminder.collect_reminders(shared, &output).await; + let r = reminder.collect_reminders(shared.clone(), &output).await; assert!( r.is_empty(), "get_task_output(completed) should suppress reminder" ); + assert!( + shared + .lock() + .await + .get::>() + .expect("reported state") + .reported + .contains("t1") + ); + } + #[tokio::test] + async fn ctrl_c_gate_suppresses_visible_completion_without_reporting_it() { + let gate = TaskWakeSuppressed::default(); + gate.set(true); + let shared = shared_with_gate(vec![make_completed("visible")], gate.clone()); + let output = ToolOutput::Dynamic(serde_json::Value::Null.into()); + assert!( + TaskCompletionReminder + .collect_reminders(shared.clone(), &output) + .await + .is_empty() + ); + assert!( + shared + .lock() + .await + .get::>() + .is_none_or(|state| !state.reported.contains("visible")) + ); + gate.set(false); + let reminders = TaskCompletionReminder + .collect_reminders(shared, &output) + .await; + assert_eq!(reminders.len(), 1); + assert!(reminders[0].contains("visible")); } #[tokio::test] async fn not_suppressed_for_unrelated_output() { @@ -1608,56 +1664,99 @@ mod tests { ); } #[test] - fn auto_wake_delivered_ids_insert_and_drain() { - let ids = AutoWakeDeliveredIds::default(); - ids.insert("t1".into()); - ids.insert("t2".into()); - let drained = ids.drain(); - assert_eq!(drained.len(), 2); - assert!(drained.contains(&"t1".to_string())); - assert!(drained.contains(&"t2".to_string())); - assert!(ids.drain().is_empty()); + fn task_completion_reservations_are_reference_counted() { + let reservations = TaskCompletionReservations::default(); + reservations.reserve("t1".into()); + reservations.reserve("t1".into()); + reservations.release("t1"); + assert!(reservations.contains("t1")); + reservations.release("t1"); + assert!(!reservations.contains("t1")); } #[test] - fn auto_wake_delivered_ids_dedup() { - let ids = AutoWakeDeliveredIds::default(); - ids.insert("t1".into()); - ids.insert("t1".into()); - let drained = ids.drain(); - assert_eq!(drained.len(), 1); - } - #[test] - fn auto_wake_delivered_ids_snapshot_is_non_destructive() { - let ids = AutoWakeDeliveredIds::default(); - ids.insert("t1".into()); - ids.insert("t2".into()); - let snap = ids.snapshot(); - assert_eq!(snap.len(), 2); - assert!(snap.contains(&"t1".to_string())); - assert!(snap.contains(&"t2".to_string())); - assert_eq!(ids.drain().len(), 2); + fn task_completion_reservations_snapshot_is_non_destructive() { + let reservations = TaskCompletionReservations::default(); + reservations.reserve("t1".into()); + reservations.reserve("t2".into()); + let snapshot = reservations.snapshot(); + assert_eq!(snapshot.len(), 2); + assert!(snapshot.contains(&"t1".to_string())); + assert!(snapshot.contains(&"t2".to_string())); + assert!(reservations.contains("t1")); + assert!(reservations.contains("t2")); } #[tokio::test] - async fn auto_wake_delivered_ids_suppress_reminders() { + async fn task_completion_reservations_suppress_reminders() { let mut res = Resources::new(); let backend: Arc = Arc::new(MockTerminal { tasks: vec![make_completed("t1"), make_completed("t2")], }); res.insert(Terminal(backend)); res.register_state::(); - let auto_wake = AutoWakeDeliveredIds::default(); - auto_wake.insert("t1".into()); - res.insert(auto_wake); + let reservations = TaskCompletionReservations::default(); + reservations.reserve("t1".into()); + res.insert(reservations); let shared = res.into_shared(); let reminder = TaskCompletionReminder; let output = ToolOutput::Dynamic(serde_json::Value::Null.into()); - let r = reminder.collect_reminders(shared, &output).await; - assert_eq!( - r.len(), - 1, - "auto-wake delivered ID should suppress reminder" - ); + let r = reminder.collect_reminders(shared.clone(), &output).await; + assert_eq!(r.len(), 1, "reserved ID should suppress reminder"); assert!(r[0].contains("t2")); + let res = shared.lock().await; + assert!( + res.get::() + .is_some_and(|ids| ids.contains("t1")) + ); + assert!( + !res.get::>() + .expect("reported state") + .reported + .contains("t1") + ); + } + #[tokio::test] + async fn reserved_completion_surfaces_after_release() { + let mut res = Resources::new(); + let backend: Arc = Arc::new(MockTerminal { + tasks: vec![make_completed("reserved")], + }); + res.insert(Terminal(backend)); + res.register_state::(); + let reservations = TaskCompletionReservations::default(); + reservations.reserve("reserved".into()); + res.insert(reservations.clone()); + let shared = res.into_shared(); + let reminder = TaskCompletionReminder; + let output = ToolOutput::Dynamic(serde_json::Value::Null.into()); + assert!( + reminder + .collect_reminders(shared.clone(), &output) + .await + .is_empty() + ); + assert!(reservations.contains("reserved")); + assert!( + !shared + .lock() + .await + .get::>() + .expect("reported state") + .reported + .contains("reserved") + ); + reservations.release("reserved"); + let reminders = reminder.collect_reminders(shared.clone(), &output).await; + assert_eq!(reminders.len(), 1); + assert!(reminders[0].contains("reserved")); + assert!( + shared + .lock() + .await + .get::>() + .expect("reported state") + .reported + .contains("reserved") + ); } /// Regression: subagent inline output larger than the bash-completion /// inline cap MUST be preserved verbatim. The inline branch is the diff --git a/crates/codegen/xai-grok-tools/src/types/resources.rs b/crates/codegen/xai-grok-tools/src/types/resources.rs index 2101574..dcd3557 100644 --- a/crates/codegen/xai-grok-tools/src/types/resources.rs +++ b/crates/codegen/xai-grok-tools/src/types/resources.rs @@ -1417,6 +1417,17 @@ mod tests { std::path::PathBuf::from("/worktree/abc/src/main.rs") ); } + #[test] + fn resolve_model_path_sensitive_edit_spellings() { + let cwd = std::path::Path::new("/worktree/abc"); + for input in [" /etc/hosts ", "\"/etc/hosts\\n\"", "'/etc/hosts\\r\\t'"] { + assert_eq!( + super::resolve_model_path(cwd, None, input), + std::path::PathBuf::from("/etc/hosts"), + "{input:?}" + ); + } + } /// An *unquoted* path keeps its backslashes: `\n` there may be a real /// path component (e.g. a Windows-style separator + dir named `n`). #[test] diff --git a/crates/codegen/xai-grok-version/Cargo.toml b/crates/codegen/xai-grok-version/Cargo.toml index 15e55eb..be1e940 100644 --- a/crates/codegen/xai-grok-version/Cargo.toml +++ b/crates/codegen/xai-grok-version/Cargo.toml @@ -1,7 +1,7 @@ [package] license = "Apache-2.0" name = "xai-grok-version" -version = "0.2.102" +version = "0.2.105" edition.workspace = true description = "Lockstepped grok CLI version." diff --git a/crates/codegen/xai-grok-workspace/src/bin/workspace_server.rs b/crates/codegen/xai-grok-workspace/src/bin/workspace_server.rs index 6ce9ff2..edf2537 100644 --- a/crates/codegen/xai-grok-workspace/src/bin/workspace_server.rs +++ b/crates/codegen/xai-grok-workspace/src/bin/workspace_server.rs @@ -81,6 +81,15 @@ struct Args { /// instead of widening to the built-in default catalog. #[arg(long)] require_explicit_toolset: bool, + /// Trust project-scoped LSP servers from `/.grok/lsp.json`. + /// Defaults off; sandbox opts in only after workspace trust is established. + #[arg( + long, + env = "GROK_WORKSPACE_PROJECT_LSP_TRUSTED", + default_value_t = false, + action = clap::ArgAction::Set, + )] + project_lsp_trusted: bool, /// Confine `x.ai/fs/*` resolution to the workspace root (reject `..`, /// absolute-outside-root, symlink escapes). On by default: the standalone /// server always backs a remote-sandbox workspace, a real tenant boundary. @@ -338,7 +347,6 @@ async fn run(args: Args, cwd: PathBuf) -> anyhow::Result<()> { } else { None }; - let project_lsp_trusted = true; let preview_scrape_interval = status_config.preview_activity_scrape_interval; xai_grok_workspace::init_metrics(); let ws_handle = xai_grok_workspace::handle::connect_local_workspace( @@ -351,7 +359,7 @@ async fn run(args: Args, cwd: PathBuf) -> anyhow::Result<()> { args.allow_insecure_ws, status_config, args.upload_queue_enabled, - project_lsp_trusted, + args.project_lsp_trusted, Some(diag_handle.clone()), args.require_explicit_toolset, args.confine_fs_to_workspace_root, @@ -448,6 +456,15 @@ mod tests { assert!(args.capabilities); } #[test] + fn project_lsp_trust_defaults_off_and_is_opt_in() { + unsafe { std::env::remove_var("GROK_WORKSPACE_PROJECT_LSP_TRUSTED") }; + let args = Args::try_parse_from(["xai-workspace-server"]).unwrap(); + assert!(!args.project_lsp_trusted); + let args = Args::try_parse_from(["xai-workspace-server", "--project-lsp-trusted", "true"]) + .unwrap(); + assert!(args.project_lsp_trusted); + } + #[test] fn capabilities_manifest_shape() { let value = serde_json::to_value(CAPABILITIES).unwrap(); assert_eq!(value, serde_json::json!({ "diag" : true })); diff --git a/crates/codegen/xai-grok-workspace/src/discovery.rs b/crates/codegen/xai-grok-workspace/src/discovery.rs index 247f802..c20567f 100644 --- a/crates/codegen/xai-grok-workspace/src/discovery.rs +++ b/crates/codegen/xai-grok-workspace/src/discovery.rs @@ -77,17 +77,6 @@ pub async fn discover_agents_md(root_cwd: &Path) -> Vec { files .into_iter() - .map(|mut file| { - // Strip rules-file YAML frontmatter so it does not leak as raw YAML (matches grok-build render). - if file.file_path.contains("/.grok/rules/") - || file.file_path.contains("/.claude/rules/") - { - file.content = xai_grok_tools::implementations::skills::skill::extract_skill_body( - &file.content, - ); - } - file - }) .filter_map(|file| match serde_json::to_value(&file) { Ok(v) => Some(v), Err(e) => { @@ -362,9 +351,9 @@ mod tests { // Discovery also scans the real `~/.grok`, so fixtures use test-unique names. #[tokio::test] - async fn discover_agents_md_strips_rules_frontmatter() { + async fn discover_agents_md_receives_normalized_rule_content() { let tmp = tempfile::tempdir().unwrap(); - let rules_dir = tmp.path().join(".grok").join("rules"); + let rules_dir = tmp.path().join(".cursor").join("rules"); fs::create_dir_all(&rules_dir).unwrap(); fs::write( rules_dir.join("xyzzy-discover-agents-md-test.md"), @@ -378,7 +367,7 @@ mod tests { .find(|f| { f["file_path"] .as_str() - .is_some_and(|p| p.ends_with("/.grok/rules/xyzzy-discover-agents-md-test.md")) + .is_some_and(|p| p.ends_with("/.cursor/rules/xyzzy-discover-agents-md-test.md")) }) .expect("should discover the rules file"); let content = rule["content"].as_str().unwrap(); diff --git a/crates/codegen/xai-grok-workspace/src/folder_trust.rs b/crates/codegen/xai-grok-workspace/src/folder_trust.rs index 0c2b92d..29de81d 100644 --- a/crates/codegen/xai-grok-workspace/src/folder_trust.rs +++ b/crates/codegen/xai-grok-workspace/src/folder_trust.rs @@ -233,8 +233,8 @@ pub fn persist_trust(store: &mut TrustStore, key: &Path) { } } -/// Whether any repo-local code-exec config is present for `cwd`. When none are -/// present there is nothing to gate, so we skip the prompt entirely. +/// Whether any repo-local trust-sensitive config is present for `cwd`. When none +/// are present there is nothing to gate, so we skip the prompt entirely. /// /// Thin wrapper over [`collect_repo_config_kinds`] with `first_only = true`, so /// the gate and the display-only [`repo_config_kinds`] enumerate the EXACT same @@ -244,12 +244,13 @@ pub fn repo_configs_present(cwd: &Path) -> bool { !collect_repo_config_kinds(cwd, true).is_empty() } -/// Display-only: which repo-local code-exec config KINDS are present for `cwd` -/// (`mcp`, `plugins`, `lsp`, `envrc`, `claude`, `hooks`, `agents`), deduped in -/// cheap→expensive marker order. Single source with [`repo_configs_present`] -/// (which is `!repo_config_kinds(cwd).is_empty()`), so a folder that the gate -/// fired on always has a non-empty, accurate kind list — no `[plugins].paths` / -/// `.claude` / `.grok/agents` / subdir-launch gaps. NOT itself the trust gate. +/// Display-only: which repo-local trust-sensitive config KINDS are present for +/// `cwd` (`mcp`, `plugins`, `lsp`, `envrc`, `claude`, `hooks`, `agents`, `roles`, +/// `personas`), deduped in cheap→expensive marker order. Single source with +/// [`repo_configs_present`] (which is `!repo_config_kinds(cwd).is_empty()`), so a +/// folder that the gate fired on always has a non-empty, accurate kind list — no +/// `[plugins].paths` / `.claude` / `.grok/agents` / subdir-launch gaps. NOT +/// itself the trust gate. pub fn repo_config_kinds(cwd: &Path) -> Vec<&'static str> { collect_repo_config_kinds(cwd, false) } @@ -262,6 +263,14 @@ fn path_present_or_uncertain(path: &Path) -> bool { } } +fn directory_present_or_uncertain(path: &Path) -> bool { + match std::fs::metadata(path) { + Ok(metadata) => metadata.is_dir(), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, + Err(_) => true, + } +} + /// Shared scanner behind [`repo_configs_present`] and [`repo_config_kinds`]. With /// `first_only` it returns immediately after the first marker (the gate's /// historical short-circuit); otherwise it collects every distinct kind. @@ -376,6 +385,14 @@ fn collect_repo_config_kinds(cwd: &Path, first_only: bool) -> Vec<&'static str> if !xai_grok_agent::discovery::project_agent_dirs_in(&chain.dirs).is_empty() { hit!("agents"); } + // Presence matches exact-cwd discovery without parsing repository content. + let grok = cwd.join(".grok"); + if directory_present_or_uncertain(&grok.join("roles")) { + hit!("roles"); + } + if directory_present_or_uncertain(&grok.join("personas")) { + hit!("personas"); + } // `~/.claude.json` `projects..mcpServers`. if claude_project_mcp_present(cwd) { hit!("mcp"); @@ -595,6 +612,64 @@ mod tests { assert!(repo_configs_present(&subdir)); } + #[test] + fn repo_configs_present_detects_project_roles() { + let tmp = repo_tmp(); + std::fs::create_dir_all(tmp.path().join(".grok").join("roles")).unwrap(); + + assert!(repo_configs_present(tmp.path())); + assert!(repo_config_kinds(tmp.path()).contains(&"roles")); + } + + #[test] + fn repo_configs_present_detects_project_personas() { + let tmp = repo_tmp(); + std::fs::create_dir_all(tmp.path().join(".grok").join("personas")).unwrap(); + + assert!(repo_configs_present(tmp.path())); + assert!(repo_config_kinds(tmp.path()).contains(&"personas")); + } + + #[test] + fn project_subagent_marker_regular_file_is_absent() { + let tmp = repo_tmp(); + let grok = tmp.path().join(".grok"); + std::fs::create_dir_all(&grok).unwrap(); + std::fs::write(grok.join("roles"), "not a directory").unwrap(); + assert!(!repo_configs_present(tmp.path())); + } + + #[test] + fn project_subagent_marker_at_repo_root_is_absent_from_subdir() { + let tmp = repo_tmp(); + std::fs::create_dir_all(tmp.path().join(".grok/roles")).unwrap(); + let subdir = tmp.path().join("nested"); + std::fs::create_dir_all(&subdir).unwrap(); + assert!(!repo_configs_present(&subdir)); + } + + #[cfg(unix)] + #[test] + fn project_subagent_marker_symlink_to_directory_is_present() { + let tmp = repo_tmp(); + let target = tmp.path().join("target-roles"); + let grok = tmp.path().join(".grok"); + std::fs::create_dir_all(&target).unwrap(); + std::fs::create_dir_all(&grok).unwrap(); + std::os::unix::fs::symlink(&target, grok.join("roles")).unwrap(); + assert!(repo_configs_present(tmp.path())); + } + + #[cfg(unix)] + #[test] + fn dangling_project_subagent_marker_is_absent() { + let tmp = repo_tmp(); + let grok = tmp.path().join(".grok"); + std::fs::create_dir_all(&grok).unwrap(); + std::os::unix::fs::symlink("missing", grok.join("personas")).unwrap(); + assert!(!repo_configs_present(tmp.path())); + } + #[test] fn repo_configs_present_detects_claude_settings_from_subdir() { // A `.claude/settings.json` `env` in a SUBDIR (no other repo config), diff --git a/crates/codegen/xai-grok-workspace/src/handle.rs b/crates/codegen/xai-grok-workspace/src/handle.rs index 4cf69d3..b844da1 100644 --- a/crates/codegen/xai-grok-workspace/src/handle.rs +++ b/crates/codegen/xai-grok-workspace/src/handle.rs @@ -2697,6 +2697,9 @@ impl WorkspaceHandle { pub fn session_ids(&self) -> Vec { self.shared.sessions.read().keys().cloned().collect() } + pub fn session_count(&self) -> usize { + self.shared.sessions.read().len() + } /// Fork a new subagent session. Clones (not references) the parent's /// tool config and env. Enforces capability subset and fork budget. /// diff --git a/crates/codegen/xai-grok-workspace/src/mcp.rs b/crates/codegen/xai-grok-workspace/src/mcp.rs index 6166f55..5d0aa5d 100644 --- a/crates/codegen/xai-grok-workspace/src/mcp.rs +++ b/crates/codegen/xai-grok-workspace/src/mcp.rs @@ -13,7 +13,7 @@ use xai_computer_hub_mcp_adapter::{ }; use xai_computer_hub_sdk::ToolServerHandler; use xai_grok_mcp::rmcp; -use xai_grok_mcp::servers::McpClient; +use xai_grok_mcp::servers::{McpClient, parse_mcp_qualified_name}; use xai_tool_protocol::ToolId; use xai_tool_runtime::{ToolCallContext, ToolStream, TypedToolOutput}; use xai_tool_types::ToolDescription; @@ -143,15 +143,14 @@ pub(crate) struct QualifiedMcpToolHandler { } impl QualifiedMcpToolHandler { - /// Returns `None` if the qualified name is not a valid `ToolId`. + /// Returns `None` if the qualified name is invalid or ambiguous. pub fn try_new(qualified_name: String, inner: Arc) -> Option { - let qualified_id = match ToolId::new(&qualified_name) { - Ok(id) => id, - Err(err) => { + let qualified_id = match parse_mcp_qualified_name(&qualified_name) { + Some((id, _, _)) => id, + None => { tracing::warn!( - qualified_name = %qualified_name, - error = %err, - "skipping MCP tool: qualified name is not a valid ToolId" + qualified_name, + "skipping MCP tool: qualified name is invalid or ambiguous" ); return None; } @@ -218,3 +217,60 @@ pub(crate) fn make_bridge_config( namespace: Some(server_name.to_owned()), } } + +#[cfg(test)] +mod tests { + use super::*; + use xai_computer_hub_mcp_adapter::{McpBridge, McpError}; + use xai_tool_protocol::SessionId; + + struct TestTransport; + + #[async_trait] + impl McpTransport for TestTransport { + async fn initialize(&self) -> Result { + Ok(McpServerInfo { + name: "test".to_owned(), + version: "1".to_owned(), + capabilities: Value::Null, + }) + } + + async fn list_tools(&self) -> Result, McpError> { + Ok(vec![McpToolDefinition { + name: "tool".to_owned(), + description: None, + input_schema: None, + }]) + } + + async fn call_tool( + &self, + _name: &str, + _arguments: Value, + ) -> Result { + unreachable!("constructor test does not call the tool") + } + + async fn close(&self) -> Result<(), McpError> { + Ok(()) + } + } + + #[tokio::test] + async fn qualified_handler_rejects_ambiguous_name() { + let bridge = McpBridge::connect( + Arc::new(TestTransport), + &make_bridge_config(SessionId::new("session").unwrap(), "test"), + ) + .await + .unwrap() + .bridge; + let inner = bridge.handlers()[0].clone(); + + let valid = QualifiedMcpToolHandler::try_new("123__lookup".to_owned(), inner.clone()) + .expect("valid qualified ToolId"); + assert_eq!(valid.tool_id().as_str(), "123__lookup"); + assert!(QualifiedMcpToolHandler::try_new("foo___bar".to_owned(), inner).is_none()); + } +} diff --git a/crates/codegen/xai-grok-workspace/src/permission/manager.rs b/crates/codegen/xai-grok-workspace/src/permission/manager.rs index b8e2b18..9f13f13 100644 --- a/crates/codegen/xai-grok-workspace/src/permission/manager.rs +++ b/crates/codegen/xai-grok-workspace/src/permission/manager.rs @@ -14,16 +14,19 @@ use crate::permission::bash_command_splitting::{ use crate::permission::policy::CompiledPolicy; use crate::permission::prompter::{AcpPrompter, PromptOutcome}; use crate::permission::shell_access::{ - combine_decisions, command_write_paths_in_tree, is_safe_write_sink, + combine_decisions, command_write_paths_in_tree, edit_target_requires_prompt, is_safe_write_sink, }; use crate::permission::state::{PermissionState, load_state_from_disk, persist_state}; use crate::permission::types::{ - AccessKind, ClientType, Decision, EditPolicy, PermissionCommand, PermissionEvent, PromptPolicy, + AccessKind, ClientType, Decision, EditPathContext, EditPolicy, PermissionCommand, + PermissionEvent, PromptPolicy, }; +use xai_grok_mcp::servers::parse_mcp_qualified_name; use xai_grok_paths::AbsPathBuf; use xai_grok_tools::implementations::grok_build::web_fetch::{ DomainMatcher, domain::normalize_domain, }; +use xai_grok_tools::types::resources::resolve_model_path; /// Canonical `decision_reason` triggers for the uploaded artifact. Single source /// so the emit sites can't drift or misspell (the field doc lists these values). @@ -97,17 +100,11 @@ pub enum PermissionHandle { AllowAll, } -/// True iff `name` is an MCP tool whose server prefix (everything before the -/// first `__`) is in `servers`. The empty-prefix guard rejects corrupt entries -/// such as `{""}` or names like `"__tool"`. +/// True iff `name` is a valid qualified MCP ID whose server is in `servers`. +/// Malformed names fail closed, including `{""}` or names like `"__tool"`. fn mcp_server_prefix_allowed(name: &str, servers: &HashSet) -> bool { - if servers.is_empty() { - return false; - } - let Some((server, _)) = name.split_once("__") else { - return false; - }; - !server.is_empty() && servers.contains(server) + !servers.is_empty() + && parse_mcp_qualified_name(name).is_some_and(|(_, server, _)| servers.contains(server)) } /// Pre-decision lookup for an MCP tool. Returns `Some(Decision::Allow)` @@ -669,6 +666,28 @@ impl PermissionHandle { session_id: Option, subagent_type: Option, subagent_description: Option, + ) -> Decision { + self.request_with_edit_path_context( + access, + tool_call_update, + None, + session_id, + subagent_type, + subagent_description, + ) + .await + } + + /// Request permission with the edit tool's per-session execution cwd. + /// Shared parent/subagent managers must use this for `AccessKind::Edit`. + pub async fn request_with_edit_path_context( + &self, + access: AccessKind, + tool_call_update: acp::ToolCallUpdate, + edit_path_context: Option, + session_id: Option, + subagent_type: Option, + subagent_description: Option, ) -> Decision { match self { PermissionHandle::AllowAll => Decision::Allow, @@ -682,6 +701,7 @@ impl PermissionHandle { let msg = PermissionCommand::Request { access, tool_call_update, + edit_path_context, respond_to: tx, session_id, subagent_type, @@ -1112,6 +1132,7 @@ fn spawn_permission_manager_with_pin( PermissionCommand::Request { access, tool_call_update, + edit_path_context, mut respond_to, session_id: request_session_id, subagent_type: request_subagent_type, @@ -1213,6 +1234,23 @@ fn spawn_permission_manager_with_pin( AccessKind::Bash(cmd) => Some(evaluate_bash(cmd, &state, true)), _ => None, }; + let protected_edit = match (&access, edit_path_context.as_ref()) { + (AccessKind::Edit(path), Some(context)) => { + let resolved = resolve_model_path( + &context.real_cwd, + context.display_cwd.as_deref(), + path, + ); + edit_target_requires_prompt(&resolved) + } + // Direct workspace callers predate per-request context and execute + // against the manager cwd; the shell always supplies context. + (AccessKind::Edit(path), None) => { + let resolved = resolve_model_path(cwd.as_path(), None, path); + edit_target_requires_prompt(&resolved) + } + _ => false, + }; // Evaluate managed policy (direct access + per-segment Bash command // rules + Bash shell-file args) up front so the YOLO/sandbox fast @@ -1274,6 +1312,7 @@ fn spawn_permission_manager_with_pin( // Ask floors fall through so managed Ask / shell-file Ask stay binding. if !policy_forced_prompt && !shell_forced_prompt + && !protected_edit && let Some((decision, reason)) = session_grant_pre_decision( &access, bash_evaluation.as_ref(), @@ -1307,7 +1346,8 @@ fn spawn_permission_manager_with_pin( AutoFastPath, ClassifierVerdict, access_requires_user_interaction, auto_mode_fast_path, }; - let needs_user = access_requires_user_interaction(&tool_name, &access); + let needs_user = + protected_edit || access_requires_user_interaction(&tool_name, &access); let fast = auto_mode_fast_path(&access, &tool_name, needs_user); match fast { AutoFastPath::Allow => { @@ -1421,7 +1461,7 @@ fn spawn_permission_manager_with_pin( // pre-decision match: a policy `Ask` rule on an MCP tool // overrides the session allowlist and forces a re-prompt. // Other access kinds keep their legacy fall-through behavior, - // subject to Bash request floors. + // subject to Bash request and protected-edit floors. match policy_decision { Some(Decision::Ask) => { tracing::info!( @@ -1431,12 +1471,13 @@ fn spawn_permission_manager_with_pin( ); } Some(Decision::Allow) - if bash_request_floor_requires_prompt(bash_evaluation.as_ref()) => + if protected_edit + || bash_request_floor_requires_prompt(bash_evaluation.as_ref()) => { tracing::info!( tool = ?tool_name, source = "policy", - "permission policy allow deferred to Bash prompt floor" + "permission policy allow deferred to confirmation floor" ); } Some(decision) => { @@ -1490,7 +1531,7 @@ fn spawn_permission_manager_with_pin( ) .map(|d| (d, reasons::PERSISTED_GRANT)), AccessKind::Edit(_) => { - if allow_edits_for_session { + if allow_edits_for_session && !protected_edit { Some((Decision::Allow, reasons::PERSISTED_GRANT)) } else { match state.edit_policy { @@ -1757,20 +1798,15 @@ fn spawn_permission_manager_with_pin( } PromptOutcome::AllowAlwaysMcpServer(server_prefix) => { // Derive the canonical server prefix from the current - // AccessKind via `split_once("__")`. Validate the - // client-supplied prefix against it; on mismatch (or - // empty / no separator), downgrade to tool-scope using - // the access-kind name. This prevents a buggy or - // malicious client from whitelisting an unrelated - // server. + // AccessKind and validate the client-supplied prefix + // against it. On mismatch or malformed input, downgrade + // to tool-scope using the access-kind name. if let AccessKind::MCPTool { name: access_name, .. } = &access { - let canonical = access_name - .split_once("__") - .map(|(s, _)| s) - .filter(|s| !s.is_empty()); + let canonical = parse_mcp_qualified_name(access_name) + .map(|(_, server, _)| server); match canonical { Some(canonical) if canonical == server_prefix => { state @@ -1784,10 +1820,9 @@ fn spawn_permission_manager_with_pin( persist_state(&cwd, &state, client_id_ref).await; } _ => { - // Mismatch, empty prefix, or no `__` separator - // in the access name. Defensively downgrade to - // tool-scope on the access-kind name so the - // user is not re-prompted, but the blast + // Mismatch or malformed access name. Defensively + // downgrade to tool-scope on the access-kind name + // so the user is not re-prompted, but the blast // radius is the smaller scope they actually // saw. tracing::warn!( @@ -2115,6 +2150,74 @@ mod tests { .await; } + #[tokio::test] + async fn session_edit_grant_excludes_protected_target() { + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let tmp = tempfile::tempdir().unwrap(); + let cwd = AbsPathBuf::new(tmp.path().to_path_buf()).unwrap(); + let transport = fake_hub(serde_json::json!({ "outcome": "always_approve" })); + let (mgr, _e) = test_manager_with_hub(&cwd, transport.clone()); + for path in ["src/first.rs", "src/second.rs", "~/.zshrc"] { + assert_eq!( + mgr.request(AccessKind::Edit(path.into()), tool_call(), None, None, None) + .await, + Decision::Allow + ); + } + assert_eq!(transport.seen.lock().unwrap().len(), 2); + }) + .await; + } + + #[tokio::test] + #[cfg(unix)] + async fn shared_manager_uses_request_edit_path_context() { + use std::os::unix::fs::symlink; + + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let parent = tempfile::tempdir().unwrap(); + let child = tempfile::tempdir().unwrap(); + let display = tempfile::tempdir().unwrap(); + symlink("/etc", child.path().join("link")).unwrap(); + let parent_cwd = AbsPathBuf::new(parent.path().to_path_buf()).unwrap(); + let transport = fake_hub(serde_json::json!({ "outcome": "approve" })); + let (mgr, _events) = test_manager_with_hub(&parent_cwd, transport.clone()); + mgr.set_auto_mode(true); + let context = EditPathContext { + real_cwd: child.path().to_path_buf(), + display_cwd: Some(display.path().to_path_buf()), + }; + + for displayed in [ + display.path().join("link/hosts"), + display.path().join("src.rs"), + ] { + assert_eq!( + mgr.request_with_edit_path_context( + AccessKind::Edit(displayed.to_string_lossy().into_owned()), + tool_call(), + Some(context.clone()), + None, + None, + None, + ) + .await, + Decision::Allow + ); + } + assert_eq!( + transport.seen.lock().unwrap().len(), + 1, + "child protected target prompts; ordinary displayed child path stays auto" + ); + }) + .await; + } + #[tokio::test] async fn hub_permission_reject_aborts() { let local = tokio::task::LocalSet::new(); @@ -2216,6 +2319,64 @@ mod tests { .await; } + #[tokio::test] + async fn ambiguous_mcp_server_scope_downgrades_to_exact_persisted_grant() { + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + for (name, forged_server) in [("a__b__c", "a"), ("foo___bar", "foo")] { + let tmp = tempfile::tempdir().unwrap(); + let cwd = AbsPathBuf::new(tmp.path().to_path_buf()).unwrap(); + let transport = fake_hub(serde_json::json!({ + "outcome": "always_approve", + "scope": { "kind": "server_prefix", "value": forged_server }, + })); + let (mgr, _e) = test_manager_with_hub(&cwd, transport.clone()); + let decision = mgr + .request( + AccessKind::MCPTool { + name: name.into(), + input: serde_json::Value::Null, + }, + tool_call(), + None, + None, + None, + ) + .await; + assert_eq!(decision, Decision::Allow); + + let persisted = load_state_from_disk(&cwd, None).await; + assert!(persisted.allowed_mcp_servers.is_empty(), "{name}"); + assert!(persisted.allowed_mcp_tools.contains(name), "{name}"); + assert!(matches!( + mcp_pre_decision(name, &persisted, false, false), + Some(Decision::Allow) + )); + + let replay_transport = fake_hub(serde_json::json!({ "outcome": "reject" })); + let (reloaded, _e) = test_manager_with_hub(&cwd, replay_transport.clone()); + assert_eq!( + reloaded + .request( + AccessKind::MCPTool { + name: name.into(), + input: serde_json::Value::Null, + }, + tool_call(), + None, + None, + None, + ) + .await, + Decision::Allow + ); + assert!(replay_transport.seen.lock().unwrap().is_empty()); + } + }) + .await; + } + /// A managed `Ask` rule on a direct `Read`/`Grep` must reach the prompt, not /// the unconditional auto-allow. With no responder wired, that surfaces as a /// non-`Allow` decision; a non-ask read still auto-allows. @@ -3482,6 +3643,59 @@ mod tests { .await; } + #[tokio::test] + async fn protected_edit_floor_covers_auto_config_allow_and_dont_ask() { + use crate::permission::types::{PermissionRule, RuleAction, ToolFilter}; + + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let mut auto = crate::permission::types::PermissionConfig::new(vec![]); + auto.prompt_policy = PromptPolicy::Auto; + let allow = crate::permission::types::PermissionConfig::new(vec![PermissionRule { + action: RuleAction::Allow, + tool: ToolFilter::Edit, + pattern: None, + pattern_mode: Default::default(), + }]); + let mut deny = crate::permission::types::PermissionConfig::new(vec![]); + deny.prompt_policy = PromptPolicy::Deny; + + for (name, config, expected_prompts, policy_deny) in [ + ("auto", auto, 1, false), + ("configured allow", allow, 1, false), + ("dontAsk", deny, 0, true), + ] { + let tmp = tempfile::tempdir().unwrap(); + let cwd = AbsPathBuf::new(tmp.path().to_path_buf()).unwrap(); + let client = RecordingClient::default(); + let prompts = client.prompts.clone(); + let (mgr, _events) = manager_with_recording_client( + &cwd, + Some(config), + client, + ClientType::Generic, + ); + let decision = mgr + .request( + AccessKind::Edit("/etc/hosts".into()), + tool_call(), + None, + None, + None, + ) + .await; + assert_eq!(prompts.borrow().len(), expected_prompts, "{name}"); + if policy_deny { + assert!(matches!(decision, Decision::PolicyDeny(_)), "{name}"); + } else { + assert!(matches!(decision, Decision::Reject(_)), "{name}"); + } + } + }) + .await; + } + #[test] fn sandbox_auto_allow_respects_real_file_write_floor() { let state = PermissionState::default(); @@ -3558,6 +3772,7 @@ mod tests { .send(PermissionCommand::Request { access: AccessKind::Bash("curl http://example.com".into()), tool_call_update: tool_call(), + edit_path_context: None, respond_to: tx, session_id: None, subagent_type: None, @@ -3667,6 +3882,7 @@ mod tests { .send(PermissionCommand::Request { access: AccessKind::Bash("curl http://example.com".into()), tool_call_update: tool_call(), + edit_path_context: None, respond_to: tx, session_id: None, subagent_type: None, @@ -5139,10 +5355,13 @@ mod tests { #[test] fn server_prefix_match_allows() { - assert!(mcp_server_prefix_allowed( - "linear__list", - &servers(&["linear"]) - )); + for (name, server) in [ + ("linear__list", "linear"), + ("123__lookup", "123"), + ("server:scope__tool", "server:scope"), + ] { + assert!(mcp_server_prefix_allowed(name, &servers(&[server]))); + } } #[test] @@ -5151,8 +5370,24 @@ mod tests { } #[test] - fn no_separator_rejects() { - assert!(!mcp_server_prefix_allowed("linear", &servers(&["linear"]))); + fn malformed_names_do_not_consume_server_grants() { + for (name, server) in [ + ("server__part__tool", "server"), + ("server__tool__part", "server"), + ("foo___bar", "foo"), + ("foo___bar", "foo_"), + ("foo____bar", "foo"), + ("server__", "server"), + ("server", "server"), + ("__tool", ""), + ("", ""), + ("server__bad.tool", "server"), + ] { + assert!( + !mcp_server_prefix_allowed(name, &servers(&[server])), + "unexpectedly allowed {name:?}" + ); + } } #[test] @@ -5172,9 +5407,8 @@ mod tests { } #[test] - fn first_double_underscore_anchors_split() { - // "a__b__c" splits into ("a", "b__c"); server "a" matches. - assert!(mcp_server_prefix_allowed("a__b__c", &servers(&["a"]))); + fn multiple_delimiters_do_not_inherit_first_segment_grant() { + assert!(!mcp_server_prefix_allowed("a__b__c", &servers(&["a"]))); } #[test] @@ -5187,21 +5421,17 @@ mod tests { )); } - #[test] - fn empty_tool_name_after_prefix_still_allowed() { - // The MCP server is responsible for rejecting empty tool names; - // the prefix match is what gates access. - assert!(mcp_server_prefix_allowed("foo__", &servers(&["foo"]))); - } - #[test] fn pre_decision_tool_grant_allows() { let mut state = PermissionState::default(); state.allowed_mcp_tools.insert("linear__list".to_string()); - assert!(matches!( - mcp_pre_decision("linear__list", &state, false, false), - Some(Decision::Allow) - )); + state.allowed_mcp_tools.insert("a__b__c".to_string()); + for name in ["linear__list", "a__b__c"] { + assert!(matches!( + mcp_pre_decision(name, &state, false, false), + Some(Decision::Allow) + )); + } } #[test] @@ -5360,10 +5590,8 @@ mod tests { .await; } - /// Auto mode accepts ALL file edits via the fast path regardless of location - /// (the accept-all-edits product decision, no workspace restriction): both an - /// in-cwd edit and an absolute path clearly OUTSIDE cwd fast-path Allow. The - /// fast path is path-independent, so the target file need not exist. + /// Auto mode accepts ordinary file edits via the fast path regardless of + /// location (the accept-all-edits product decision, no workspace restriction). #[tokio::test] async fn auto_mode_edit_fast_path_allows() { let local = tokio::task::LocalSet::new(); @@ -5380,7 +5608,6 @@ mod tests { ) }; - // In-cwd edit → Allow (file need not exist). let in_cwd = tmp.path().join("f.rs").to_string_lossy().into_owned(); let d = mgr .request(AccessKind::Edit(in_cwd), mk("tc-edit-in"), None, None, None) @@ -5390,10 +5617,9 @@ mod tests { "in-cwd edit under auto must fast-path allow, got {d:?}" ); - // Out-of-workspace absolute edit → Allow too (no workspace restriction). let d = mgr .request( - AccessKind::Edit("/etc/hosts".into()), + AccessKind::Edit("/tmp/out-of-ws.rs".into()), mk("tc-edit-out"), None, None, diff --git a/crates/codegen/xai-grok-workspace/src/permission/prompter.rs b/crates/codegen/xai-grok-workspace/src/permission/prompter.rs index f6b79dc..5f4d2c8 100644 --- a/crates/codegen/xai-grok-workspace/src/permission/prompter.rs +++ b/crates/codegen/xai-grok-workspace/src/permission/prompter.rs @@ -9,6 +9,7 @@ use crate::permission::{ use agent_client_protocol::{self as acp, Client as _}; use xai_acp_lib::AcpAgentGatewaySender as GatewaySender; use xai_file_utils::events::{Event, EventWriter, PermissionDecision}; +use xai_grok_mcp::servers::parse_mcp_qualified_name; use xai_grok_tools::implementations::grok_build::web_fetch::domain_from_url; const REJECT_ONCE_LABEL: &str = "No, and tell Grok what to do differently"; @@ -162,9 +163,8 @@ pub struct BashCommandSelectedTerms { /// depend on it without dragging the full workspace or rmcp into each /// other). Re-exported here for backward-compat with callers that historically /// reached `xai_grok_workspace::permission::MCP_TOOL_NAME_DELIMITER`. -/// Validation in `into_registration` rejects MCP tools whose qualified name -/// contains more than one occurrence of this delimiter, so stripping it given -/// a trusted `server_prefix` is always unambiguous. +/// Model-callable MCP registration validates this delimiter before permission +/// handling, so stripping it given a trusted `server_prefix` is unambiguous. pub use xai_grok_workspace_types::MCP_TOOL_NAME_DELIMITER; /// Extract the action segment of a qualified MCP tool name using a @@ -222,15 +222,13 @@ pub fn mcp_tool_display_name(tool_name: &str, server_prefix: Option<&str>) -> St /// Display variant for callers that have only a qualified-or-raw tool /// name string (e.g. activity titles from ACP `tool_call.fields.title` -/// or scrollback blocks that store the wire name verbatim). Splits on -/// the (validated-at-construction) `MCP_TOOL_NAME_DELIMITER`: if the -/// split succeeds the name is formatted as `"(Server) Action"` with -/// each segment title-cased; otherwise the input is returned unchanged -/// (no title-casing — the input may be a bash command, file path, or -/// other non-MCP text that the caller mustn't mangle). +/// or scrollback blocks that store the wire name verbatim). Valid qualified +/// names are formatted as `"(Server) Action"` with each segment title-cased; +/// otherwise the input is returned unchanged (no title-casing — the input may +/// be a bash command, file path, or other non-MCP text). pub fn mcp_pretty_name_if_qualified(name: &str) -> String { - match name.split_once(MCP_TOOL_NAME_DELIMITER) { - Some((server, action)) => format!( + match parse_mcp_qualified_name(name) { + Some((_, server, action)) => format!( "({}) {}", mcp_titleize_segment(server), mcp_titleize_segment(action) @@ -250,10 +248,9 @@ pub struct McpToolPermission { /// Full tool name as the agent called it /// (e.g. `"grok_com_notion__notion-fetch"`). pub tool_name: String, - /// Server segment (everything before the single `__` separator, - /// e.g. `"grok_com_notion"`). `None` if the tool name has no `__`, - /// in which case the view hides the scope toggle and only offers - /// tool-scope. + /// Server component of a valid qualified MCP ID (e.g. `"grok_com_notion"`). + /// `None` for malformed or unqualified names, in which case the view hides + /// the scope toggle and only offers tool-scope. pub server_prefix: Option, } @@ -277,7 +274,7 @@ impl McpToolPermission { pub enum McpScopeSelection { /// Whitelist exactly this tool name. Tool { tool_name: String }, - /// Whitelist every tool whose name starts with `__`. + /// Whitelist the server component of the current valid qualified MCP ID. Server { server: String }, } @@ -293,8 +290,8 @@ pub enum PromptOutcome { AllowAlwaysDomain(String), /// Persist this exact MCP tool name in `allowed_mcp_tools`. AllowAlwaysMcpTool(String), - /// Persist this MCP server prefix (no trailing `__`) in - /// `allowed_mcp_servers`. An empty string is rejected by the manager. + /// Persist the current valid qualified MCP ID's server component in + /// `allowed_mcp_servers`; the manager rejects mismatched or malformed input. AllowAlwaysMcpServer(String), RejectOnce, RejectAlwaysBashCommand(String), @@ -671,7 +668,8 @@ impl AcpPrompter { ClientType::GrokTUI | ClientType::GrokPager | ClientType::Desktop => { let mut options: IndexMap = IndexMap::new(); - let server_prefix = tool_name.split_once("__").map(|(s, _)| s.to_owned()); + let server_prefix = parse_mcp_qualified_name(tool_name) + .map(|(_, server, _)| server.to_owned()); options.insert( acp::PermissionOptionId::new("allow-always-mcp"), acp::PermissionOption::new( @@ -1169,37 +1167,46 @@ mod tests { #[test] fn mcp_prompt_includes_allow_always_with_meta() { let p = prompter(ClientType::GrokTUI); - let access = AccessKind::MCPTool { - name: "linear__list".to_owned(), - input: serde_json::Value::Null, - }; - let opts = p.build_options(&access); - let opt = opts - .get(&acp::PermissionOptionId::new("allow-always-mcp")) - .expect("allow-always-mcp option missing"); - let meta = opt.meta.clone().expect("meta missing"); - let perm: McpToolPermission = - serde_json::from_value(serde_json::Value::Object(meta)).unwrap(); - assert_eq!(perm.tool_name, "linear__list"); - assert_eq!(perm.server_prefix.as_deref(), Some("linear")); - assert_eq!(perm.prompt_prefix, "Always allow:"); + for (name, server) in [ + ("linear__list", "linear"), + ("123__lookup", "123"), + ("server:scope__tool", "server:scope"), + ] { + let access = AccessKind::MCPTool { + name: name.to_owned(), + input: serde_json::Value::Null, + }; + let opts = p.build_options(&access); + let opt = opts + .get(&acp::PermissionOptionId::new("allow-always-mcp")) + .expect("allow-always-mcp option missing"); + let meta = opt.meta.clone().expect("meta missing"); + let perm: McpToolPermission = + serde_json::from_value(serde_json::Value::Object(meta)).unwrap(); + assert_eq!(perm.tool_name, name); + assert_eq!(perm.server_prefix.as_deref(), Some(server)); + assert_eq!(perm.prompt_prefix, "Always allow:"); + } } #[test] - fn mcp_prompt_no_separator_hides_server_scope() { + fn mcp_prompt_malformed_name_hides_server_scope() { let p = prompter(ClientType::GrokPager); - let access = AccessKind::MCPTool { - name: "standalone".to_owned(), - input: serde_json::Value::Null, - }; - let opts = p.build_options(&access); - let opt = opts - .get(&acp::PermissionOptionId::new("allow-always-mcp")) - .unwrap(); - let perm: McpToolPermission = - serde_json::from_value(serde_json::Value::Object(opt.meta.clone().unwrap())).unwrap(); - assert_eq!(perm.tool_name, "standalone"); - assert_eq!(perm.server_prefix, None); + for name in ["standalone", "linear__shadow__exfil", "linear__"] { + let access = AccessKind::MCPTool { + name: name.to_owned(), + input: serde_json::Value::Null, + }; + let opts = p.build_options(&access); + let opt = opts + .get(&acp::PermissionOptionId::new("allow-always-mcp")) + .unwrap(); + let perm: McpToolPermission = + serde_json::from_value(serde_json::Value::Object(opt.meta.clone().unwrap())) + .unwrap(); + assert_eq!(perm.tool_name, name); + assert_eq!(perm.server_prefix, None); + } } #[test] @@ -1317,11 +1324,20 @@ mod tests { mcp_pretty_name_if_qualified("linear__list_issues"), "(Linear) List Issues" ); + assert_eq!(mcp_pretty_name_if_qualified("123__lookup"), "(123) Lookup"); + assert_eq!( + mcp_pretty_name_if_qualified("server:scope__tool"), + "(Server:scope) Tool" + ); // Non-qualified input (e.g. a bash command, file path, or any // string without `__`) is returned UNCHANGED — must not // title-case or mangle non-MCP strings. assert_eq!(mcp_pretty_name_if_qualified("read_file"), "read_file"); assert_eq!(mcp_pretty_name_if_qualified("cargo test"), "cargo test"); + assert_eq!( + mcp_pretty_name_if_qualified("linear__shadow__exfil"), + "linear__shadow__exfil" + ); assert_eq!(mcp_pretty_name_if_qualified(""), ""); } diff --git a/crates/codegen/xai-grok-workspace/src/permission/shell_access.rs b/crates/codegen/xai-grok-workspace/src/permission/shell_access.rs index e50f1a8..b4cfe71 100644 --- a/crates/codegen/xai-grok-workspace/src/permission/shell_access.rs +++ b/crates/codegen/xai-grok-workspace/src/permission/shell_access.rs @@ -230,6 +230,84 @@ pub(crate) fn is_safe_write_sink(path: &str) -> bool { matches!(path, "/dev/null" | "/dev/stdout" | "/dev/stderr") } +/// Whether an already-resolved direct edit target needs explicit confirmation. +/// +/// The caller uses the edit tools' shared model-path resolver first. This helper +/// preserves its uncollapsed components for physical symlink + `..` resolution, +/// while checking a separate lexical normalization for traversal aliases. +pub(crate) fn edit_target_requires_prompt(path: &Path) -> bool { + if !path.is_absolute() { + return true; + } + let lexical = xai_grok_paths::normalize_lexically(path); + if protected_edit_path(&lexical) { + return true; + } + let Some(resolved) = resolve_following_symlinks(path, 0) else { + return true; + }; + protected_edit_path(&resolved) || resolved_path_is_within_root(&resolved, Path::new("/etc")) +} + +fn protected_edit_path(path: &Path) -> bool { + let components: Vec = path + .components() + .filter_map(|component| match component { + std::path::Component::Normal(part) => Some(part.to_string_lossy().to_ascii_lowercase()), + _ => None, + }) + .collect(); + let string_components: Vec<&str> = components.iter().map(String::as_str).collect(); + let file = string_components.last().copied().unwrap_or(""); + const STARTUP_FILES: &[&str] = &[ + ".bashrc", + ".bash_profile", + ".bash_login", + ".bash_logout", + ".profile", + ".zshrc", + ".zshenv", + ".zprofile", + ".zlogin", + ".zlogout", + ".kshrc", + ".cshrc", + ".tcshrc", + ".login", + ".logout", + ".inputrc", + ".xprofile", + ]; + + STARTUP_FILES.contains(&file) + || protected_git_hooks_path(&string_components) + || string_components.contains(&".ssh") + || string_components.ends_with(&[".grok", "config.toml"]) + || path == Path::new("/etc") + || path.starts_with(Path::new("/etc")) +} + +fn protected_git_hooks_path(components: &[&str]) -> bool { + components.windows(2).any(|pair| pair == [".git", "hooks"]) + || components.iter().enumerate().any(|(git, component)| { + *component == ".git" + && components.get(git + 1) == Some(&"modules") + && components[git + 2..] + .iter() + .skip(1) + .any(|component| *component == "hooks") + }) +} + +/// `resolved_path` is already physical; resolve `root` so platform aliases such +/// as macOS `/etc -> /private/etc` compare in the same namespace. Resolution +/// failure is conservative: the caller then requires confirmation. +fn resolved_path_is_within_root(resolved_path: &Path, root: &Path) -> bool { + resolve_following_symlinks(root, 0) + .map(|resolved_root| resolved_path.starts_with(resolved_root)) + .unwrap_or(true) +} + #[derive(Clone, Copy)] pub(crate) enum ShellFileMode { Read, @@ -738,8 +816,8 @@ fn resolve_symlink_target(absolute: &str) -> Option { /// Resolve `path` following every symlink, including a *dangling* final link /// (which `canonicalize` alone rejects) and not-yet-existing trailing -/// components. Depth-bounded against cycles; any fs error yields `None`. -/// Blocking fs syscalls; runs per operand when file rules exist. +/// components. Depth-bounded against cycles; unexpected fs errors yield `None`. +/// Blocking fs syscalls; runs for shell operands under file rules and direct edits. fn resolve_following_symlinks(path: &Path, depth: usize) -> Option { const MAX_SYMLINK_DEPTH: usize = 40; if depth > MAX_SYMLINK_DEPTH { @@ -750,13 +828,17 @@ fn resolve_following_symlinks(path: &Path, depth: usize) -> Option { return Some(canonical); } // Resolve the parent, then the final component, so a dangling/new leaf still follows. + // Missing components are valid new paths; other metadata errors fail closed. let parent = path.parent()?; let file_name = path.file_name()?; let resolved_parent = resolve_following_symlinks(parent, depth + 1)?; let candidate = resolved_parent.join(file_name); - if let Ok(meta) = std::fs::symlink_metadata(&candidate) - && meta.file_type().is_symlink() - { + let metadata = match std::fs::symlink_metadata(&candidate) { + Ok(metadata) => Some(metadata), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(_) => return None, + }; + if metadata.is_some_and(|metadata| metadata.file_type().is_symlink()) { // A symlink must be followed; if it can't be read, treat the whole path // as unresolved (`None`) rather than returning the link's own path. let target = std::fs::read_link(&candidate).ok()?; @@ -818,6 +900,111 @@ mod tests { std::path::Path::new("/work") } + #[test] + fn sensitive_edit_targets_and_lexical_aliases_prompt() { + for path in [ + "/home/user/.zshrc", + "/etc", + "/etc/grok-test", + "/work/subdir/../.git/hooks/pre-commit", + ] { + assert!( + edit_target_requires_prompt(Path::new(path)), + "protected edit target must prompt: {path}" + ); + } + for path in [ + "/work/src/main.rs", + "/work/project/.grok/config.toml/backup", + ] { + assert!( + !edit_target_requires_prompt(Path::new(path)), + "ordinary edit target should not prompt: {path}" + ); + } + } + + #[test] + fn sensitive_edit_targets_include_submodule_hooks() { + for path in [ + "/work/.git/modules/foo/hooks/pre-commit", + "/work/.git/modules/submodules/sglang-private/hooks/pre-commit", + "/work/.git/modules/outer/modules/inner/hooks/pre-commit", + "/work/subdir/../.git/modules/foo/hooks/pre-commit", + ] { + assert!( + edit_target_requires_prompt(Path::new(path)), + "submodule hook target must prompt: {path}" + ); + } + for path in [ + "/work/.git/modules/hooks/pre-commit", + "/work/.git/module/foo/hooks/pre-commit", + "/work/.git/modules/foo/hook/pre-commit", + "/work/.git/modules/foo/hooks-disabled/pre-commit", + "/work/src/modules/foo/hooks/pre-commit", + ] { + assert!( + !edit_target_requires_prompt(Path::new(path)), + "non-hook control must not prompt: {path}" + ); + } + } + + #[test] + #[cfg(unix)] + fn sensitive_edit_targets_follow_symlinks() { + use std::os::unix::fs::symlink; + let ws = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let startup = outside.path().join(".zshrc"); + std::fs::write(&startup, b"").unwrap(); + symlink(&startup, ws.path().join("file-link")).unwrap(); + std::fs::create_dir_all(outside.path().join(".git/hooks")).unwrap(); + symlink( + outside.path().join(".git/hooks"), + ws.path().join("hooks-link"), + ) + .unwrap(); + std::fs::create_dir_all(outside.path().join(".git/modules/foo/hooks")).unwrap(); + symlink( + outside.path().join(".git/modules/foo/hooks"), + ws.path().join("module-hooks-link"), + ) + .unwrap(); + + for path in [ + ws.path().join("file-link"), + ws.path().join("hooks-link/new-hook"), + ws.path().join("module-hooks-link/new-hook"), + ] { + assert!( + edit_target_requires_prompt(&path), + "symlinked protected edit target must prompt: {}", + path.display() + ); + } + } + + #[test] + fn resolved_root_alias_matches_physical_destination() { + let resolved_root = resolve_following_symlinks(Path::new("/etc"), 0).unwrap(); + assert!(resolved_path_is_within_root( + &resolved_root.join("grok-test"), + Path::new("/etc") + )); + assert!(!resolved_path_is_within_root( + Path::new("/tmp/grok-test"), + Path::new("/etc") + )); + } + + #[test] + #[cfg(target_os = "macos")] + fn private_etc_alias_requires_prompt() { + assert!(edit_target_requires_prompt(Path::new("/private/etc/hosts"))); + } + #[test] #[cfg(unix)] fn resolved_symlink_target_hits_read_deny() { diff --git a/crates/codegen/xai-grok-workspace/src/permission/state.rs b/crates/codegen/xai-grok-workspace/src/permission/state.rs index c52e118..7dd6278 100644 --- a/crates/codegen/xai-grok-workspace/src/permission/state.rs +++ b/crates/codegen/xai-grok-workspace/src/permission/state.rs @@ -6,7 +6,9 @@ use std::collections::HashSet; use xai_grok_paths::AbsPathBuf; use xai_grok_tools::util::grok_home::grok_home; -#[derive(Debug, Default, Clone, Serialize, Deserialize)] +const VALIDATED_MCP_SERVER_GRANTS_VERSION: i64 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct PermissionState { pub edit_policy: EditPolicy, @@ -19,11 +21,47 @@ pub struct PermissionState { /// Exact MCP tool names (e.g. `"grok_com_notion__notion-fetch"`) /// the user has granted "always allow" for. Lookup is exact. pub allowed_mcp_tools: HashSet, - /// MCP server prefixes (everything before the first `__`, - /// e.g. `"grok_com_notion"`) for which the user has granted - /// "always allow" to every tool. Lookup is "tool name starts with - /// `__`". + /// Server components of valid qualified MCP IDs (e.g. `"grok_com_notion"`) + /// for which the user has granted "always allow" to every tool. Lookup + /// validates and parses the complete qualified ID before matching. pub allowed_mcp_servers: HashSet, + /// Version proving server-wide grants were minted from validated qualified IDs. + /// Missing or malformed markers are legacy; future integer versions are preserved. + #[serde( + default = "legacy_mcp_server_grants_version", + deserialize_with = "deserialize_mcp_server_grants_version" + )] + pub(crate) validated_mcp_server_grants_version: i64, +} + +fn legacy_mcp_server_grants_version() -> i64 { + 0 +} + +fn deserialize_mcp_server_grants_version<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let value = toml::Value::deserialize(deserializer)?; + Ok(match value.as_integer() { + Some(version) if version >= 0 => version, + _ => 0, + }) +} + +impl Default for PermissionState { + fn default() -> Self { + Self { + edit_policy: EditPolicy::default(), + allow_bash_execute: false, + allowed_bash_commands: HashSet::new(), + disallowed_bash_commands: HashSet::new(), + allowed_web_fetch_domains: HashSet::new(), + allowed_mcp_tools: HashSet::new(), + allowed_mcp_servers: HashSet::new(), + validated_mcp_server_grants_version: VALIDATED_MCP_SERVER_GRANTS_VERSION, + } + } } fn state_dir_for_cwd(cwd: &AbsPathBuf) -> std::path::PathBuf { @@ -49,9 +87,23 @@ fn state_file_path(dir: &std::path::Path, client_identifier: Option<&str>) -> st } } -async fn try_load_state(path: &std::path::Path) -> Option { +async fn try_load_state_with_writer(path: &std::path::Path, writer: F) -> Option +where + F: FnOnce(&std::path::Path, &str) -> std::io::Result<()> + Send + 'static, +{ match tokio::fs::read_to_string(path).await { - Ok(s) => Some(toml::from_str(&s).unwrap_or_default()), + Ok(s) => { + let mut state: PermissionState = toml::from_str(&s).unwrap_or_default(); + if state.validated_mcp_server_grants_version < VALIDATED_MCP_SERVER_GRANTS_VERSION { + state.allowed_mcp_servers.clear(); + state.validated_mcp_server_grants_version = VALIDATED_MCP_SERVER_GRANTS_VERSION; + tracing::info!(path = %path.display(), "invalidated legacy MCP server grants"); + if let Err(e) = persist_state_to_path_with_writer(path, &state, writer).await { + tracing::warn!(?e, path = %path.display(), "failed writing permission state"); + } + } + Some(state) + } Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, Err(e) => { tracing::warn!(?e, "failed reading permission state"); @@ -60,6 +112,13 @@ async fn try_load_state(path: &std::path::Path) -> Option { } } +async fn try_load_state(path: &std::path::Path) -> Option { + try_load_state_with_writer(path, |path, contents| { + xai_grok_config::fs_atomic::write_atomically(path, contents, None) + }) + .await +} + async fn load_state_from_dir( dir: &std::path::Path, client_identifier: Option<&str>, @@ -69,12 +128,10 @@ async fn load_state_from_dir( if let Some(state) = try_load_state(&per_client).await { return state; } - let shared = state_file_path(dir, None); - try_load_state(&shared).await.unwrap_or_default() - } else { - let path = state_file_path(dir, None); - try_load_state(&path).await.unwrap_or_default() } + try_load_state(&state_file_path(dir, None)) + .await + .unwrap_or_default() } pub(crate) async fn load_state_from_disk( @@ -84,6 +141,32 @@ pub(crate) async fn load_state_from_disk( load_state_from_dir(&state_dir_for_cwd(cwd), client_identifier).await } +async fn persist_state_to_path_with_writer( + path: &std::path::Path, + state: &PermissionState, + writer: F, +) -> std::io::Result<()> +where + F: FnOnce(&std::path::Path, &str) -> std::io::Result<()> + Send + 'static, +{ + let contents = toml::to_string_pretty(state) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + let path = path.to_path_buf(); + tokio::task::spawn_blocking(move || writer(&path, &contents)) + .await + .map_err(std::io::Error::other)? +} + +async fn persist_state_to_path( + path: &std::path::Path, + state: &PermissionState, +) -> std::io::Result<()> { + persist_state_to_path_with_writer(path, state, |path, contents| { + xai_grok_config::fs_atomic::write_atomically(path, contents, None) + }) + .await +} + async fn persist_state_to_dir( dir: &std::path::Path, state: &PermissionState, @@ -94,13 +177,8 @@ async fn persist_state_to_dir( return; } let path = state_file_path(dir, client_identifier); - match toml::to_string_pretty(state) { - Ok(s) => { - if let Err(e) = tokio::fs::write(&path, s).await { - tracing::warn!(?e, "failed writing permission state"); - } - } - Err(e) => tracing::warn!(?e, "failed serializing permission state"), + if let Err(e) = persist_state_to_path(&path, state).await { + tracing::warn!(?e, path = %path.display(), "failed writing permission state"); } } @@ -161,6 +239,10 @@ mod tests { assert!(!restored.allow_bash_execute); assert!(restored.allowed_bash_commands.is_empty()); assert!(restored.disallowed_bash_commands.is_empty()); + assert_eq!( + restored.validated_mcp_server_grants_version, + VALIDATED_MCP_SERVER_GRANTS_VERSION + ); } #[test] @@ -251,11 +333,12 @@ mod tests { } #[test] - fn deserialize_from_empty_toml() { + fn deserialize_from_empty_toml_is_legacy() { let state: PermissionState = toml::from_str("").unwrap(); assert!(!state.allow_bash_execute); assert!(state.allowed_bash_commands.is_empty()); assert!(state.disallowed_bash_commands.is_empty()); + assert_eq!(state.validated_mcp_server_grants_version, 0); } #[test] @@ -364,6 +447,16 @@ allowed_web_fetch_domains = ["github.com"] assert!(state.allowed_web_fetch_domains.contains("github.com")); assert!(state.allowed_mcp_tools.is_empty()); assert!(state.allowed_mcp_servers.is_empty()); + assert_eq!(state.validated_mcp_server_grants_version, 0); + } + + #[test] + fn malformed_mcp_server_grants_version_is_legacy() { + for marker in ["-1", "\"invalid\""] { + let state: PermissionState = + toml::from_str(&format!("validated_mcp_server_grants_version = {marker}")).unwrap(); + assert_eq!(state.validated_mcp_server_grants_version, 0); + } } #[test] @@ -387,13 +480,139 @@ allowed_bash_commands = ["ls"] // ── Disk persistence roundtrip tests ───────────────────────── + async fn write_legacy_mcp_state(path: &std::path::Path) { + tokio::fs::write( + path, + r#" +edit_policy = "reject" +allow_bash_execute = true +allowed_bash_commands = ["cargo test"] +disallowed_bash_commands = ["rm"] +allowed_web_fetch_domains = ["example.com"] +allowed_mcp_tools = ["a__b__c"] +allowed_mcp_servers = ["a"] +"#, + ) + .await + .unwrap(); + } + + fn assert_legacy_mcp_state_migrated(state: &PermissionState) { + assert!(state.allowed_mcp_servers.is_empty()); + assert!(state.allowed_mcp_tools.contains("a__b__c")); + assert!(state.allow_bash_execute); + assert!(state.allowed_bash_commands.contains("cargo test")); + assert!(state.disallowed_bash_commands.contains("rm")); + assert!(state.allowed_web_fetch_domains.contains("example.com")); + assert_eq!(state.edit_policy, EditPolicy::Reject); + assert_eq!( + state.validated_mcp_server_grants_version, + VALIDATED_MCP_SERVER_GRANTS_VERSION + ); + } + + #[tokio::test] + async fn legacy_shared_mcp_server_grants_migrate_and_rewrite() { + let tmp = tempfile::tempdir().unwrap(); + let path = state_file_path(tmp.path(), None); + write_legacy_mcp_state(&path).await; + + assert_legacy_mcp_state_migrated(&load_state_from_dir(tmp.path(), None).await); + let rewritten: PermissionState = + toml::from_str(&tokio::fs::read_to_string(&path).await.unwrap()).unwrap(); + assert_legacy_mcp_state_migrated(&rewritten); + } + + #[tokio::test] + async fn failed_migration_rewrite_preserves_legacy_file_for_retry() { + fn fail_write(_: &std::path::Path, _: &str) -> std::io::Result<()> { + Err(std::io::Error::other("injected write failure")) + } + + let tmp = tempfile::tempdir().unwrap(); + let path = state_file_path(tmp.path(), None); + write_legacy_mcp_state(&path).await; + let legacy_contents = tokio::fs::read_to_string(&path).await.unwrap(); + + let in_memory = try_load_state_with_writer(&path, fail_write).await.unwrap(); + assert_legacy_mcp_state_migrated(&in_memory); + assert_eq!( + tokio::fs::read_to_string(&path).await.unwrap(), + legacy_contents + ); + let still_legacy: PermissionState = toml::from_str(&legacy_contents).unwrap(); + assert_eq!(still_legacy.validated_mcp_server_grants_version, 0); + assert!(still_legacy.allowed_mcp_servers.contains("a")); + + assert_legacy_mcp_state_migrated(&try_load_state(&path).await.unwrap()); + let rewritten: PermissionState = + toml::from_str(&tokio::fs::read_to_string(&path).await.unwrap()).unwrap(); + assert_legacy_mcp_state_migrated(&rewritten); + } + + #[tokio::test] + async fn current_and_future_mcp_server_grants_are_retained_exactly() { + for version in [ + VALIDATED_MCP_SERVER_GRANTS_VERSION, + VALIDATED_MCP_SERVER_GRANTS_VERSION + 1, + 4_294_967_296, + ] { + let tmp = tempfile::tempdir().unwrap(); + let mut state = PermissionState::default(); + state.validated_mcp_server_grants_version = version; + state.allowed_mcp_servers.insert("linear".to_owned()); + persist_state_to_dir(tmp.path(), &state, None).await; + + let loaded = load_state_from_dir(tmp.path(), None).await; + assert!(loaded.allowed_mcp_servers.contains("linear")); + assert_eq!(loaded.validated_mcp_server_grants_version, version); + let persisted: PermissionState = toml::from_str( + &tokio::fs::read_to_string(state_file_path(tmp.path(), None)) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!(persisted.validated_mcp_server_grants_version, version); + } + } + + #[tokio::test] + async fn per_client_legacy_migration_rewrites_only_loaded_file() { + let tmp = tempfile::tempdir().unwrap(); + let shared = state_file_path(tmp.path(), None); + let per_client = state_file_path(tmp.path(), Some("desktop")); + let mut shared_state = PermissionState::default(); + shared_state.allowed_mcp_servers.insert("shared".to_owned()); + persist_state_to_dir(tmp.path(), &shared_state, None).await; + write_legacy_mcp_state(&per_client).await; + + assert_legacy_mcp_state_migrated(&load_state_from_dir(tmp.path(), Some("desktop")).await); + let shared_after: PermissionState = + toml::from_str(&tokio::fs::read_to_string(shared).await.unwrap()).unwrap(); + assert!(shared_after.allowed_mcp_servers.contains("shared")); + let client_after: PermissionState = + toml::from_str(&tokio::fs::read_to_string(per_client).await.unwrap()).unwrap(); + assert_legacy_mcp_state_migrated(&client_after); + } + + #[tokio::test] + async fn per_client_fallback_migrates_shared_file() { + let tmp = tempfile::tempdir().unwrap(); + let shared = state_file_path(tmp.path(), None); + write_legacy_mcp_state(&shared).await; + + assert_legacy_mcp_state_migrated( + &load_state_from_dir(tmp.path(), Some("missing-client")).await, + ); + let shared_after: PermissionState = + toml::from_str(&tokio::fs::read_to_string(shared).await.unwrap()).unwrap(); + assert_legacy_mcp_state_migrated(&shared_after); + assert!(!state_file_path(tmp.path(), Some("missing-client")).exists()); + } + #[tokio::test] async fn persist_and_load_roundtrip() { let tmp = tempfile::tempdir().unwrap(); - let cwd_path = tmp.path().join("my-project"); - std::fs::create_dir_all(&cwd_path).unwrap(); - let _cwd = AbsPathBuf::new(cwd_path).unwrap(); - let mut state = PermissionState::default(); state.allow_bash_execute = true; state @@ -401,18 +620,8 @@ allowed_bash_commands = ["ls"] .insert("cargo build".to_string()); state.disallowed_bash_commands.insert("rm -rf".to_string()); - // Override the state dir to use our temp dir. - // We can't easily override grok_home(), so instead test - // the serialize/deserialize path directly with TOML. - let toml_str = toml::to_string_pretty(&state).unwrap(); - let dir = tmp.path().join("sessions").join("test"); - tokio::fs::create_dir_all(&dir).await.unwrap(); - let path = dir.join("permission.toml"); - tokio::fs::write(&path, &toml_str).await.unwrap(); - - let content = tokio::fs::read_to_string(&path).await.unwrap(); - let restored: PermissionState = toml::from_str(&content).unwrap(); - + persist_state_to_dir(tmp.path(), &state, None).await; + let restored = load_state_from_dir(tmp.path(), None).await; assert!(restored.allow_bash_execute); assert!(restored.allowed_bash_commands.contains("cargo build")); assert!(restored.disallowed_bash_commands.contains("rm -rf")); @@ -492,11 +701,17 @@ allowed_bash_commands = ["ls"] async fn try_load_state_valid_file() { let tmp = tempfile::tempdir().unwrap(); let path = tmp.path().join("permission.toml"); - tokio::fs::write(&path, "allow_bash_execute = true") + let mut expected = PermissionState::default(); + expected.allow_bash_execute = true; + tokio::fs::write(&path, toml::to_string_pretty(&expected).unwrap()) .await .unwrap(); let state = try_load_state(&path).await.unwrap(); assert!(state.allow_bash_execute); + assert_eq!( + state.validated_mcp_server_grants_version, + VALIDATED_MCP_SERVER_GRANTS_VERSION + ); } #[tokio::test] diff --git a/crates/codegen/xai-grok-workspace/src/permission/types.rs b/crates/codegen/xai-grok-workspace/src/permission/types.rs index 04d13d6..ddd6e21 100644 --- a/crates/codegen/xai-grok-workspace/src/permission/types.rs +++ b/crates/codegen/xai-grok-workspace/src/permission/types.rs @@ -211,11 +211,17 @@ impl<'de> Deserialize<'de> for EditPolicy { deserializer.deserialize_str(V) } } +#[derive(Debug, Clone)] +pub struct EditPathContext { + pub real_cwd: std::path::PathBuf, + pub display_cwd: Option, +} #[allow(clippy::large_enum_variant)] pub enum PermissionCommand { Request { access: AccessKind, tool_call_update: acp::ToolCallUpdate, + edit_path_context: Option, respond_to: oneshot::Sender, /// Session ID originating this request. Used to attribute /// permission events to child subagents. diff --git a/crates/codegen/xai-grok-workspace/src/session/tool_config.rs b/crates/codegen/xai-grok-workspace/src/session/tool_config.rs index 9c1484f..239ea1c 100644 --- a/crates/codegen/xai-grok-workspace/src/session/tool_config.rs +++ b/crates/codegen/xai-grok-workspace/src/session/tool_config.rs @@ -475,7 +475,7 @@ impl SessionContextFactory for WorkspaceSessionContextFactory { } fn build_terminal_backend(&self) -> crate::config::SessionTerminalBackend { crate::config::SessionTerminalBackend::local( - xai_grok_tools::computer::local::LocalTerminalBackend::with_persistent_shell(), + xai_grok_tools::computer::local::LocalTerminalBackend::new(), ) } fn registry_builder(&self) -> ToolRegistryBuilder { @@ -497,6 +497,10 @@ fn build_proxy_headers(base_url: &str) -> indexmap::IndexMap { format!("xai-grok-workspace/{version}"), ); headers.insert("x-grok-client-version".to_string(), version.to_string()); + headers.insert( + "x-grok-client-identifier".to_string(), + std::env::var("GROK_CLIENT_NAME").unwrap_or_else(|_| "grok-shell".to_string()), + ); if base_url.contains("cli-chat-proxy") || base_url.contains("chat-proxy") { headers.insert("X-XAI-Token-Auth".to_string(), "xai-grok-cli".to_string()); headers.insert( @@ -518,6 +522,9 @@ fn build_web_fetch_config() -> xai_grok_tools::implementations::grok_build::web_ if let Ok(proxy) = std::env::var("GROK_WEB_FETCH_PROXY") { params.proxy_endpoint = Some(proxy); } + if xai_grok_config::env_bool("GROK_WEB_FETCH_ALLOW_LOCAL") == Some(true) { + params.allow_local = Some(true); + } WebFetchConfig::Enabled { params } } fn default_web_search_model() -> String { diff --git a/crates/codegen/xai-ratatui-textarea/src/editor.rs b/crates/codegen/xai-ratatui-textarea/src/editor.rs index 2344c18..7e9ac68 100644 --- a/crates/codegen/xai-ratatui-textarea/src/editor.rs +++ b/crates/codegen/xai-ratatui-textarea/src/editor.rs @@ -1,4 +1,4 @@ -use std::ops::Range; +use std::ops::{Deref, Range}; use std::sync::Arc; use unicode_segmentation::{GraphemeCursor, UnicodeSegmentation as _}; @@ -32,6 +32,35 @@ pub enum EditCommand { DeleteToLineEnd, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum EditCommandCategory { + Insert, + Navigation, + Delete, + Kill, +} + +impl EditCommand { + pub(crate) fn category(self) -> EditCommandCategory { + match self { + Self::Insert(_) => EditCommandCategory::Insert, + Self::MoveGraphemeLeft + | Self::MoveGraphemeRight + | Self::MoveWordLeft(_) + | Self::MoveWordRight(_) + | Self::MoveLogicalLineStart + | Self::MoveLogicalLineEnd => EditCommandCategory::Navigation, + Self::DeleteGraphemeBackward | Self::DeleteGraphemeForward => { + EditCommandCategory::Delete + } + Self::DeleteWordBackward(_) + | Self::DeleteWordForward(_) + | Self::DeleteToLineStart + | Self::DeleteToLineEnd => EditCommandCategory::Kill, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct EditDelta { pub replaced_byte_range: Range, @@ -155,6 +184,14 @@ impl PartialEq for EditBuffer { impl Eq for EditBuffer {} +impl Deref for EditBuffer { + type Target = str; + + fn deref(&self) -> &Self::Target { + self.text() + } +} + impl EditBuffer { pub fn new() -> Self { Self::default() @@ -210,14 +247,14 @@ impl EditBuffer { #[must_use] pub fn insert_str(&mut self, text: &str) -> EditOutcome { let plan = self.plan_replace_byte_range(self.cursor_byte..self.cursor_byte, text, &[]); - self.apply_valid_plan(&plan) + self.apply_validated_plan(&plan) } /// Edit-result cursors keep right affinity when adjacent text merges into one grapheme. #[must_use] pub fn replace_byte_range(&mut self, range: Range, replacement: &str) -> EditOutcome { let plan = self.plan_replace_byte_range(range, replacement, &[]); - self.apply_valid_plan(&plan) + self.apply_validated_plan(&plan) } pub fn plan_replace_byte_range( @@ -380,13 +417,13 @@ impl EditBuffer { pub fn apply_plan(&mut self, plan: &EditPlan) -> Result { self.validate_plan(plan)?; - Ok(self.apply_valid_plan(plan)) + Ok(self.apply_validated_plan(plan)) } #[must_use] pub fn apply(&mut self, command: EditCommand) -> EditOutcome { let plan = self.plan_command(command, &[]); - self.apply_valid_plan(&plan) + self.apply_validated_plan(&plan) } fn make_plan( @@ -408,7 +445,7 @@ impl EditBuffer { } } - fn validate_plan(&self, plan: &EditPlan) -> Result<(), ApplyEditPlanError> { + pub(crate) fn validate_plan(&self, plan: &EditPlan) -> Result<(), ApplyEditPlanError> { if !Arc::ptr_eq(&plan.source_identity, &self.identity) || plan.source_generation != self.generation { @@ -447,7 +484,7 @@ impl EditBuffer { Ok(()) } - fn apply_valid_plan(&mut self, plan: &EditPlan) -> EditOutcome { + pub(crate) fn apply_validated_plan(&mut self, plan: &EditPlan) -> EditOutcome { let old_cursor = self.cursor_byte; let text_changed = plan.removed_text != plan.replacement; let inserted_len = plan.replacement.len(); diff --git a/crates/codegen/xai-ratatui-textarea/src/editor_keys.rs b/crates/codegen/xai-ratatui-textarea/src/editor_keys.rs index 12875f7..7fd9f47 100644 --- a/crates/codegen/xai-ratatui-textarea/src/editor_keys.rs +++ b/crates/codegen/xai-ratatui-textarea/src/editor_keys.rs @@ -4,6 +4,7 @@ use super::{EditCommand, WordStyle}; pub fn classify_key_event(event: &KeyEvent) -> Option { match event { + // Some terminals encode Ctrl-B/Ctrl-F as bare C0 characters. KeyEvent { code: KeyCode::Char('\u{0002}'), modifiers: KeyModifiers::NONE, @@ -21,20 +22,21 @@ pub fn classify_key_event(event: &KeyEvent) -> Option { } if *modifiers == (KeyModifiers::CONTROL | KeyModifiers::ALT) => { Some(EditCommand::DeleteWordBackward(WordStyle::Small)) } + // Kitty protocol loss can surface Backspace as raw BS or DEL; modifiers are unreliable. KeyEvent { - code: KeyCode::Backspace | KeyCode::Char('\u{0008}' | '\u{007f}'), + code: KeyCode::Char('\u{0008}' | '\u{007f}'), + .. + } => Some(EditCommand::DeleteGraphemeBackward), + KeyEvent { + code: KeyCode::Backspace, modifiers, .. } => Some(backspace_command(*modifiers)), KeyEvent { code: KeyCode::Delete, - modifiers: KeyModifiers::ALT | KeyModifiers::CONTROL, + modifiers, .. - } => Some(EditCommand::DeleteWordForward(WordStyle::Small)), - KeyEvent { - code: KeyCode::Delete, - .. - } => Some(EditCommand::DeleteGraphemeForward), + } => Some(delete_command(*modifiers)), KeyEvent { code: KeyCode::Char('w'), modifiers: KeyModifiers::CONTROL, @@ -44,14 +46,18 @@ pub fn classify_key_event(event: &KeyEvent) -> Option { )), KeyEvent { code: KeyCode::Left, - modifiers: KeyModifiers::ALT | KeyModifiers::CONTROL, + modifiers, .. - } => Some(EditCommand::MoveWordLeft(WordStyle::Small)), + } if modifiers.intersects(KeyModifiers::ALT | KeyModifiers::CONTROL) => { + Some(EditCommand::MoveWordLeft(WordStyle::Small)) + } KeyEvent { code: KeyCode::Right, - modifiers: KeyModifiers::ALT | KeyModifiers::CONTROL, + modifiers, .. - } => Some(EditCommand::MoveWordRight(WordStyle::Small)), + } if modifiers.intersects(KeyModifiers::ALT | KeyModifiers::CONTROL) => { + Some(EditCommand::MoveWordRight(WordStyle::Small)) + } KeyEvent { code: KeyCode::Char('a'), modifiers: KeyModifiers::CONTROL, @@ -114,9 +120,11 @@ pub fn classify_key_event(event: &KeyEvent) -> Option { } => Some(EditCommand::DeleteGraphemeForward), KeyEvent { code: KeyCode::Char('d'), - modifiers: KeyModifiers::ALT | KeyModifiers::SUPER, + modifiers, .. - } => Some(EditCommand::DeleteWordForward(WordStyle::Small)), + } if modifiers.intersects(KeyModifiers::ALT | KeyModifiers::SUPER) => { + Some(EditCommand::DeleteWordForward(WordStyle::Small)) + } KeyEvent { code: KeyCode::Char(character), modifiers: KeyModifiers::NONE | KeyModifiers::SHIFT, @@ -149,6 +157,7 @@ fn shifted_char(character: char) -> char { } fn backspace_command(modifiers: KeyModifiers) -> EditCommand { + // Backspace preserves exact historical chords; extra modifiers fall back to grapheme delete. match modifiers { KeyModifiers::ALT | KeyModifiers::CONTROL => { EditCommand::DeleteWordBackward(WordStyle::Small) @@ -157,3 +166,12 @@ fn backspace_command(modifiers: KeyModifiers) -> EditCommand { _ => EditCommand::DeleteGraphemeBackward, } } + +fn delete_command(modifiers: KeyModifiers) -> EditCommand { + // Delete accepts Shift in addition to a word modifier because enhanced protocols retain it. + if modifiers.intersects(KeyModifiers::ALT | KeyModifiers::CONTROL | KeyModifiers::SUPER) { + EditCommand::DeleteWordForward(WordStyle::Small) + } else { + EditCommand::DeleteGraphemeForward + } +} diff --git a/crates/codegen/xai-ratatui-textarea/src/editor_tests/keys.rs b/crates/codegen/xai-ratatui-textarea/src/editor_tests/keys.rs index 065b273..a970d0f 100644 --- a/crates/codegen/xai-ratatui-textarea/src/editor_tests/keys.rs +++ b/crates/codegen/xai-ratatui-textarea/src/editor_tests/keys.rs @@ -168,6 +168,7 @@ fn lifecycle_and_host_owned_keys_remain_unclassified() { key(KeyCode::Esc, KeyModifiers::NONE), key(KeyCode::Enter, KeyModifiers::NONE), key(KeyCode::Tab, KeyModifiers::NONE), + key(KeyCode::Char('\t'), KeyModifiers::NONE), key(KeyCode::BackTab, KeyModifiers::SHIFT), key(KeyCode::Up, KeyModifiers::NONE), key(KeyCode::Down, KeyModifiers::NONE), @@ -209,37 +210,37 @@ fn backspace_delete_and_raw_encodings_have_modifier_parity() { ( KeyModifiers::SUPER, EditCommand::DeleteToLineStart, - EditCommand::DeleteGraphemeForward, + EditCommand::DeleteWordForward(WordStyle::Small), ), ( KeyModifiers::CONTROL | KeyModifiers::SHIFT, EditCommand::DeleteGraphemeBackward, - EditCommand::DeleteGraphemeForward, + EditCommand::DeleteWordForward(WordStyle::Small), ), ( KeyModifiers::ALT | KeyModifiers::SHIFT, EditCommand::DeleteGraphemeBackward, - EditCommand::DeleteGraphemeForward, + EditCommand::DeleteWordForward(WordStyle::Small), ), ( KeyModifiers::SUPER | KeyModifiers::SHIFT, EditCommand::DeleteGraphemeBackward, - EditCommand::DeleteGraphemeForward, + EditCommand::DeleteWordForward(WordStyle::Small), ), ( KeyModifiers::CONTROL | KeyModifiers::ALT, EditCommand::DeleteGraphemeBackward, - EditCommand::DeleteGraphemeForward, + EditCommand::DeleteWordForward(WordStyle::Small), ), ( KeyModifiers::CONTROL | KeyModifiers::SUPER, EditCommand::DeleteGraphemeBackward, - EditCommand::DeleteGraphemeForward, + EditCommand::DeleteWordForward(WordStyle::Small), ), ( KeyModifiers::ALT | KeyModifiers::SUPER, EditCommand::DeleteGraphemeBackward, - EditCommand::DeleteGraphemeForward, + EditCommand::DeleteWordForward(WordStyle::Small), ), ( KeyModifiers::META, @@ -254,7 +255,7 @@ fn backspace_delete_and_raw_encodings_have_modifier_parity() { ( KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT, EditCommand::DeleteGraphemeBackward, - EditCommand::DeleteGraphemeForward, + EditCommand::DeleteWordForward(WordStyle::Small), ), ]; @@ -270,11 +271,15 @@ fn backspace_delete_and_raw_encodings_have_modifier_parity() { Some(expected_delete), "{delete:?}" ); - assert_eq!(classify_key_event(&raw_bs), backspace_command, "{raw_bs:?}"); + assert_eq!( + classify_key_event(&raw_bs), + Some(EditCommand::DeleteGraphemeBackward), + "{raw_bs:?}", + ); assert_eq!( classify_key_event(&raw_del), - backspace_command, - "{raw_del:?}" + Some(EditCommand::DeleteGraphemeBackward), + "{raw_del:?}", ); } } diff --git a/crates/codegen/xai-ratatui-textarea/src/textarea.rs b/crates/codegen/xai-ratatui-textarea/src/textarea.rs index ac11155..ad0e551 100644 --- a/crates/codegen/xai-ratatui-textarea/src/textarea.rs +++ b/crates/codegen/xai-ratatui-textarea/src/textarea.rs @@ -1,3 +1,7 @@ +use crate::editor::{ + ApplyEditPlanError, EditBuffer, EditCommand, EditCommandCategory, EditOutcome, EditPlan, + WordStyle, classify_key_event, +}; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; @@ -184,8 +188,7 @@ impl ClickTracker { #[derive(Debug)] pub struct TextArea { - text: String, - cursor_pos: usize, + text: EditBuffer, wrap_cache: RefCell>, preferred_col: Option, elements: Vec, @@ -407,8 +410,7 @@ impl TextArea { pub fn new() -> Self { Self { - text: String::new(), - cursor_pos: 0, + text: EditBuffer::new(), wrap_cache: RefCell::new(None), preferred_col: None, elements: Vec::new(), @@ -477,13 +479,144 @@ impl TextArea { grapheme_display_width_with_tab(grapheme, self.tab_width) } + fn element_ranges(&self) -> Vec> { + self.elements + .iter() + .map(|element| element.range.clone()) + .collect() + } + + fn adjust_position_after_edit( + position: usize, + replaced: &Range, + inserted_len: usize, + ) -> usize { + if position < replaced.start { + position + } else if position <= replaced.end { + replaced.start + inserted_len + } else { + position - replaced.len() + inserted_len + } + } + + fn is_semantic_edit(plan: &EditPlan) -> bool { + plan.removed_text() != plan.replacement() || !plan.replaced_byte_range().is_empty() + } + + fn assert_valid_edit_plan(&self, plan: &EditPlan) { + if let Err(error) = self.text.validate_plan(plan) { + panic!("textarea edit invariant failed: {error:?}"); + } + } + + fn apply_validated_edit_plan( + &mut self, + plan: EditPlan, + mutation_kind: Option, + ) -> EditOutcome { + let semantic_edit = Self::is_semantic_edit(&plan); + let replaced = plan.replaced_byte_range(); + let inserted_len = plan.replacement().len(); + let outcome = self.text.apply_validated_plan(&plan); + if semantic_edit { + self.update_elements_after_replace(replaced.start, replaced.end, inserted_len); + if let Some(selection) = &mut self.selection { + selection.anchor = + Self::adjust_position_after_edit(selection.anchor, &replaced, inserted_len); + selection.head = + Self::adjust_position_after_edit(selection.head, &replaced, inserted_len); + } + if self + .selection + .is_some_and(|selection| selection.anchor == selection.head) + { + self.selection = None; + } + self.wrap_cache.replace(None); + if mutation_kind == Some(MutationKind::Kill) { + self.kill_buffer = plan.into_removed_text(); + } + } + if semantic_edit || !matches!(outcome, EditOutcome::Unchanged) { + self.preferred_col = None; + self.scroll_override = None; + } + outcome + } + + fn try_apply_edit_plan( + &mut self, + plan: EditPlan, + mutation_kind: Option, + ) -> Result { + self.text.validate_plan(&plan)?; + let semantic_edit = Self::is_semantic_edit(&plan); + if semantic_edit && let Some(kind) = mutation_kind { + self.pre_mutate(kind); + } + let outcome = self.apply_validated_edit_plan(plan, mutation_kind); + if semantic_edit && mutation_kind.is_some() { + self.post_mutate(); + } + Ok(outcome) + } + + fn apply_edit_plan( + &mut self, + plan: EditPlan, + mutation_kind: Option, + ) -> EditOutcome { + match self.try_apply_edit_plan(plan, mutation_kind) { + Ok(outcome) => outcome, + Err(error) => panic!("textarea edit invariant failed: {error:?}"), + } + } + + fn apply_edit_command( + &mut self, + command: EditCommand, + mutation_kind: Option, + ) -> EditOutcome { + let category = command.category(); + let ranges = self.element_ranges(); + let plan = self.text.plan_command(command, &ranges); + let outcome = self.apply_edit_plan(plan, mutation_kind); + if category == EditCommandCategory::Navigation { + self.preferred_col = None; + self.scroll_override = None; + } + outcome + } + + fn plan_edit_replacement(&self, range: Range, replacement: &str) -> EditPlan { + let replacement = self.expand_tabs(replacement).into_owned(); + let ranges = self.element_ranges(); + self.text + .plan_replace_byte_range(range, &replacement, &ranges) + } + + fn apply_edit_replacement( + &mut self, + range: Range, + replacement: &str, + mutation_kind: Option, + ) { + let plan = self.plan_edit_replacement(range, replacement); + self.apply_edit_plan(plan, mutation_kind); + } + pub fn set_text(&mut self, text: &str) { + let cursor = self.cursor(); + let plan = self.plan_edit_replacement(0..self.text.len(), text); + self.assert_valid_edit_plan(&plan); self.pre_mutate(MutationKind::Replace); - self.text = self.expand_tabs(text).into_owned(); - self.cursor_pos = self.cursor_pos.clamp(0, self.text.len()); + let _ = self.text.apply_validated_plan(&plan); + self.elements.clear(); + let len = self.text.len(); + self.set_cursor_inner(cursor.min(len)); self.wrap_cache.replace(None); self.preferred_col = None; - self.elements.clear(); // Kill buffer intentionally survives: yank is independent of buffer // content, so a cut can be pasted into a fresh prompt after send. self.selection = None; @@ -502,7 +635,7 @@ impl TextArea { } pub fn text(&self) -> &str { - &self.text + self.text.text() } pub fn insert_str(&mut self, text: &str) { @@ -520,101 +653,46 @@ impl TextArea { self.undo.last_kind = None; } } - self.pre_mutate(MutationKind::Insert); - self.insert_str_inner(self.cursor_pos, text); + self.apply_edit_replacement( + self.cursor()..self.cursor(), + text, + Some(MutationKind::Insert), + ); if let Some(last) = text.chars().last() { self.undo.last_insert_ws = last.is_whitespace(); } - self.post_mutate(); } pub fn insert_str_at(&mut self, pos: usize, text: &str) { if text.is_empty() { return; } - self.pre_mutate(MutationKind::Insert); - self.insert_str_inner(pos, text); + self.apply_edit_replacement(pos..pos, text, Some(MutationKind::Insert)); if let Some(last) = text.chars().last() { self.undo.last_insert_ws = last.is_whitespace(); } - self.post_mutate(); - } - - /// Raw text insertion — no undo tracking. Called by all insert paths. - /// Returns the number of bytes actually inserted (post tab expansion). - fn insert_str_inner(&mut self, pos: usize, text: &str) -> usize { - if text.is_empty() { - return 0; - } - let pos = self.clamp_pos_for_insertion(pos); - let text = self.expand_tabs(text); - let inserted_len = text.len(); - self.text.insert_str(pos, &text); - self.wrap_cache.replace(None); - if pos <= self.cursor_pos { - self.cursor_pos += inserted_len; - } - self.shift_elements(pos, 0, inserted_len); - self.preferred_col = None; - inserted_len } pub fn replace_range(&mut self, range: std::ops::Range, text: &str) { - self.pre_mutate(MutationKind::Replace); - let range = self.expand_range_to_element_boundaries(range); - self.replace_range_raw(range, text); - self.post_mutate(); - } - - /// Returns the number of bytes inserted (post tab expansion). - fn replace_range_raw(&mut self, range: std::ops::Range, text: &str) -> usize { - assert!(range.start <= range.end); - let start = range.start.clamp(0, self.text.len()); - let end = range.end.clamp(0, self.text.len()); - let text = self.expand_tabs(text); - let removed_len = end - start; - let inserted_len = text.len(); - if removed_len == 0 && inserted_len == 0 { - return 0; - } - // Any text mutation should snap the viewport back to follow the cursor. - self.scroll_override = None; - let diff = inserted_len as isize - removed_len as isize; - - self.text.replace_range(start..end, &text); - self.wrap_cache.replace(None); - self.preferred_col = None; - self.update_elements_after_replace(start, end, inserted_len); - - // Update the cursor position to account for the edit. - self.cursor_pos = if self.cursor_pos < start { - // Cursor was before the edited range – no shift. - self.cursor_pos - } else if self.cursor_pos <= end { - // Cursor was inside the replaced range – move to end of the new text. - start + inserted_len - } else { - // Cursor was after the replaced range – shift by the length diff. - ((self.cursor_pos as isize) + diff) as usize - } - .min(self.text.len()); - - // Ensure cursor is not inside an element - self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos); - inserted_len + self.apply_edit_replacement(range, text, Some(MutationKind::Replace)); } pub fn cursor(&self) -> usize { - self.cursor_pos + self.text.cursor_byte() } pub fn set_cursor(&mut self, pos: usize) { - self.cursor_pos = pos.clamp(0, self.text.len()); - self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos); + let pos = pos.clamp(0, self.text.len()); + let pos = self.clamp_pos_to_nearest_boundary(pos); + self.set_cursor_inner(pos); self.preferred_col = None; self.scroll_override = None; } + fn set_cursor_inner(&mut self, pos: usize) { + let _ = self.text.set_cursor_byte(pos); + } + /// Override the scroll position, bypassing cursor-follow logic. /// /// When set to `Some(offset)`, `effective_scroll` will use this offset @@ -656,9 +734,9 @@ impl TextArea { let tw = self.text_width(area); let lines = self.wrapped_lines(tw); let effective_scroll = self.effective_scroll(area.height, &lines, state.scroll); - let mut i = Self::wrapped_line_index_by_start(&lines, self.cursor_pos)?; + let mut i = Self::wrapped_line_index_by_start(&lines, self.cursor())?; let ls = &lines[i]; - let mut col = self.display_width_of_range(ls.start, self.cursor_pos) as u16; + let mut col = self.display_width_of_range(ls.start, self.cursor()) as u16; // If the cursor sits at the exact wrap boundary (col == content width), // show it at the start of the next visual line instead of on the @@ -915,9 +993,8 @@ impl TextArea { return false; }; let start = range.start; - self.pre_mutate(MutationKind::Replace); - self.replace_range_raw(range, ""); - self.cursor_pos = start.min(self.text.len()); + self.apply_edit_replacement(range, "", Some(MutationKind::Replace)); + self.set_cursor_inner(start.min(self.text.len())); self.post_mutate(); self.selection = None; true @@ -1013,7 +1090,7 @@ impl TextArea { .find(|e| pos >= e.range.start && pos <= e.range.end && !e.range.is_empty())?; let id = elem.id; let start = elem.range.start; - self.cursor_pos = start; + self.set_cursor_inner(start); self.preferred_col = None; self.drag_anchor = Some(start); self.pending_element_event = Some(TextElementEvent { @@ -1143,11 +1220,12 @@ impl TextArea { }); // Place cursor on the last character of the // selection (neovim style), not one past the end. - self.cursor_pos = self.text[start..end] + let cursor = self.text[start..end] .char_indices() .next_back() .map(|(i, _)| start + i) .unwrap_or(start); + self.set_cursor_inner(cursor); self.preferred_col = None; if let Some(text) = self.selected_text() { self.set_clipboard_text(text); @@ -1155,7 +1233,7 @@ impl TextArea { return MouseAction::SelectionFinished; } // Clicked on whitespace — just place cursor. - self.cursor_pos = pos; + self.set_cursor_inner(pos); self.preferred_col = None; MouseAction::CursorPlaced } @@ -1175,7 +1253,7 @@ impl TextArea { }); // Keep cursor at the click position (like neovim), // not at the end of the selection. - self.cursor_pos = pos; + self.set_cursor_inner(pos); self.preferred_col = None; if let Some(text) = self.selected_text() { self.set_clipboard_text(text); @@ -1194,7 +1272,7 @@ impl TextArea { } self.drag_anchor = Some(pos); - self.cursor_pos = pos; + self.set_cursor_inner(pos); self.preferred_col = None; MouseAction::CursorPlaced @@ -1294,7 +1372,7 @@ impl TextArea { self.drag_active = true; self.selection = Some(Selection { anchor, head }); } - self.cursor_pos = head; + self.set_cursor_inner(head); self.preferred_col = None; if self.selection.is_some() { @@ -1366,7 +1444,7 @@ impl TextArea { if let Some(sel) = &mut self.selection { sel.head = new_pos; } - self.cursor_pos = new_pos; + self.set_cursor_inner(new_pos); } MouseAction::Scrolled } @@ -1402,7 +1480,7 @@ impl TextArea { if let Some(sel) = &mut self.selection { sel.head = new_pos; } - self.cursor_pos = new_pos; + self.set_cursor_inner(new_pos); } MouseAction::Scrolled } @@ -1527,26 +1605,6 @@ impl TextArea { } } - /// Classify the atomic unit in `start..end` for word navigation. - /// - /// Elements are treated as their own navigable unit. Plain-text units use - /// the same word classes as double-click selection. - fn atomic_unit_class(&self, start: usize, end: usize) -> Option { - if start >= end { - return None; - } - - if self - .elements - .iter() - .any(|e| start >= e.range.start && end <= e.range.end) - { - return Some(3); - } - - self.text[start..end].chars().next().map(Self::char_class) - } - /// Find the start of the word containing `pos` (for double-click selection). /// /// Uses vim-style word classes: word chars (alphanumeric + `_`), punctuation, @@ -1617,7 +1675,7 @@ impl TextArea { fn current_display_col(&self) -> usize { let bol = self.beginning_of_current_line(); - self.display_width_of_range(bol, self.cursor_pos) + self.display_width_of_range(bol, self.cursor()) } /// Compute the display width of the buffer range `[from..to)`. @@ -1789,9 +1847,10 @@ impl TextArea { line_end: usize, target_col: usize, ) { - self.cursor_pos = self + let cursor = self .display_col_to_buffer_pos(line_start, line_end, target_col) .0; + self.set_cursor_inner(cursor); } fn beginning_of_line(&self, pos: usize) -> usize { @@ -1805,7 +1864,7 @@ impl TextArea { 0 } fn beginning_of_current_line(&self) -> usize { - self.beginning_of_line(self.cursor_pos) + self.beginning_of_line(self.cursor()) } fn end_of_line(&self, pos: usize) -> usize { @@ -1818,7 +1877,7 @@ impl TextArea { self.text.len() } fn end_of_current_line(&self) -> usize { - self.end_of_line(self.cursor_pos) + self.end_of_line(self.cursor()) } /// Check if a byte position is inside (strictly within) an element. @@ -1828,23 +1887,18 @@ impl TextArea { .any(|e| pos >= e.range.start && pos < e.range.end) } - /// Map a base US-keyboard character to its Shift-modified form. - /// - /// With the kitty keyboard protocol (`DISAMBIGUATE_ESCAPE_CODES`), some - /// terminals (notably VS Code's xterm.js) report shifted characters using - /// the **base** key codepoint with the SHIFT modifier instead of the - /// already-shifted codepoint. For example, Shift+2 may arrive as - /// `Char('2') + SHIFT` rather than `Char('@') + SHIFT`. This function - /// translates the base character so the correct symbol is inserted. - /// - /// Characters that are already shifted (e.g. `@`, `#`) or not in the US - /// layout map are returned unchanged. - fn apply_shift(c: char) -> char { - if c.is_ascii_lowercase() { - c.to_ascii_uppercase() - } else { - c + fn apply_classified_command(&mut self, command: EditCommand) { + if let EditCommand::Insert(character) = command { + self.insert_str(&character.to_string()); + return; } + let mutation_kind = match command.category() { + EditCommandCategory::Insert => unreachable!("insert commands return above"), + EditCommandCategory::Navigation => None, + EditCommandCategory::Delete => Some(MutationKind::Delete), + EditCommandCategory::Kill => Some(MutationKind::Kill), + }; + self.apply_edit_command(command, mutation_kind); } pub fn input(&mut self, event: KeyEvent) { @@ -1852,38 +1906,16 @@ impl TextArea { // When a selection is active, certain keys interact with the selected // range rather than performing their normal single-char action. if self.selection.is_some() { + if let Some(EditCommand::Insert(character)) = classify_key_event(&event) { + self.begin_undo_group(); + if !self.delete_selection() { + self.clear_selection(); + } + self.insert_str(&character.to_string()); + self.end_undo_group(); + return; + } match event { - KeyEvent { - code: KeyCode::Char(c), - modifiers: KeyModifiers::NONE | KeyModifiers::SHIFT, - .. - } if c != '\x08' && c != '\x7f' => { - let c = if event.modifiers.contains(KeyModifiers::SHIFT) { - Self::apply_shift(c) - } else { - c - }; - self.begin_undo_group(); - if !self.delete_selection() { - self.clear_selection(); - } - self.insert_str(&c.to_string()); - self.end_undo_group(); - return; - } - KeyEvent { - code: KeyCode::Char(c), - modifiers, - .. - } if crate::is_altgr(modifiers) => { - self.begin_undo_group(); - if !self.delete_selection() { - self.clear_selection(); - } - self.insert_str(&c.to_string()); - self.end_undo_group(); - return; - } // Enter / Ctrl-J/M → replace selection with newline. KeyEvent { code: KeyCode::Char('j' | 'm'), @@ -1948,42 +1980,12 @@ impl TextArea { } } + if let Some(command) = classify_key_event(&event) { + self.apply_classified_command(command); + return; + } + match event { - // Some terminals (or configurations) send Control key chords as - // C0 control characters without reporting the CONTROL modifier. - // Handle common fallbacks for Ctrl-B/Ctrl-F here so they don't get - // inserted as literal control bytes. - KeyEvent { code: KeyCode::Char('\u{0002}'), modifiers: KeyModifiers::NONE, .. } /* ^B */ => { - self.move_cursor_left(); - } - KeyEvent { code: KeyCode::Char('\u{0006}'), modifiers: KeyModifiers::NONE, .. } /* ^F */ => { - self.move_cursor_right(); - } - // When the Kitty keyboard protocol gets silently popped (e.g. by a - // terminal emulator losing track of enhancement state after heavy - // output or focus changes), Backspace can arrive as raw DEL (0x7F) - // or BS (0x08) characters instead of KeyCode::Backspace. Intercept - // them here so they don't get inserted as invisible garbage. - KeyEvent { code: KeyCode::Char('\u{007f}'), .. } /* DEL */ | - KeyEvent { code: KeyCode::Char('\u{0008}'), modifiers: KeyModifiers::NONE, .. } /* BS */ => { - self.delete_backward(1); - } - KeyEvent { - code: KeyCode::Char(c), - // Insert plain characters (and Shift-modified). Do NOT insert when ALT is held, - // because many terminals map Option/Meta combos to ALT+ (e.g. ESC f/ESC b) - // for word navigation. Those are handled explicitly below. - // Also exclude \x08 (BS) and \x7f (DEL) which are handled as backspace/delete. - modifiers: KeyModifiers::NONE | KeyModifiers::SHIFT, - .. - } if c != '\x08' && c != '\x7f' => { - let c = if event.modifiers.contains(KeyModifiers::SHIFT) { - Self::apply_shift(c) - } else { - c - }; - self.insert_str(&c.to_string()); - } KeyEvent { code: KeyCode::Char('j' | 'm'), modifiers: KeyModifiers::CONTROL, @@ -1993,124 +1995,6 @@ impl TextArea { code: KeyCode::Enter, .. } => self.insert_str("\n"), - KeyEvent { - code: KeyCode::Char('h'), - modifiers, - .. - } if modifiers == (KeyModifiers::CONTROL | KeyModifiers::ALT) => { - self.delete_backward_word() - } - // Must come after explicit Ctrl+Alt bindings so they aren't shadowed. - KeyEvent { - code: KeyCode::Char(c), - modifiers, - .. - } if crate::is_altgr(modifiers) => { - self.insert_str(&c.to_string()); - } - KeyEvent { - code: KeyCode::Backspace, - modifiers: KeyModifiers::ALT, - .. - } => self.delete_backward_word(), - // Cmd+Backspace (macOS): delete from cursor to beginning of line. - KeyEvent { - code: KeyCode::Backspace, - modifiers: KeyModifiers::SUPER, - .. - } => { - self.kill_to_beginning_of_line(); - } - // Ctrl+Backspace: delete previous word (common on Linux/Windows). - KeyEvent { - code: KeyCode::Backspace, - modifiers: KeyModifiers::CONTROL, - .. - } => self.delete_backward_word(), - KeyEvent { - code: KeyCode::Backspace | KeyCode::Char('\x08'), - .. - } - | KeyEvent { - code: KeyCode::Char('h'), - modifiers: KeyModifiers::CONTROL, - .. - } => self.delete_backward(1), - // Delete forward word: Alt+Delete, Alt+D (Emacs Meta-d), - // Cmd+D (macOS Kitty), Ctrl+Delete (Linux/Windows; mirrors Ctrl+Backspace). - KeyEvent { - code: KeyCode::Delete, - modifiers: KeyModifiers::ALT, - .. - } - | KeyEvent { - code: KeyCode::Delete, - modifiers: KeyModifiers::CONTROL, - .. - } - | KeyEvent { - code: KeyCode::Char('d'), - modifiers: KeyModifiers::ALT, - .. - } - | KeyEvent { - code: KeyCode::Char('d'), - modifiers: KeyModifiers::SUPER, - .. - } => self.delete_forward_word(), - KeyEvent { - code: KeyCode::Delete, - .. - } - | KeyEvent { - code: KeyCode::Char('d'), - modifiers: KeyModifiers::CONTROL, - .. - } => self.delete_forward(1), - - // Readline parity: C-w is unix-word-rubout; M-DEL/C-Backspace - // above intentionally keep chunked delete_backward_word. - KeyEvent { - code: KeyCode::Char('w'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.delete_backward_unix_word(); - } - // Meta-b -> move to beginning of previous word - // Meta-f -> move to end of next word - // Many terminals map Option (macOS) to Alt. Some send Alt|Shift, so match contains(ALT). - KeyEvent { - code: KeyCode::Char('b'), - modifiers: KeyModifiers::ALT, - .. - } => { - self.set_cursor(self.beginning_of_previous_word()); - } - KeyEvent { - code: KeyCode::Char('f'), - modifiers: KeyModifiers::ALT, - .. - } => { - self.set_cursor(self.end_of_next_word()); - } - // Ctrl+U / terminal-translated Cmd+Backspace (^U): kill cursor → BOL. - // Many terminals map Cmd+Backspace to ^U; kill_current_line would wipe - // text after the cursor. - KeyEvent { - code: KeyCode::Char('u'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.kill_to_beginning_of_line(); - } - KeyEvent { - code: KeyCode::Char('k'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.kill_to_end_of_line(); - } KeyEvent { code: KeyCode::Char('y'), modifiers: KeyModifiers::CONTROL, @@ -2152,62 +2036,6 @@ impl TextArea { } } - // Cursor movement - KeyEvent { - code: KeyCode::Left, - modifiers: KeyModifiers::NONE, - .. - } => { - self.move_cursor_left(); - } - KeyEvent { - code: KeyCode::Right, - modifiers: KeyModifiers::NONE, - .. - } => { - self.move_cursor_right(); - } - KeyEvent { - code: KeyCode::Char('b'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.move_cursor_left(); - } - KeyEvent { - code: KeyCode::Char('f'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.move_cursor_right(); - } - // Some terminals send Alt+Arrow for word-wise movement: - // Option/Left -> Alt+Left (previous word start) - // Option/Right -> Alt+Right (next word end) - KeyEvent { - code: KeyCode::Left, - modifiers: KeyModifiers::ALT, - .. - } - | KeyEvent { - code: KeyCode::Left, - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.set_cursor(self.beginning_of_previous_word()); - } - KeyEvent { - code: KeyCode::Right, - modifiers: KeyModifiers::ALT, - .. - } - | KeyEvent { - code: KeyCode::Right, - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.set_cursor(self.end_of_next_word()); - } // Cmd+Left / Cmd+Right (macOS): terminals using the Kitty keyboard // protocol (Ghostty, Kitty, WezTerm) send these as Super+Arrow. KeyEvent { @@ -2252,26 +2080,12 @@ impl TextArea { } => { self.move_cursor_to_beginning_of_line(false); } - KeyEvent { - code: KeyCode::Char('a'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.move_cursor_to_beginning_of_line(true); - } KeyEvent { code: KeyCode::End, .. } => { self.move_cursor_to_end_of_line(false); } - KeyEvent { - code: KeyCode::Char('e'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.move_cursor_to_end_of_line(true); - } _o => { #[cfg(feature = "debug-logs")] tracing::debug!("Unhandled key event in TextArea: {:?}", _o); @@ -2284,16 +2098,15 @@ impl TextArea { /// Create a snapshot of the current textarea state. fn snapshot(&self) -> UndoEntry { UndoEntry { - text: self.text.clone(), - cursor: self.cursor_pos, + text: self.text().to_owned(), + cursor: self.cursor(), elements: self.elements.clone(), } } /// Restore the textarea state from a snapshot. fn restore(&mut self, entry: UndoEntry) { - self.text = entry.text; - self.cursor_pos = entry.cursor; + self.text = EditBuffer::from_parts(entry.text, entry.cursor); self.elements = entry.elements; self.wrap_cache.replace(None); self.preferred_col = None; @@ -2322,7 +2135,7 @@ impl TextArea { None => true, Some(prev) => { prev != kind - || self.cursor_pos != self.undo.last_cursor + || self.cursor() != self.undo.last_cursor || matches!( kind, MutationKind::Kill | MutationKind::Element | MutationKind::Replace @@ -2344,7 +2157,7 @@ impl TextArea { /// Update `last_cursor` after a mutation completes so the next `pre_mutate` /// can detect cursor jumps. fn post_mutate(&mut self) { - self.undo.last_cursor = self.cursor_pos; + self.undo.last_cursor = self.cursor(); } /// Clear the undo/redo history, leaving the current text and cursor @@ -2360,7 +2173,7 @@ impl TextArea { self.undo.stack.clear(); self.undo.redo.clear(); self.undo.last_kind = None; - self.undo.last_cursor = self.cursor_pos; + self.undo.last_cursor = self.cursor(); } /// Undo the last mutation. Returns `true` if there was something to undo. @@ -2372,7 +2185,7 @@ impl TextArea { self.restore(entry); // Reset batching — next mutation starts a fresh group. self.undo.last_kind = None; - self.undo.last_cursor = self.cursor_pos; + self.undo.last_cursor = self.cursor(); true } else { false @@ -2388,7 +2201,7 @@ impl TextArea { self.restore(entry); // Reset batching — next mutation starts a fresh group. self.undo.last_kind = None; - self.undo.last_cursor = self.cursor_pos; + self.undo.last_cursor = self.cursor(); true } else { false @@ -2430,8 +2243,8 @@ impl TextArea { if self.undo.group_depth == 0 { if let Some(checkpoint) = self.undo.group_checkpoint.take() { // Only push if state actually changed. - let changed = checkpoint.text != self.text - || checkpoint.cursor != self.cursor_pos + let changed = checkpoint.text.as_str() != self.text() + || checkpoint.cursor != self.cursor() || checkpoint.elements.len() != self.elements.len(); if changed { self.undo.stack.push(checkpoint); @@ -2443,7 +2256,7 @@ impl TextArea { } // Reset batching state so the next mutation starts fresh. self.undo.last_kind = None; - self.undo.last_cursor = self.cursor_pos; + self.undo.last_cursor = self.cursor(); } } @@ -2462,64 +2275,76 @@ impl TextArea { } // Reset batching state. self.undo.last_kind = None; - self.undo.last_cursor = self.cursor_pos; + self.undo.last_cursor = self.cursor(); } // ####### Input Functions ####### pub fn delete_backward(&mut self, n: usize) { - if n == 0 || self.cursor_pos == 0 { + if n == 0 { return; } - self.pre_mutate(MutationKind::Delete); - let mut target = self.cursor_pos; + if n == 1 { + self.apply_edit_command( + EditCommand::DeleteGraphemeBackward, + Some(MutationKind::Delete), + ); + return; + } + self.begin_undo_group(); for _ in 0..n { - target = self.prev_atomic_boundary(target); - if target == 0 { + if matches!( + self.apply_edit_command( + EditCommand::DeleteGraphemeBackward, + Some(MutationKind::Delete), + ), + EditOutcome::Unchanged + ) { break; } } - let range = self.expand_range_to_element_boundaries(target..self.cursor_pos); - self.replace_range_raw(range, ""); - self.post_mutate(); + self.end_undo_group(); } pub fn delete_forward(&mut self, n: usize) { - if n == 0 || self.cursor_pos >= self.text.len() { + if n == 0 { return; } - self.pre_mutate(MutationKind::Delete); - let mut target = self.cursor_pos; + if n == 1 { + self.apply_edit_command( + EditCommand::DeleteGraphemeForward, + Some(MutationKind::Delete), + ); + return; + } + self.begin_undo_group(); for _ in 0..n { - target = self.next_atomic_boundary(target); - if target >= self.text.len() { + if matches!( + self.apply_edit_command( + EditCommand::DeleteGraphemeForward, + Some(MutationKind::Delete), + ), + EditOutcome::Unchanged + ) { break; } } - let range = self.expand_range_to_element_boundaries(self.cursor_pos..target); - self.replace_range_raw(range, ""); - self.post_mutate(); + self.end_undo_group(); } pub fn delete_backward_word(&mut self) { - let start = self.beginning_of_previous_word(); - if start >= self.cursor_pos { - return; - } - self.pre_mutate(MutationKind::Kill); - self.kill_range(start..self.cursor_pos); - self.post_mutate(); + self.apply_edit_command( + EditCommand::DeleteWordBackward(WordStyle::Small), + Some(MutationKind::Kill), + ); } /// readline `unix-word-rubout` (whitespace-delimited), vs /// [`Self::delete_backward_word`]'s punctuation-chunked M-DEL semantics. pub fn delete_backward_unix_word(&mut self) { - let start = self.beginning_of_previous_unix_word(); - if start >= self.cursor_pos { - return; - } - self.pre_mutate(MutationKind::Kill); - self.kill_range(start..self.cursor_pos); - self.post_mutate(); + self.apply_edit_command( + EditCommand::DeleteWordBackward(WordStyle::WhitespaceDelimited), + Some(MutationKind::Kill), + ); } /// Delete text to the right of the cursor using readline-style word semantics. @@ -2528,46 +2353,18 @@ impl TextArea { /// by `end_of_next_word()`. Any delimiters between the cursor and that word /// (whitespace, punctuation, newlines) are included in the deletion. pub fn delete_forward_word(&mut self) { - let end = self.end_of_next_word(); - if end > self.cursor_pos { - self.pre_mutate(MutationKind::Kill); - self.kill_range(self.cursor_pos..end); - self.post_mutate(); - } + self.apply_edit_command( + EditCommand::DeleteWordForward(WordStyle::Small), + Some(MutationKind::Kill), + ); } pub fn kill_to_end_of_line(&mut self) { - let eol = self.end_of_current_line(); - let range = if self.cursor_pos == eol { - if eol < self.text.len() { - Some(self.cursor_pos..eol + 1) - } else { - None - } - } else { - Some(self.cursor_pos..eol) - }; - - if let Some(range) = range { - self.pre_mutate(MutationKind::Kill); - self.kill_range(range); - self.post_mutate(); - } + self.apply_edit_command(EditCommand::DeleteToLineEnd, Some(MutationKind::Kill)); } pub fn kill_to_beginning_of_line(&mut self) { - let bol = self.beginning_of_current_line(); - let range = if self.cursor_pos == bol { - if bol > 0 { Some(bol - 1..bol) } else { None } - } else { - Some(bol..self.cursor_pos) - }; - - if let Some(range) = range { - self.pre_mutate(MutationKind::Kill); - self.kill_range(range); - self.post_mutate(); - } + self.apply_edit_command(EditCommand::DeleteToLineStart, Some(MutationKind::Kill)); } /// Kill the entire current line (BOL to EOL), regardless of cursor position. @@ -2583,9 +2380,7 @@ impl TextArea { }; if let Some(range) = range { - self.pre_mutate(MutationKind::Kill); - self.kill_range(range); - self.post_mutate(); + self.apply_edit_replacement(range, "", Some(MutationKind::Kill)); } } @@ -2593,43 +2388,25 @@ impl TextArea { if self.kill_buffer.is_empty() { return; } - self.pre_mutate(MutationKind::Insert); let text = self.kill_buffer.clone(); - self.insert_str_inner(self.cursor_pos, &text); + self.apply_edit_replacement( + self.cursor()..self.cursor(), + &text, + Some(MutationKind::Insert), + ); if let Some(last) = text.chars().last() { self.undo.last_insert_ws = last.is_whitespace(); } - self.post_mutate(); - } - - fn kill_range(&mut self, range: Range) { - let range = self.expand_range_to_element_boundaries(range); - let range = range.start.min(self.text.len())..range.end.min(self.text.len()); - if range.start >= range.end { - return; - } - - let removed = self.text[range.clone()].to_string(); - if removed.is_empty() { - return; - } - - self.kill_buffer = removed; - self.replace_range_raw(range, ""); } /// Move the cursor left by a single grapheme cluster. pub fn move_cursor_left(&mut self) { - self.scroll_override = None; - self.cursor_pos = self.prev_atomic_boundary(self.cursor_pos); - self.preferred_col = None; + self.apply_edit_command(EditCommand::MoveGraphemeLeft, None); } /// Move the cursor right by a single grapheme cluster. pub fn move_cursor_right(&mut self) { - self.scroll_override = None; - self.cursor_pos = self.next_atomic_boundary(self.cursor_pos); - self.preferred_col = None; + self.apply_edit_command(EditCommand::MoveGraphemeRight, None); } pub fn move_cursor_up(&mut self) { @@ -2639,10 +2416,10 @@ impl TextArea { let cache_ref = self.wrap_cache.borrow(); if let Some(cache) = cache_ref.as_ref() { let lines = &cache.lines; - if let Some(idx) = Self::wrapped_line_index_by_start(lines, self.cursor_pos) { + if let Some(idx) = Self::wrapped_line_index_by_start(lines, self.cursor()) { let cur_range = &lines[idx]; let target_col = self.preferred_col.unwrap_or_else(|| { - self.display_width_of_range(cur_range.start, self.cursor_pos) + self.display_width_of_range(cur_range.start, self.cursor()) }); if idx > 0 { let prev = &lines[idx - 1]; @@ -2670,7 +2447,7 @@ impl TextArea { } None => { // Already at first visual line -> move to start - self.cursor_pos = 0; + self.set_cursor_inner(0); self.preferred_col = None; return; } @@ -2678,7 +2455,7 @@ impl TextArea { } // Fallback to logical line navigation if we don't have wrapping info yet. - if let Some(prev_nl) = self.text[..self.cursor_pos].rfind('\n') { + if let Some(prev_nl) = self.text[..self.cursor()].rfind('\n') { let target_col = match self.preferred_col { Some(c) => c, None => { @@ -2691,7 +2468,7 @@ impl TextArea { let prev_line_end = prev_nl; self.move_to_display_col_on_line(prev_line_start, prev_line_end, target_col); } else { - self.cursor_pos = 0; + self.set_cursor_inner(0); self.preferred_col = None; } } @@ -2703,10 +2480,10 @@ impl TextArea { let cache_ref = self.wrap_cache.borrow(); if let Some(cache) = cache_ref.as_ref() { let lines = &cache.lines; - if let Some(idx) = Self::wrapped_line_index_by_start(lines, self.cursor_pos) { + if let Some(idx) = Self::wrapped_line_index_by_start(lines, self.cursor()) { let cur_range = &lines[idx]; let target_col = self.preferred_col.unwrap_or_else(|| { - self.display_width_of_range(cur_range.start, self.cursor_pos) + self.display_width_of_range(cur_range.start, self.cursor()) }); if idx + 1 < lines.len() { let next = &lines[idx + 1]; @@ -2733,7 +2510,7 @@ impl TextArea { } None => { // Already on last visual line -> move to end - self.cursor_pos = self.text.len(); + self.set_cursor_inner(self.text.len()); self.preferred_col = None; return; } @@ -2749,9 +2526,9 @@ impl TextArea { c } }; - if let Some(next_nl) = self.text[self.cursor_pos..] + if let Some(next_nl) = self.text[self.cursor()..] .find('\n') - .map(|i| i + self.cursor_pos) + .map(|i| i + self.cursor()) { let next_line_start = next_nl + 1; let next_line_end = self.text[next_line_start..] @@ -2760,7 +2537,7 @@ impl TextArea { .unwrap_or(self.text.len()); self.move_to_display_col_on_line(next_line_start, next_line_end, target_col); } else { - self.cursor_pos = self.text.len(); + self.set_cursor_inner(self.text.len()); self.preferred_col = None; } } @@ -2768,40 +2545,39 @@ impl TextArea { /// Home / Super+Left when `move_up_at_bol` is false (visual row if wrapped); /// Ctrl+A when true (logical line; already-at-BOL chains to previous line). pub fn move_cursor_to_beginning_of_line(&mut self, move_up_at_bol: bool) { - if !move_up_at_bol && let Some(bol) = self.beginning_of_current_visual_line() { + if move_up_at_bol { + self.apply_edit_command(EditCommand::MoveLogicalLineStart, None); + return; + } + if let Some(bol) = self.beginning_of_current_visual_line() { self.set_cursor(bol); return; } let bol = self.beginning_of_current_line(); - if move_up_at_bol && self.cursor_pos == bol { - self.set_cursor(self.beginning_of_line(self.cursor_pos.saturating_sub(1))); - } else { - self.set_cursor(bol); - } + self.set_cursor(bol); } /// End / Super+Right when `move_down_at_eol` is false (visual row if wrapped); /// Ctrl+E when true (logical line; already-at-EOL chains to next line). pub fn move_cursor_to_end_of_line(&mut self, move_down_at_eol: bool) { - if !move_down_at_eol && let Some(eol) = self.end_of_current_visual_line() { + if move_down_at_eol { + self.apply_edit_command(EditCommand::MoveLogicalLineEnd, None); + return; + } + if let Some(eol) = self.end_of_current_visual_line() { self.set_cursor(eol); return; } let eol = self.end_of_current_line(); - if move_down_at_eol && self.cursor_pos == eol { - let next_pos = (self.cursor_pos.saturating_add(1)).min(self.text.len()); - self.set_cursor(self.end_of_line(next_pos)); - } else { - self.set_cursor(eol); - } + self.set_cursor(eol); } fn beginning_of_current_visual_line(&self) -> Option { let cache = self.wrap_cache.borrow(); let cache = cache.as_ref()?; - let idx = Self::wrapped_line_index_by_start(&cache.lines, self.cursor_pos)?; + let idx = Self::wrapped_line_index_by_start(&cache.lines, self.cursor())?; Some(cache.lines[idx].start) } @@ -2810,7 +2586,7 @@ impl TextArea { fn end_of_current_visual_line(&self) -> Option { let cache = self.wrap_cache.borrow(); let cache = cache.as_ref()?; - let idx = Self::wrapped_line_index_by_start(&cache.lines, self.cursor_pos)?; + let idx = Self::wrapped_line_index_by_start(&cache.lines, self.cursor())?; let line = &cache.lines[idx]; let end = line.end.min(self.text.len()); let soft_continued = cache @@ -2839,15 +2615,8 @@ impl TextArea { kind: ElementKind, display: Option>, ) -> ElementId { - self.pre_mutate(MutationKind::Element); - let start = self.clamp_pos_for_insertion(self.cursor_pos); - let inserted_len = self.insert_str_inner(start, text); - let end = start + inserted_len; - let id = self.add_element(start..end, kind, display); - // Place cursor at end of inserted element - self.set_cursor(end); - self.post_mutate(); - id + let plan = self.plan_edit_replacement(self.cursor()..self.cursor(), text); + self.apply_element_transaction(plan, kind, display) } /// Replace a range of buffer text with an atomic element. @@ -2864,15 +2633,23 @@ impl TextArea { kind: ElementKind, display: Option>, ) -> ElementId { + let plan = self.plan_edit_replacement(range, text); + self.apply_element_transaction(plan, kind, display) + } + + fn apply_element_transaction( + &mut self, + plan: EditPlan, + kind: ElementKind, + display: Option>, + ) -> ElementId { + let start = plan.replaced_byte_range().start; + let inserted_len = plan.replacement().len(); + self.assert_valid_edit_plan(&plan); self.pre_mutate(MutationKind::Element); - // First, remove any elements that overlap the replacement range - self.elements - .retain(|e| !(e.range.start < range.end && e.range.end > range.start)); - let start = range.start.clamp(0, self.text.len()); - let inserted_len = self.replace_range_raw(range, text); + self.apply_validated_edit_plan(plan, Some(MutationKind::Element)); let end = start + inserted_len; let id = self.add_element(start..end, kind, display); - // Place cursor at end of new element self.set_cursor(end); self.post_mutate(); id @@ -2894,6 +2671,7 @@ impl TextArea { }; self.elements.push(elem); self.elements.sort_by_key(|e| e.range.start); + self.wrap_cache.replace(None); id } @@ -2905,7 +2683,7 @@ impl TextArea { pub fn element_at_cursor(&self) -> Option<&TextElement> { self.elements .iter() - .find(|e| self.cursor_pos >= e.range.start && self.cursor_pos < e.range.end) + .find(|e| self.cursor() >= e.range.start && self.cursor() < e.range.end) } /// Returns the underlying buffer text for the element with the given id. @@ -2961,7 +2739,7 @@ impl TextArea { self.pre_mutate(MutationKind::Element); self.elements.remove(idx); - self.cursor_pos = end; + self.set_cursor_inner(end); self.preferred_col = None; self.wrap_cache.replace(None); self.undo.last_kind = None; // always discrete @@ -2980,7 +2758,7 @@ impl TextArea { if self.text.is_empty() { return None; } - let pos = self.cursor_pos.min(self.text.len()); + let pos = self.cursor().min(self.text.len()); // Find word start: scan backward from cursor to find whitespace boundary let start = self.text[..pos] @@ -3041,23 +2819,6 @@ impl TextArea { } } - fn clamp_pos_for_insertion(&self, pos: usize) -> usize { - // Do not allow inserting into the middle of an element - if let Some(idx) = self.find_element_containing(pos) { - let e = &self.elements[idx]; - // Choose closest edge for insertion - let dist_start = pos.saturating_sub(e.range.start); - let dist_end = e.range.end.saturating_sub(pos); - if dist_start <= dist_end { - e.range.start - } else { - e.range.end - } - } else { - pos - } - } - fn expand_range_to_element_boundaries(&self, mut range: Range) -> Range { // Expand to include any intersecting elements fully loop { @@ -3109,58 +2870,6 @@ impl TextArea { self.shift_elements(start, end.saturating_sub(start), inserted_len); } - fn prev_atomic_boundary(&self, pos: usize) -> usize { - if pos == 0 { - return 0; - } - // If currently at an element end or inside, jump to start of that element. - if let Some(idx) = self - .elements - .iter() - .position(|e| pos > e.range.start && pos <= e.range.end) - { - return self.elements[idx].range.start; - } - let mut gc = unicode_segmentation::GraphemeCursor::new(pos, self.text.len(), false); - match gc.prev_boundary(&self.text, 0) { - Ok(Some(b)) => { - if let Some(idx) = self.find_element_containing(b) { - self.elements[idx].range.start - } else { - b - } - } - Ok(None) => 0, - Err(_) => pos.saturating_sub(1), - } - } - - fn next_atomic_boundary(&self, pos: usize) -> usize { - if pos >= self.text.len() { - return self.text.len(); - } - // If currently at an element start or inside, jump to end of that element. - if let Some(idx) = self - .elements - .iter() - .position(|e| pos >= e.range.start && pos < e.range.end) - { - return self.elements[idx].range.end; - } - let mut gc = unicode_segmentation::GraphemeCursor::new(pos, self.text.len(), false); - match gc.next_boundary(&self.text, 0) { - Ok(Some(b)) => { - if let Some(idx) = self.find_element_containing(b) { - self.elements[idx].range.end - } else { - b - } - } - Ok(None) => self.text.len(), - Err(_) => pos.saturating_add(1), - } - } - /// Move to the beginning of the previous navigable chunk. /// /// Word characters are alphanumeric plus `_`. Punctuation runs (such as @@ -3168,59 +2877,22 @@ impl TextArea { /// right side of `-`, then the left side of `-`, then the start of `aa`. /// Whitespace is skipped over. Elements remain atomic units. pub fn beginning_of_previous_word(&self) -> usize { - let mut pos = self.cursor_pos.min(self.text.len()); - - while pos > 0 { - let prev = self.prev_atomic_boundary(pos); - match self.atomic_unit_class(prev, pos) { - Some(0) => pos = prev, - Some(_) => break, - None => return 0, - } - } - - if pos == 0 { - return 0; - } - - let target_class = self.atomic_unit_class(self.prev_atomic_boundary(pos), pos); - while pos > 0 { - let prev = self.prev_atomic_boundary(pos); - if self.atomic_unit_class(prev, pos) == target_class { - pos = prev; - } else { - break; - } - } - - pos + let ranges = self.element_ranges(); + self.text + .plan_command(EditCommand::MoveWordLeft(WordStyle::Small), &ranges) + .cursor_byte() } /// Start of the previous whitespace-delimited WORD; elements count as /// non-whitespace. pub fn beginning_of_previous_unix_word(&self) -> usize { - let mut pos = self.cursor_pos.min(self.text.len()); - - // Skip the whitespace run (class 0) left of the cursor. - while pos > 0 { - let prev = self.prev_atomic_boundary(pos); - match self.atomic_unit_class(prev, pos) { - Some(0) => pos = prev, - Some(_) => break, - None => return 0, - } - } - - // Consume non-whitespace units (word chars, punctuation, elements). - while pos > 0 { - let prev = self.prev_atomic_boundary(pos); - match self.atomic_unit_class(prev, pos) { - Some(0) | None => break, - Some(_) => pos = prev, - } - } - - pos + let ranges = self.element_ranges(); + self.text + .plan_command( + EditCommand::MoveWordLeft(WordStyle::WhitespaceDelimited), + &ranges, + ) + .cursor_byte() } /// Move to the end of the next navigable chunk. @@ -3230,32 +2902,10 @@ impl TextArea { /// left side of `-`, then the right side of `-`, then the end of `bb`. /// Whitespace is skipped over. Elements remain atomic units. pub fn end_of_next_word(&self) -> usize { - let mut pos = self.cursor_pos.min(self.text.len()); - - while pos < self.text.len() { - let next = self.next_atomic_boundary(pos); - match self.atomic_unit_class(pos, next) { - Some(0) => pos = next, - Some(_) => break, - None => return self.text.len(), - } - } - - if pos >= self.text.len() { - return self.text.len(); - } - - let target_class = self.atomic_unit_class(pos, self.next_atomic_boundary(pos)); - while pos < self.text.len() { - let next = self.next_atomic_boundary(pos); - if self.atomic_unit_class(pos, next) == target_class { - pos = next; - } else { - break; - } - } - - pos + let ranges = self.element_ranges(); + self.text + .plan_command(EditCommand::MoveWordRight(WordStyle::Small), &ranges) + .cursor_byte() } fn adjust_pos_out_of_elements(&self, pos: usize, prefer_start: bool) -> usize { @@ -3493,7 +3143,7 @@ impl TextArea { // Where is the cursor within wrapped lines? Prefer assigning boundary positions // (where pos equals the start of a wrapped line) to that later line. let cursor_line_idx = - Self::wrapped_line_index_by_start(lines, self.cursor_pos).unwrap_or(0) as u16; + Self::wrapped_line_index_by_start(lines, self.cursor()).unwrap_or(0) as u16; let mut scroll = current_scroll.min(max_scroll); @@ -3936,6 +3586,281 @@ mod tests { t } + #[test] + fn canonical_adapter_matches_standalone_edit_buffer() { + let cases = [ + ( + "hello-world", + "hello-world".len(), + EditCommand::MoveWordLeft(WordStyle::Small), + ), + ( + "hello-world", + 0, + EditCommand::MoveWordRight(WordStyle::Small), + ), + ( + "foo bar", + "foo bar".len(), + EditCommand::DeleteWordBackward(WordStyle::Small), + ), + ( + "foo bar", + 0, + EditCommand::DeleteWordForward(WordStyle::Small), + ), + ("one\ntwo", 4, EditCommand::MoveLogicalLineStart), + ("one\ntwo", 3, EditCommand::MoveLogicalLineEnd), + ("abc", 2, EditCommand::DeleteGraphemeBackward), + ("abc", 1, EditCommand::DeleteGraphemeForward), + ]; + + for (text, cursor, command) in cases { + let mut textarea = TextArea::new(); + textarea.set_text(text); + textarea.clear_history(); + textarea.set_cursor(cursor); + let mut buffer = EditBuffer::from_parts(text, cursor); + + textarea.apply_classified_command(command); + let _ = buffer.apply(command); + + assert_eq!(textarea.text(), buffer.text()); + assert_eq!(textarea.cursor(), buffer.cursor_byte()); + } + } + + #[test] + fn canonical_adapter_updates_selection_from_applied_delta() { + let mut textarea = ta_with("abcdef"); + textarea.set_selection(4, 6); + textarea.replace_range(0..2, "X"); + assert_eq!(textarea.text(), "Xcdef"); + assert_eq!(textarea.selection_range(), Some(3..5)); + } + + #[test] + fn canonical_adapter_applies_same_byte_metadata_edits_with_history() { + let mut textarea = TextArea::new(); + let id = textarea.insert_element("TOKEN", ElementKind(1), None); + textarea.set_selection(0, 5); + textarea.clear_history(); + + textarea.replace_range(0..5, "TOKEN"); + assert_eq!(textarea.text(), "TOKEN"); + assert!(textarea.elements().is_empty()); + assert!(textarea.selection.is_none()); + assert!(textarea.can_undo()); + + assert!(textarea.undo()); + assert_eq!(textarea.text(), "TOKEN"); + assert_eq!(textarea.elements().len(), 1); + assert_eq!(textarea.elements()[0].id, id); + assert!(textarea.redo()); + assert_eq!(textarea.text(), "TOKEN"); + assert!(textarea.elements().is_empty()); + } + + #[test] + fn replace_element_forces_cursor_end_and_restores_metadata() { + let mut before = ta_with("left TOKEN right"); + before.clear_history(); + before.set_cursor(0); + let id = before.replace_range_with_element(5..10, "NODE", ElementKind(1), None); + let end = 5 + "NODE".len(); + assert_eq!(before.cursor(), end); + assert_eq!(before.elements()[0].id, id); + + assert!(before.undo()); + assert_eq!(before.text(), "left TOKEN right"); + assert!(before.elements().is_empty()); + assert_eq!(before.cursor(), 0); + assert!(before.redo()); + assert_eq!(before.text(), "left NODE right"); + assert_eq!(before.elements()[0].id, id); + assert_eq!(before.cursor(), end); + + let mut after = ta_with("left TOKEN right"); + after.clear_history(); + after.set_cursor(after.text().len()); + after.replace_range_with_element(5..10, "NODE", ElementKind(1), None); + assert_eq!(after.cursor(), end); + } + + #[test] + fn empty_set_text_invalidates_redo_and_is_undoable() { + let mut textarea = TextArea::new(); + textarea.insert_str("x"); + assert!(textarea.undo()); + assert!(textarea.can_redo()); + + textarea.set_text(""); + assert!(!textarea.can_redo()); + assert!(textarea.can_undo()); + assert_eq!(textarea.cursor(), 0); + } + + #[test] + fn set_text_preserves_cursor_clamped_across_grow_and_shrink() { + let mut grow = ta_with("abcd"); + grow.set_cursor(2); + grow.set_text("abcdefgh"); + assert_eq!(grow.cursor(), 2); + + let mut shrink = ta_with("abcdefgh"); + shrink.set_cursor(6); + shrink.set_text("abc"); + assert_eq!(shrink.cursor(), 3); + } + + #[test] + fn set_text_restores_zero_length_element_metadata_through_history() { + let mut textarea = TextArea::new(); + let id = textarea.insert_element("", ElementKind(7), None); + textarea.clear_history(); + + textarea.set_text(""); + assert!(textarea.elements().is_empty()); + assert!(textarea.undo()); + assert_eq!(textarea.text(), ""); + assert_eq!(textarea.elements().len(), 1); + assert_eq!(textarea.elements()[0].id, id); + assert_eq!(textarea.elements()[0].range, 0..0); + assert!(textarea.redo()); + assert!(textarea.elements().is_empty()); + } + + #[test] + fn rejected_adapter_plan_has_no_side_effects() { + let mut textarea = TextArea::new(); + let id = textarea.insert_element("TOKEN", ElementKind(1), None); + textarea.set_selection(0, 5); + textarea.kill_buffer = "sentinel".to_owned(); + textarea.preferred_col = Some(3); + textarea.scroll_override = Some(2); + let _ = textarea.desired_height(20); + textarea.clear_history(); + let plan = textarea.plan_edit_replacement(0..5, "X"); + let _ = textarea.text.set_cursor_byte(0); + + let result = textarea.try_apply_edit_plan(plan, Some(MutationKind::Replace)); + assert_eq!(result, Err(ApplyEditPlanError::StalePlan)); + assert_eq!(textarea.text(), "TOKEN"); + assert_eq!(textarea.elements().len(), 1); + assert_eq!(textarea.elements()[0].id, id); + assert_eq!(textarea.selection_range(), Some(0..5)); + assert_eq!(textarea.kill_buffer, "sentinel"); + assert_eq!(textarea.preferred_col, Some(3)); + assert_eq!(textarea.scroll_override, Some(2)); + assert!(textarea.wrap_cache.borrow().is_some()); + assert!(!textarea.can_undo()); + } + + #[test] + fn handled_boundary_navigation_clears_vertical_affinity() { + let mut textarea = ta_with("ab\nwxyz"); + textarea.set_cursor(0); + textarea.preferred_col = Some(3); + textarea.scroll_override = Some(2); + + textarea.move_cursor_left(); + assert_eq!(textarea.cursor(), 0); + assert_eq!(textarea.preferred_col, None); + assert_eq!(textarea.scroll_override, None); + + textarea.move_cursor_down(); + assert_eq!(textarea.cursor(), 3); + } + + #[test] + fn insert_str_at_inside_element_clamps_to_an_atomic_boundary() { + let mut textarea = TextArea::new(); + textarea.insert_str("a"); + textarea.insert_element("TOKEN", ElementKind(1), None); + textarea.insert_str("b"); + textarea.clear_history(); + + textarea.insert_str_at(3, "X"); + assert_eq!(textarea.text(), "aXTOKENb"); + assert_eq!(textarea.cursor(), 8); + assert_eq!(textarea.elements()[0].range, 2..7); + assert!(textarea.undo()); + assert_eq!(textarea.text(), "aTOKENb"); + assert_eq!(textarea.elements()[0].range, 1..6); + } + + #[test] + fn canonical_adapter_keeps_elements_atomic_for_motion_and_deletion() { + let mut backward = TextArea::new(); + backward.insert_str("a"); + let id = backward.insert_element("TOKEN", ElementKind(1), None); + backward.insert_str("b"); + let range = backward.elements()[0].range.clone(); + backward.set_cursor(range.end); + backward.move_cursor_left(); + assert_eq!(backward.cursor(), range.start); + backward.move_cursor_right(); + assert_eq!(backward.cursor(), range.end); + backward.delete_backward(1); + assert_eq!(backward.text(), "ab"); + assert!(backward.elements().iter().all(|element| element.id != id)); + + let mut forward = TextArea::new(); + forward.insert_str("a"); + forward.insert_element("TOKEN", ElementKind(1), None); + forward.insert_str("b"); + let range = forward.elements()[0].range.clone(); + forward.set_cursor(range.start); + forward.delete_forward(1); + assert_eq!(forward.text(), "ab"); + assert!(forward.elements().is_empty()); + } + + #[test] + fn canonical_adapter_ignores_element_newlines_and_restores_kills() { + let mut textarea = TextArea::new(); + textarea.insert_str("a"); + textarea.insert_element("X\nY", ElementKind(1), None); + textarea.insert_str("b\nc"); + textarea.clear_history(); + + textarea.set_cursor(0); + textarea.move_cursor_to_end_of_line(true); + assert_eq!(textarea.cursor(), 5); + textarea.set_cursor(0); + textarea.kill_to_end_of_line(); + assert_eq!(textarea.text(), "\nc"); + assert_eq!(textarea.kill_buffer, "aX\nYb"); + + assert!(textarea.undo()); + assert_eq!(textarea.text(), "aX\nYb\nc"); + assert_eq!(textarea.elements().len(), 1); + assert!(textarea.redo()); + assert_eq!(textarea.text(), "\nc"); + assert!(textarea.elements().is_empty()); + } + + #[test] + fn canonical_adapter_preserves_right_affinity_through_undo_redo() { + let woman = "👩"; + let tail = "👩🏽\u{200d}💻"; + let original = format!("{woman}{tail}"); + let mut textarea = ta_with(&original); + textarea.clear_history(); + textarea.set_cursor(woman.len()); + + textarea.insert_str("\u{200d}"); + assert_eq!(textarea.text().graphemes(true).count(), 1); + assert_eq!(textarea.cursor(), textarea.text().len()); + + assert!(textarea.undo()); + assert_eq!(textarea.text(), original); + assert_eq!(textarea.cursor(), woman.len()); + assert!(textarea.redo()); + assert_eq!(textarea.text().graphemes(true).count(), 1); + assert_eq!(textarea.cursor(), textarea.text().len()); + } + #[test] fn is_undo_input_accepts_ctrl_and_cmd_z() { assert!(is_undo_input(&KeyEvent::new( @@ -4240,7 +4165,9 @@ mod tests { // cursor in the middle of the element, delete_forward_word deletes the element let elem_range = t.elements()[0].range.clone(); - t.cursor_pos = elem_range.start + (elem_range.len() / 2); + let _ = t + .text + .set_cursor_byte(elem_range.start + (elem_range.len() / 2)); t.delete_forward_word(); assert_eq!(t.text(), "prefix tail"); assert_eq!(t.cursor(), elem_range.start); @@ -5374,6 +5301,20 @@ mod tests { assert_eq!(t.cursor(), 5); } + #[test] + fn no_op_kill_preserves_the_kill_buffer() { + let mut textarea = ta_with("hello"); + textarea.set_cursor(0); + textarea.kill_to_end_of_line(); + assert_eq!(textarea.kill_buffer, "hello"); + + textarea.set_text("world"); + textarea.set_cursor(textarea.text().len()); + textarea.kill_to_end_of_line(); + textarea.yank(); + assert_eq!(textarea.text(), "worldhello"); + } + #[test] fn kill_buffer_survives_set_text() { // A cut must outlive the buffer reset that send does via set_text(""). @@ -5659,6 +5600,26 @@ mod tests { assert_eq!(t.cursor(), 1); } + #[test] + fn raw_delete_chars_ignore_stray_modifiers() { + for raw in ['\u{0008}', '\u{007f}'] { + for modifiers in [ + KeyModifiers::ALT, + KeyModifiers::CONTROL, + KeyModifiers::SUPER, + KeyModifiers::ALT | KeyModifiers::CONTROL, + ] { + let mut t = ta_with("alpha beta"); + t.input(KeyEvent::new(KeyCode::Char(raw), modifiers)); + assert_eq!( + t.text(), + "alpha bet", + "raw {raw:?} with {modifiers:?} must delete one grapheme", + ); + } + } + } + #[test] fn del_char_treated_as_backspace() { // When Kitty keyboard protocol gets silently popped, Backspace can @@ -5676,7 +5637,6 @@ mod tests { assert_eq!(t.text(), "helo"); assert_eq!(t.cursor(), 0); - // With modifiers (e.g. ALT): still treated as backspace t.set_cursor(t.text().len()); t.input(KeyEvent::new(KeyCode::Char('\u{007f}'), KeyModifiers::ALT)); assert_eq!(t.text(), "hel"); @@ -6305,7 +6265,7 @@ mod tests { ta.insert_str(&s); } 1 => { - // replace_range with small random slice + // Include mid-grapheme char boundaries so normalization stays exercised. let mut b: Vec = vec![0]; b.extend(ta.text().char_indices().map(|(i, _)| i).skip(1)); b.push(ta.text().len()); @@ -6322,24 +6282,17 @@ mod tests { s.push_str(&rand_grapheme(&mut rng)); } let before = ta.text().len(); - // If the chosen range intersects an element, replace_range will expand to - // element boundaries, so the naive size delta assertion does not hold. - let intersects_element = elem_texts.iter().any(|payload| { - if let Some(pstart) = ta.text().find(payload) { - let pend = pstart + payload.len(); - pstart < end && pend > start - } else { - false - } - }); + let atomic_ranges = ta.element_ranges(); + let plan = ta + .text + .plan_replace_byte_range(start..end, &s, &atomic_ranges); + let normalized_len = plan.replaced_byte_range().len(); ta.replace_range(start..end, &s); - if !intersects_element { - let after = ta.text().len(); - assert_eq!( - after as isize, - before as isize + (s.len() as isize) - ((end - start) as isize) - ); - } + let after = ta.text().len(); + assert_eq!( + after as isize, + before as isize + (s.len() as isize) - (normalized_len as isize) + ); } 2 => ta.delete_backward(rng.random_range(0..=3)), 3 => ta.delete_forward(rng.random_range(0..=3)), @@ -6988,6 +6941,47 @@ mod tests { assert!(!ta.undo()); } + #[test] + fn multi_grapheme_delete_calls_are_single_undo_steps() { + for forward in [false, true] { + let mut ta = ta_with("hello"); + if forward { + ta.set_cursor(0); + ta.delete_forward(2); + assert_eq!(ta.text(), "llo"); + } else { + ta.delete_backward(2); + assert_eq!(ta.text(), "hel"); + } + assert!(ta.undo()); + assert_eq!(ta.text(), "hello"); + } + } + + #[test] + fn multi_count_deletes_cross_atomic_element_boundaries() { + let mut backward = TextArea::new(); + backward.insert_str("a"); + backward.insert_element("TOKEN", ElementKind(1), None); + backward.insert_str("b"); + backward.delete_backward(2); + assert_eq!(backward.text(), "a"); + assert!(backward.elements().is_empty()); + assert!(backward.undo()); + assert_eq!(backward.text(), "aTOKENb"); + + let mut forward = TextArea::new(); + forward.insert_str("a"); + forward.insert_element("TOKEN", ElementKind(1), None); + forward.insert_str("b"); + forward.set_cursor(0); + forward.delete_forward(2); + assert_eq!(forward.text(), "b"); + assert!(forward.elements().is_empty()); + assert!(forward.undo()); + assert_eq!(forward.text(), "aTOKENb"); + } + #[test] fn batch_consecutive_deletes_into_one_undo_step() { let mut ta = TextArea::new(); @@ -7243,15 +7237,36 @@ mod tests { let id = ta.insert_element("@foo", ElementKind(1), None); assert_eq!(ta.elements().len(), 1); assert_eq!(ta.elements()[0].id, id); + assert_eq!(ta.cursor(), "@foo".len()); ta.undo(); // remove element assert!(ta.elements().is_empty()); assert_eq!(ta.text(), ""); + assert_eq!(ta.cursor(), 0); ta.redo(); // restore element — same ElementId assert_eq!(ta.elements().len(), 1); assert_eq!(ta.elements()[0].id, id); assert_eq!(ta.text(), "@foo"); + assert_eq!(ta.cursor(), "@foo".len()); + } + + #[test] + fn undo_redo_zero_length_element_preserves_metadata_and_cursor() { + let mut ta = TextArea::new(); + let id = ta.insert_element("", ElementKind(9), None); + assert_eq!(ta.cursor(), 0); + assert_eq!(ta.elements()[0].range, 0..0); + + assert!(ta.undo()); + assert!(ta.elements().is_empty()); + assert_eq!(ta.cursor(), 0); + + assert!(ta.redo()); + assert_eq!(ta.elements().len(), 1); + assert_eq!(ta.elements()[0].id, id); + assert_eq!(ta.elements()[0].range, 0..0); + assert_eq!(ta.cursor(), 0); } #[test] @@ -7754,10 +7769,13 @@ mod tests { #[test] fn undo_after_delete_selection_restores() { let mut ta = ta_with("hello world"); + ta.set_cursor(ta.text().len()); ta.set_selection(0, 5); ta.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); assert_eq!(ta.text(), " world"); + assert_eq!(ta.cursor(), 0); + assert_eq!(ta.undo.last_cursor, 0); ta.undo(); assert_eq!(ta.text(), "hello world"); @@ -9007,7 +9025,7 @@ mod tests { let mut ta = TextArea::new(); ta.insert_str("1\n2\n3\n4\n5\n6\n7\n8\n9\n10"); // Move cursor to start so we can verify it doesn't move. - ta.cursor_pos = 0; + let _ = ta.text.set_cursor_byte(0); let area = Rect::new(0, 0, 20, 5); let state = TextAreaState::default(); // Click on the scrollbar column (rightmost column = 19), @@ -9024,7 +9042,7 @@ mod tests { ); assert_eq!(action, MouseAction::Scrolled); // Cursor should not have moved — scrollbar click doesn't place cursor. - assert_eq!(ta.cursor_pos, 0); + assert_eq!(ta.cursor(), 0); // scroll_override should be set. assert!(ta.scroll_override.is_some()); } @@ -9193,7 +9211,7 @@ mod tests { let mut ta = TextArea::new(); // Fill 18 chars + enough lines to overflow. ta.insert_str(&format!("{}\n2\n3\n4\n5\n6", "x".repeat(18))); - ta.cursor_pos = 0; // at start + let _ = ta.text.set_cursor_byte(0); // at start let area = Rect::new(0, 0, 20, 5); let state = TextAreaState::default(); let pos = ta.cursor_pos_with_state(area, state); @@ -9633,14 +9651,48 @@ mod tests { } #[test] - fn apply_shift_only_uppercases_letters() { - assert_eq!(TextArea::apply_shift('a'), 'A'); - assert_eq!(TextArea::apply_shift('z'), 'Z'); - assert_eq!(TextArea::apply_shift('A'), 'A'); - // Non-letters pass through unchanged (trust the terminal's layout mapping). - assert_eq!(TextArea::apply_shift('7'), '7'); - assert_eq!(TextArea::apply_shift('/'), '/'); - assert_eq!(TextArea::apply_shift(';'), ';'); + fn shifted_character_classification_only_uppercases_letters() { + for (input, expected) in [ + ('a', 'A'), + ('z', 'Z'), + ('A', 'A'), + ('7', '7'), + ('/', '/'), + (';', ';'), + ] { + assert_eq!( + classify_key_event(&KeyEvent::new(KeyCode::Char(input), KeyModifiers::SHIFT)), + Some(EditCommand::Insert(expected)) + ); + } + } + + #[test] + fn modified_delete_and_arrow_keys_keep_word_semantics() { + for modifiers in [ + KeyModifiers::ALT, + KeyModifiers::CONTROL, + KeyModifiers::ALT | KeyModifiers::CONTROL, + ] { + assert_eq!( + classify_key_event(&KeyEvent::new(KeyCode::Delete, modifiers)), + Some(EditCommand::DeleteWordForward(WordStyle::Small)), + ); + assert_eq!( + classify_key_event(&KeyEvent::new(KeyCode::Left, modifiers)), + Some(EditCommand::MoveWordLeft(WordStyle::Small)), + ); + assert_eq!( + classify_key_event(&KeyEvent::new(KeyCode::Right, modifiers)), + Some(EditCommand::MoveWordRight(WordStyle::Small)), + ); + } + for modifiers in [KeyModifiers::ALT, KeyModifiers::SUPER] { + assert_eq!( + classify_key_event(&KeyEvent::new(KeyCode::Char('d'), modifiers)), + Some(EditCommand::DeleteWordForward(WordStyle::Small)), + ); + } } #[test] diff --git a/crates/common/xai-tool-types/src/task.rs b/crates/common/xai-tool-types/src/task.rs index 27c9147..8e8c716 100644 --- a/crates/common/xai-tool-types/src/task.rs +++ b/crates/common/xai-tool-types/src/task.rs @@ -888,10 +888,15 @@ fn lifecycle_target_suffix(monitor_present: bool, subagent_present: bool) -> &'s } } -/// Optional "(a monitor's task_id is returned by {monitor})" clause. -fn monitor_task_id_note(monitor_tool: Option<&str>) -> String { +/// Optional "(a monitor's {id_name} is returned by {monitor})" clause. +/// +/// `id_name` is the model-facing singular id name — kill_task's `task_id` +/// input (tracks renames). get_task_output's `task_ids` array is plural and +/// must not be used here; both tools share this wording so randomization +/// cannot disagree across kill vs get-output docs. +fn monitor_task_id_note(monitor_tool: Option<&str>, id_name: &str) -> String { match monitor_tool { - Some(m) => format!(" (a monitor's task_id is returned by {m})"), + Some(m) => format!(" (a monitor's {id_name} is returned by {m})"), None => String::new(), } } @@ -907,6 +912,8 @@ pub struct KillTaskToolNaming<'a> { pub bash_present: bool, /// Whether termination uses a Windows Job Object (vs POSIX signals). pub is_windows: bool, + /// Model-facing name of the `task_id` input (tracks param renames). + pub task_id_param: &'a str, } /// Build the shared `kill_task` tool description. @@ -916,11 +923,12 @@ pub fn build_kill_task_description(naming: &KillTaskToolNaming) -> String { subagent_present, bash_present, is_windows, + task_id_param, } = *naming; let monitor_present = monitor_tool.is_some(); let target_suffix = lifecycle_target_suffix(monitor_present, subagent_present); - let monitor_note = monitor_task_id_note(monitor_tool); + let monitor_note = monitor_task_id_note(monitor_tool, task_id_param); let verb = if is_windows { "Terminates the Job Object of" @@ -947,7 +955,7 @@ pub fn build_kill_task_description(naming: &KillTaskToolNaming) -> String { format!( "Terminate a running background task{target_suffix}.\n\n\ Usage notes:\n\ - - Pass its task_id{monitor_note}.\n\ + - Pass its {task_id_param}{monitor_note}.\n\ - {action}.\n\ - Returns success if the task was killed or had already exited." ) @@ -964,6 +972,13 @@ pub struct TaskOutputToolNaming<'a> { pub bash_background_param: Option<&'a str>, /// The subagent `run_in_background` param name, when a `task` tool is present. pub subagent_background_param: Option<&'a str>, + /// Model-facing name of the `task_ids` input (tracks param renames). + pub task_ids_param: &'a str, + /// Model-facing name of the `timeout_ms` input (tracks param renames). + pub timeout_ms_param: &'a str, + /// Singular monitor-id name for the monitor aside — kill_task's `task_id` + /// (tracks renames). Not get_task_output's plural `task_ids`. + pub task_id_param: &'a str, } /// Build the shared `get_task_output` tool description. @@ -973,6 +988,9 @@ pub fn build_task_output_description(naming: &TaskOutputToolNaming) -> String { read_tool, bash_background_param, subagent_background_param, + task_ids_param, + timeout_ms_param, + task_id_param, } = *naming; let monitor_present = monitor_tool.is_some(); let subagent_present = subagent_background_param.is_some(); @@ -988,7 +1006,7 @@ pub fn build_task_output_description(naming: &TaskOutputToolNaming) -> String { } let sources = sources.join(" or "); - let monitor_note = monitor_task_id_note(monitor_tool); + let monitor_note = monitor_task_id_note(monitor_tool, task_id_param); let read_note = match read_tool { Some(r) => format!("\n- If output is large, use {r} on the output_file path"), None => String::new(), @@ -997,8 +1015,8 @@ pub fn build_task_output_description(naming: &TaskOutputToolNaming) -> String { format!( "Get output and status from a background task{target_suffix}.\n\n\ Usage notes:\n\ - - Pass task_ids with one or more ids from {sources}{monitor_note}; for a single task use a one-element array. Multiple ids with a positive timeout_ms wait until all complete\n\ - - Omit timeout_ms or pass 0 for a non-blocking status snapshot; set a positive timeout_ms to wait up to that many milliseconds, capped at ~10 min\n\ + - Pass {task_ids_param} with one or more ids from {sources}{monitor_note}; for a single task use a one-element array. Multiple ids with a positive {timeout_ms_param} wait until all complete\n\ + - Omit {timeout_ms_param} or pass 0 for a non-blocking status snapshot; set a positive {timeout_ms_param} to wait up to that many milliseconds, capped at ~10 min\n\ - Returns current output, status, and exit code if completed{read_note}" ) } @@ -1386,6 +1404,7 @@ mod tests { subagent_present: true, bash_present: true, is_windows: false, + task_id_param: "task_id", }); assert_eq!( desc, @@ -1404,6 +1423,7 @@ mod tests { subagent_present: true, bash_present: true, is_windows: true, + task_id_param: "task_id", }); assert!(desc.contains( "- Terminates the Job Object of a bash task or monitor; sends Cancel+Shutdown to a subagent." @@ -1417,6 +1437,7 @@ mod tests { subagent_present: true, bash_present: false, is_windows: false, + task_id_param: "task_id", }); assert_eq!( desc, @@ -1428,6 +1449,54 @@ mod tests { ); } + #[test] + fn kill_task_description_tracks_renamed_task_id() { + let desc = build_kill_task_description(&KillTaskToolNaming { + monitor_tool: Some("monitor"), + subagent_present: false, + bash_present: true, + is_windows: false, + task_id_param: "id", + }); + assert!( + desc.contains("Pass its id (a monitor's id is returned by monitor)"), + "renamed task_id must appear in pass-line and monitor aside: {desc}" + ); + assert!( + !desc.contains("task_id"), + "canonical task_id must not remain after rename: {desc}" + ); + } + + #[test] + fn task_output_description_tracks_renamed_params() { + let desc = build_task_output_description(&TaskOutputToolNaming { + monitor_tool: Some("monitor"), + read_tool: None, + bash_background_param: Some("is_background"), + subagent_background_param: None, + task_ids_param: "process_ids", + timeout_ms_param: "max_wait", + task_id_param: "id", + }); + assert!( + desc.contains("Pass process_ids with"), + "renamed task_ids must appear: {desc}" + ); + assert!( + desc.contains("positive max_wait wait") && desc.contains("Omit max_wait or pass 0"), + "renamed timeout_ms must appear: {desc}" + ); + assert!( + desc.contains("a monitor's id is returned by monitor"), + "renamed kill_task task_id must appear in monitor aside: {desc}" + ); + assert!( + !desc.contains("task_ids") && !desc.contains("timeout_ms") && !desc.contains("task_id"), + "canonical param names must not remain after rename: {desc}" + ); + } + #[test] fn task_output_matches_cli_default() { let desc = build_task_output_description(&TaskOutputToolNaming { @@ -1435,6 +1504,9 @@ mod tests { read_tool: Some("read_file"), bash_background_param: Some("background"), subagent_background_param: Some("background"), + task_ids_param: "task_ids", + timeout_ms_param: "timeout_ms", + task_id_param: "task_id", }); assert_eq!( desc, @@ -1454,6 +1526,9 @@ mod tests { read_tool: Some("read_file"), bash_background_param: None, subagent_background_param: Some("run_in_background"), + task_ids_param: "task_ids", + timeout_ms_param: "timeout_ms", + task_id_param: "task_id", }); assert_eq!( desc,