gentle-pi 4.0.0 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +157 -18
- package/assets/agents/gentle-ai-explore.md +11 -4
- package/assets/agents/gentle-ai-verify.md +23 -4
- package/assets/agents/gentle-ai-worker.md +6 -6
- package/assets/install-wizard/index.html +47 -0
- package/assets/install-wizard/wizard.css +1279 -0
- package/assets/install-wizard/wizard.js +951 -0
- package/assets/orchestrator-delegation.md +70 -163
- package/assets/orchestrator-memory.md +11 -4
- package/assets/orchestrator-prompts.md +48 -0
- package/assets/orchestrator-tracking.md +53 -0
- package/assets/orchestrator-verification.md +28 -0
- package/assets/orchestrator-writer.md +38 -0
- package/assets/orchestrator.md +29 -25
- package/assets/sounds/LICENSE.md +52 -0
- package/assets/sounds/attention.wav +0 -0
- package/assets/sounds/error.wav +0 -0
- package/assets/sounds/success.wav +0 -0
- package/bin/engram-guard +9 -0
- package/bin/gentle-shell-install.mjs +302 -0
- package/bin/gentle-shell.mjs +708 -22
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +7 -7
- package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/manifest.json +2 -2
- package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/orchestration/pi.md +16 -4
- package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-capabilities.baseline.json +2 -2
- package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-roles.baseline.json +1 -1
- package/docs/auto-permission-answers.md +25 -0
- package/docs/auto-permission-evidence.md +39 -0
- package/docs/bundled-install.md +399 -0
- package/docs/delegated-verification.md +3 -5
- package/docs/gentle-agents-activity.md +513 -0
- package/docs/gentle-shell-uninstall.md +136 -0
- package/docs/gentle-shell.md +90 -33
- package/docs/install-wizard.md +2040 -0
- package/docs/native-authority-architecture.md +1 -1
- package/docs/permission-modes.md +127 -0
- package/docs/prompt-history.md +1 -1
- package/docs/readme-reference.md +310 -47
- package/docs/render-cache-performance.md +28 -0
- package/docs/session-profile-format.md +39 -0
- package/docs/sound-notifications-proposal.md +191 -0
- package/docs/sound-notifications.md +114 -0
- package/docs/yolo-mode.md +11 -10
- package/extensions/ask-user-choice.ts +4 -0
- package/extensions/ask-user-question.ts +6 -1
- package/extensions/child-context.ts +11 -2
- package/extensions/child-safety.ts +4 -2
- package/extensions/codegraph-tools.ts +18 -5
- package/extensions/gentle-agents.ts +718 -87
- package/extensions/gentle-ai.ts +991 -263
- package/extensions/gentle-herdr-activity.ts +81 -0
- package/extensions/gentle-notifications.ts +104 -0
- package/extensions/gentle-shell.ts +271 -80
- package/extensions/gentle-todo.ts +123 -18
- package/extensions/history/index.ts +11 -6
- package/extensions/pi-pretty.ts +39 -3
- package/extensions/quiet-tools.ts +91 -21
- package/extensions/skill-registry.ts +175 -46
- package/extensions/startup-banner.ts +11 -2
- package/lib/agent-home.ts +10 -1
- package/lib/agent-model-resolution.ts +80 -0
- package/lib/agent-profiles.ts +13 -3
- package/lib/agents-completion-delivery.ts +11 -3
- package/lib/agents-messaging.ts +1 -1
- package/lib/agents-protocol.ts +40 -1
- package/lib/agents-runner.ts +90 -15
- package/lib/auto-permission-answers.ts +133 -0
- package/lib/auto-permission-classifier.ts +459 -0
- package/lib/auto-permission-evidence.ts +88 -0
- package/lib/auto-permission-memory.ts +69 -0
- package/lib/auto-permission-runtime.ts +484 -0
- package/lib/auto-permission-tool-context.ts +61 -0
- package/lib/background-jobs-tools.ts +202 -0
- package/lib/background-jobs.ts +253 -0
- package/lib/background-monitor.ts +154 -0
- package/lib/bounded-writer-admission.ts +83 -10
- package/lib/builtin-codemode-optout.ts +166 -0
- package/lib/card-content-policy.ts +85 -0
- package/lib/child-package-injection.ts +62 -0
- package/lib/codemode-renderer.ts +14 -4
- package/lib/command-palette-catalog.ts +3 -2
- package/lib/context-threshold.ts +26 -0
- package/lib/destructive-command-guard.ts +104 -28
- package/lib/gentle-ai-binary.ts +109 -4
- package/lib/gentle-shell-bundled.ts +535 -0
- package/lib/gentle-shell-launcher.ts +75 -24
- package/lib/gentle-shell-startup-flow.ts +54 -0
- package/lib/gentle-shell-startup-update.ts +92 -0
- package/lib/gentle-shell-uninstall.ts +840 -0
- package/lib/gentle-shell-update-panel.ts +231 -0
- package/lib/herdr-activity.ts +183 -0
- package/lib/inprocess-reviewer.ts +3 -0
- package/lib/jobs-sidebar-state.ts +20 -0
- package/lib/jobs-view.ts +161 -0
- package/lib/model-price-ratio.ts +83 -0
- package/lib/model-routing-authority.ts +6 -1
- package/lib/nan-provider.ts +33 -11
- package/lib/native-review-cli.ts +194 -73
- package/lib/notification-audio-native.ts +162 -0
- package/lib/notification-audio-windows.ts +399 -0
- package/lib/notification-audio.ts +332 -0
- package/lib/notification-customize.ts +241 -0
- package/lib/notification-events.ts +113 -0
- package/lib/notification-policy.ts +148 -0
- package/lib/notification-pulse-client.ts +367 -0
- package/lib/notification-pulse-protocol.ts +305 -0
- package/lib/notification-pulse-stream.ts +213 -0
- package/lib/notification-pulse-worker.ts +66 -0
- package/lib/notification-scheduler.ts +173 -0
- package/lib/notification-service.ts +202 -0
- package/lib/notification-ui.ts +87 -0
- package/lib/odd-phase.ts +1 -1
- package/lib/orchestrator-alias.ts +20 -0
- package/lib/orchestrator-catalog.ts +79 -0
- package/lib/orchestrator-consultation.ts +61 -0
- package/lib/orchestrator-discovery.ts +49 -0
- package/lib/orchestrator-helper-consent.ts +122 -0
- package/lib/orchestrator-helper.ts +167 -0
- package/lib/orchestrator-message-card.ts +105 -0
- package/lib/orchestrator-presence.ts +115 -14
- package/lib/orchestrator-scope.ts +80 -0
- package/lib/orchestrator-state.ts +107 -0
- package/lib/orchestrator-tool-card.ts +280 -0
- package/lib/orchestrator-work-search.ts +218 -0
- package/lib/orchestrator-work.ts +79 -0
- package/lib/permission-mode-policy.ts +59 -0
- package/lib/review-candidate-view-owner.ts +8 -6
- package/lib/review-candidate-view.ts +3 -2
- package/lib/review-compact-contract.ts +2 -2
- package/lib/review-host-relay.ts +7 -2
- package/lib/review-integration-v2.ts +100 -22
- package/lib/review-publication-gate.ts +1 -1
- package/lib/review-risk-assessment.ts +77 -51
- package/lib/review-sidebar-state.ts +2 -1
- package/lib/review-transaction.ts +72 -3
- package/lib/runtime-metrics-children.ts +1 -1
- package/lib/runtime-metrics-native.ts +3 -2
- package/lib/selection-engine.ts +22 -7
- package/lib/session-change-capture.ts +5 -4
- package/lib/session-changes.ts +27 -22
- package/lib/session-profile-binding.ts +83 -0
- package/lib/session-profile-freeze.ts +261 -0
- package/lib/session-profile-persistence.ts +187 -0
- package/lib/session-worktree-registry.ts +74 -8
- package/lib/shell-bar.ts +41 -11
- package/lib/shell-changes-view.ts +40 -13
- package/lib/shell-changes.ts +88 -71
- package/lib/shell-prompt.ts +6 -4
- package/lib/shell-sidebar-layout.ts +5 -1
- package/lib/shell-todo.ts +81 -26
- package/lib/shell-usage.ts +4 -3
- package/lib/status-timing-diagnostics.ts +201 -0
- package/lib/telemetry-trigger.ts +3 -1
- package/lib/vim-editor-adapter.ts +27 -5
- package/lib/visual-customization-policy.ts +20 -9
- package/lib/visual-customize-view.ts +171 -20
- package/lib/writer-surfaces.ts +162 -0
- package/lib/yolo-session-policy.ts +198 -121
- package/package.json +5 -5
- package/runtime/child-package-injection.mjs +63 -0
- package/runtime/gentle-ai-binary.mjs +109 -4
- package/runtime/gentle-shell-bundled.mjs +536 -0
- package/runtime/gentle-shell-launcher.mjs +74 -23
- package/runtime/gentle-shell-startup-flow.mjs +55 -0
- package/runtime/gentle-shell-startup-update.mjs +93 -0
- package/runtime/gentle-shell-uninstall.mjs +841 -0
- package/runtime/gentle-shell-update-panel.mjs +232 -0
- package/runtime/native-review-cli.mjs +191 -70
- package/runtime/review-integration-v2.mjs +100 -22
- package/runtime/review-risk-assessment.mjs +77 -51
- package/runtime/status-timing-diagnostics.mjs +202 -0
- package/runtime/telemetry-trigger.mjs +3 -1
- package/scripts/bootstrap.cmd +255 -0
- package/scripts/bootstrap.sh +168 -0
- package/scripts/build-distribution.mjs +157 -0
- package/scripts/build-installer-bundles.mjs +136 -0
- package/scripts/build-runtime-modules.mjs +7 -0
- package/scripts/bundled-install.mjs +1308 -0
- package/scripts/bundled-wizard.mjs +399 -0
- package/scripts/check-types.mjs +4 -6
- package/scripts/gentle-ai-installer.mjs +79 -32
- package/scripts/install-gentle-ai.mjs +48 -1
- package/scripts/install-tui-mode-setting.mjs +10 -1
- package/scripts/install-wizard-preview.mjs +154 -0
- package/scripts/installer-downloads.mjs +648 -0
- package/scripts/installer-preflight.mjs +387 -0
- package/scripts/installer-probes.mjs +564 -0
- package/scripts/installer-runner.mjs +1102 -0
- package/scripts/installer-server.mjs +881 -0
- package/scripts/installer-windows-artifacts.json +15 -0
- package/scripts/installer-windows.mjs +752 -0
- package/scripts/main-channel.mjs +452 -0
- package/scripts/npm-pack-result.mjs +31 -0
- package/scripts/pi-update.mjs +223 -0
- package/scripts/render-cache-performance-worker.mjs +205 -0
- package/scripts/render-cache-performance.mjs +115 -0
- package/scripts/run-test-suite.mjs +22 -8
- package/scripts/test-installer-acceptance.sh +161 -0
- package/scripts/test-packed-runner.mjs +23 -5
- package/scripts/types-baseline.json +2 -92
- package/scripts/verify-package-files.mjs +82 -18
- package/skills/gentle-ai/SKILL.md +12 -11
- package/tests/agent-home.test.ts +56 -3
- package/tests/agent-model-resolution.test.ts +113 -0
- package/tests/agent-profiles.test.ts +12 -0
- package/tests/agent-risk-escalation.test.ts +123 -0
- package/tests/agents-completion-delivery.test.ts +17 -0
- package/tests/agents-protocol.test.ts +57 -0
- package/tests/agents-queries.test.ts +9 -9
- package/tests/agents-runner-process.test.ts +31 -0
- package/tests/agents-runner.test.ts +89 -4
- package/tests/append-system-prompt-route.test.ts +18 -1
- package/tests/auto-mode-human-answers.test.ts +68 -0
- package/tests/auto-mode-invariants.test.ts +276 -0
- package/tests/auto-mode-sdk.test.ts +84 -0
- package/tests/auto-mode.test.ts +49 -0
- package/tests/auto-permission-answers.test.ts +170 -0
- package/tests/auto-permission-classifier.test.ts +634 -0
- package/tests/auto-permission-compound.test.ts +24 -0
- package/tests/auto-permission-evidence.test.ts +88 -0
- package/tests/auto-permission-memory.test.ts +46 -0
- package/tests/auto-permission-public-regressions.test.ts +111 -0
- package/tests/auto-permission-runtime.test.ts +454 -0
- package/tests/autonomous-guard.test.ts +86 -16
- package/tests/background-cache-warming.test.ts +2 -1
- package/tests/background-jobs.test.ts +227 -0
- package/tests/background-monitor.test.ts +195 -0
- package/tests/background-subagents-default-mode.test.ts +28 -5
- package/tests/bounded-stop-contract.test.ts +85 -0
- package/tests/bounded-writer-admission.test.ts +52 -1
- package/tests/bridge-wake-lifecycle.test.ts +129 -0
- package/tests/bridge-wake-rendering.test.ts +56 -0
- package/tests/build-distribution.test.ts +269 -0
- package/tests/builtin-codemode-optout.test.ts +242 -0
- package/tests/bundled-install.test.ts +1319 -0
- package/tests/bundled-wizard.test.ts +505 -0
- package/tests/card-content-policy.test.ts +68 -0
- package/tests/card-style-policy.test.ts +34 -2
- package/tests/child-codegraph-bootstrap.test.ts +74 -0
- package/tests/child-package-entrypoints.test.ts +65 -0
- package/tests/child-package-injection.test.ts +74 -0
- package/tests/child-safety.test.ts +12 -0
- package/tests/child-web-package.test.ts +224 -0
- package/tests/codegraph-tools.test.ts +35 -0
- package/tests/codemode-rendering.test.ts +107 -2
- package/tests/command-palette.test.ts +5 -5
- package/tests/context-threshold.test.ts +31 -0
- package/tests/delegate-for-reason-contract.test.ts +327 -0
- package/tests/delegated-key-learnings-contract.test.ts +3 -2
- package/tests/devbinary/native-review-parity.devtest.ts +1 -1
- package/tests/devbinary/pi-host-relay.devtest.ts +173 -3
- package/tests/engram-safety.test.ts +316 -0
- package/tests/generic-agent-tools.test.ts +16 -1
- package/tests/gentle-agents.test.ts +1895 -76
- package/tests/gentle-ai-binary-isolation.test.ts +135 -0
- package/tests/gentle-ai-binary.test.ts +19 -2
- package/tests/gentle-ai-child-guards.test.ts +202 -0
- package/tests/gentle-ai-dev-binary-surfacing.test.ts +125 -15
- package/tests/gentle-ai-installer.test.ts +548 -58
- package/tests/gentle-ai-prompt-model-routing.test.ts +123 -0
- package/tests/gentle-ai-renderer.test.ts +6 -6
- package/tests/gentle-ai.test.ts +999 -31
- package/tests/gentle-herdr-activity.test.ts +100 -0
- package/tests/gentle-notifications.test.ts +427 -0
- package/tests/gentle-shell-bin.test.ts +263 -19
- package/tests/gentle-shell-bundled.test.ts +1036 -0
- package/tests/gentle-shell-launcher.test.ts +251 -25
- package/tests/gentle-shell-startup-flow.test.ts +75 -0
- package/tests/gentle-shell-startup-update.test.ts +85 -0
- package/tests/gentle-shell-uninstall.test.ts +978 -0
- package/tests/gentle-shell-update-panel.test.ts +158 -0
- package/tests/gentle-shell.test.ts +611 -100
- package/tests/gentle-todo.test.ts +317 -14
- package/tests/herdr-activity.test.ts +243 -0
- package/tests/history-agent-home.test.ts +81 -0
- package/tests/history-child-guard.test.ts +70 -0
- package/tests/inprocess-reviewer.test.ts +20 -0
- package/tests/install-gentle-ai-failure.test.ts +69 -0
- package/tests/install-tui-mode-setting.test.ts +15 -0
- package/tests/install-wizard.test.ts +1223 -0
- package/tests/installer-bundles.test.ts +122 -0
- package/tests/installer-posix-bootstrap.test.ts +1201 -0
- package/tests/installer-preflight.test.ts +750 -0
- package/tests/installer-probes.test.ts +1106 -0
- package/tests/installer-runner.test.ts +2159 -0
- package/tests/installer-server.test.ts +1567 -0
- package/tests/installer-windows-bootstrap.test.ts +2410 -0
- package/tests/jobs-view.test.ts +160 -0
- package/tests/main-channel-upgrade.test.ts +395 -0
- package/tests/main-channel.test.ts +344 -0
- package/tests/maintainer/provider-relay.maintest.ts +13 -12
- package/tests/model-price-ratio.test.ts +116 -0
- package/tests/model-profiles-preservation.test.ts +153 -0
- package/tests/model-routing-package.test.ts +10 -0
- package/tests/nan-provider-runtime.test.ts +156 -0
- package/tests/nan-provider.test.ts +28 -18
- package/tests/native-binary-gate.test.ts +2 -0
- package/tests/native-review-capability-contract.test.ts +7 -1
- package/tests/native-review-cli.test.ts +236 -1
- package/tests/native-review-consent.test.ts +2 -2
- package/tests/native-review-parity-runtime.test.ts +1 -1
- package/tests/native-review-parity.test.ts +12 -48
- package/tests/native-risk-evidence-parity.test.ts +21 -0
- package/tests/no-self-pause-contract.test.ts +87 -0
- package/tests/notification-audio-native.test.ts +281 -0
- package/tests/notification-audio-windows.test.ts +581 -0
- package/tests/notification-audio.test.ts +548 -0
- package/tests/notification-customize.test.ts +655 -0
- package/tests/notification-events.test.ts +115 -0
- package/tests/notification-policy.test.ts +201 -0
- package/tests/notification-pulse-client.test.ts +356 -0
- package/tests/notification-pulse-protocol.test.ts +213 -0
- package/tests/notification-pulse-stream.test.ts +320 -0
- package/tests/notification-scheduler.test.ts +258 -0
- package/tests/notification-ui.test.ts +157 -0
- package/tests/npm-pack-result.test.ts +41 -0
- package/tests/odd-integration.test.ts +14 -5
- package/tests/odd-routing-canonical-ratchet.test.ts +53 -12
- package/tests/odd-routing-contract.test.ts +80 -23
- package/tests/odd-runtime-delegation-gate.test.ts +4 -1
- package/tests/orchestrator-budget.test.ts +29 -6
- package/tests/orchestrator-catalog.test.ts +146 -0
- package/tests/orchestrator-consultation-sdk.test.ts +614 -0
- package/tests/orchestrator-consultation.test.ts +111 -0
- package/tests/orchestrator-discovery.test.ts +241 -0
- package/tests/orchestrator-helper-consent.test.ts +171 -0
- package/tests/orchestrator-helper.test.ts +297 -0
- package/tests/orchestrator-message-card.test.ts +190 -0
- package/tests/orchestrator-presence.test.ts +23 -0
- package/tests/orchestrator-rdd-ownership.test.ts +6 -5
- package/tests/orchestrator-scope.test.ts +107 -0
- package/tests/orchestrator-state.test.ts +114 -0
- package/tests/orchestrator-status-publication-contract.test.ts +34 -0
- package/tests/orchestrator-tool-card.test.ts +398 -0
- package/tests/orchestrator-work-search.test.ts +318 -0
- package/tests/orchestrator-work.test.ts +147 -0
- package/tests/package-manifest.test.ts +105 -21
- package/tests/packed-runner-owned-path.test.ts +35 -1
- package/tests/parallel-reason-contract.test.ts +21 -0
- package/tests/parallel-writer-admission.test.ts +210 -0
- package/tests/permission-mode-policy.test.ts +194 -0
- package/tests/persona-single-channel.test.ts +4 -12
- package/tests/pi-pretty.test.ts +56 -0
- package/tests/pi-update-launcher.test.ts +87 -0
- package/tests/pi-update.test.ts +186 -0
- package/tests/provider-defect-handoff.test.ts +4 -3
- package/tests/quiet-tool-rendering.test.ts +160 -3
- package/tests/rdd-aware-verification-contract.test.ts +29 -41
- package/tests/rdd-status-line.test.ts +52 -0
- package/tests/render-cache-performance.test.ts +48 -0
- package/tests/review-agent-end-preflight.test.ts +62 -10
- package/tests/review-candidate-view.test.ts +101 -12
- package/tests/review-contract-prompt.test.ts +33 -4
- package/tests/review-controller-native-recovery.test.ts +327 -17
- package/tests/review-controller-native-routing.test.ts +255 -1
- package/tests/review-controller-workspace-root.test.ts +43 -2
- package/tests/review-controller.test.ts +2 -2
- package/tests/review-correction-lifecycle.test.ts +2 -1
- package/tests/review-gate.test.ts +7 -4
- package/tests/review-graph-schema.test.ts +2 -2
- package/tests/review-host-relay-routing.test.ts +5 -3
- package/tests/review-host-relay.test.ts +49 -2
- package/tests/review-integration-v2-forward.test.ts +21 -1
- package/tests/review-json-arguments.test.ts +167 -0
- package/tests/review-last-event-closure.test.ts +157 -8
- package/tests/review-ledger-contract.test.ts +5 -4
- package/tests/review-policy-judgment-day.test.ts +1 -1
- package/tests/review-policy-ordinary.test.ts +2 -2
- package/tests/review-relay-transport-agent.test.ts +66 -1
- package/tests/review-risk-assessment.test.ts +109 -33
- package/tests/review-session-standing-permission-controller.test.ts +16 -17
- package/tests/review-session-standing-permission-ipc.test.ts +6 -5
- package/tests/review-test-fixtures.ts +7 -1
- package/tests/review-transaction.test.ts +18 -1
- package/tests/run-test-suite.test.ts +83 -5
- package/tests/runtime-harness.mjs +42 -15
- package/tests/runtime-metrics-extension.test.ts +1 -1
- package/tests/runtime-metrics-native.test.ts +31 -1
- package/tests/selection-engine.test.ts +65 -5
- package/tests/session-change-capture.test.ts +10 -10
- package/tests/session-changes-shell.test.ts +242 -48
- package/tests/session-changes.test.ts +17 -24
- package/tests/session-profile-binding.test.ts +163 -0
- package/tests/session-profile-freeze.test.ts +394 -0
- package/tests/session-profile-persistence.test.ts +334 -0
- package/tests/session-subject-routing-contract.test.ts +34 -0
- package/tests/session-worktree-registry.test.ts +57 -2
- package/tests/shell-bar.test.ts +40 -0
- package/tests/shell-changes-view.test.ts +85 -34
- package/tests/shell-changes.test.ts +79 -62
- package/tests/shell-render-cache-runtime.test.ts +69 -0
- package/tests/shell-render-cache.test.ts +129 -0
- package/tests/shell-sidebar-layout.test.ts +27 -2
- package/tests/shell-todo.test.ts +132 -11
- package/tests/shell-usage-view.test.ts +1 -1
- package/tests/shell-usage.test.ts +35 -0
- package/tests/short-check-routing-contract.test.ts +49 -0
- package/tests/skill-registry-watcher.test.ts +119 -0
- package/tests/skill-registry.test.ts +311 -5
- package/tests/startup-banner.test.ts +117 -3
- package/tests/startup-home-repo.test.ts +170 -0
- package/tests/status-timing-diagnostics.test.ts +283 -0
- package/tests/support/orchestrator-modules.ts +19 -0
- package/tests/task-size-routing-contract.test.ts +241 -0
- package/tests/telemetry-trigger.test.ts +27 -1
- package/tests/test-strength-contract.test.ts +124 -0
- package/tests/verify-agent-contract.test.ts +90 -0
- package/tests/verify-first-launch-contract.test.ts +139 -0
- package/tests/verify-package-files.test.ts +56 -0
- package/tests/vim-editor-adapter.test.ts +47 -21
- package/tests/visual-customization-policy.test.ts +32 -3
- package/tests/visual-customize-view.test.ts +288 -10
- package/tests/writer-edit-surface-scope.test.ts +59 -2
- package/tests/writer-surfaces.test.ts +199 -0
- package/tests/yolo-customize.test.ts +18 -3
- package/tests/yolo-mode-runtime.test.ts +37 -10
- package/tests/yolo-mode.test.ts +38 -5
- package/tests/yolo-session-policy.test.ts +1 -1
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/README.md +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/lens.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/refuter.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/targeted-validator.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/lens.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/refuter.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/targeted-validator.json +0 -0
|
@@ -4,18 +4,23 @@ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSy
|
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
7
|
-
import test, { after } from "node:test";
|
|
7
|
+
import test, { after, afterEach } from "node:test";
|
|
8
8
|
import { initTheme, type ExtensionAPI, type ExtensionContext, type SlashCommandInfo, type SourceInfo } from "@earendil-works/pi-coding-agent";
|
|
9
|
-
import { CURSOR_MARKER, visibleWidth, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
10
|
-
import installGentleShell, { buildShellBarModel, createActiveProfileReader, changesShortcut, devBinaryCard, extractQueuedText, fetchCodexUsage, fetchNanUsage, loadFileDiff, shellGitRunner, openInExternalEditor, usageShortcut, GentlePromptEditor } from "../extensions/gentle-shell.ts";
|
|
9
|
+
import { CURSOR_MARKER, matchesKey, stripTerminalSequences as stripAnsi, TuiMainScreen, visibleWidth, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
10
|
+
import installGentleShell, { backgroundChangeRefreshesSettled, buildShellBarModel, createActiveProfileReader, changesShortcut, devBinaryCard, extractQueuedText, fetchCodexUsage, fetchNanUsage, loadFileDiff, shellGitRunner, openInExternalEditor, usageShortcut, GentlePromptEditor } from "../extensions/gentle-shell.ts";
|
|
11
11
|
import { CODEX_USAGE_URL, NAN_QUOTA_URL, USAGE_SOURCE_EVENT, USAGE_SOURCE_SCHEMA } from "../lib/shell-usage.ts";
|
|
12
|
-
import {
|
|
12
|
+
import { bindSessionProfile, clearSessionProfileBinding, resetSessionProfileBindingsForTesting } from "../lib/session-profile-binding.ts";
|
|
13
|
+
import { createVimEditorAdapter, isAuditedPiEditorVersion } from "../lib/vim-editor-adapter.ts";
|
|
14
|
+
import { readFrozenInheritedProfile, resetFrozenInheritedProfilesForTesting } from "../lib/session-profile-freeze.ts";
|
|
15
|
+
|
|
16
|
+
// Fixture sessions share ids across tests; the inherited-profile freeze is
|
|
17
|
+
// process state keyed by session id (gentle-shell#1064 slice 3b-ii).
|
|
18
|
+
afterEach(() => resetFrozenInheritedProfilesForTesting());
|
|
13
19
|
import { buildCommandPaletteGroups } from "../lib/command-palette-catalog.ts";
|
|
14
|
-
import { CHANGE_STATUS } from "../lib/shell-changes.ts";
|
|
20
|
+
import { CHANGE_STATUS, DIFF_MAX_BYTES, DIFF_MAX_LINES, DIFF_TOO_LARGE } from "../lib/shell-changes.ts";
|
|
15
21
|
import { sidebarPart, sidebarState, type SidebarRail } from "../lib/shell-sidebar.ts";
|
|
16
22
|
import { renderTodoCard } from "../lib/shell-todo.ts";
|
|
17
23
|
import type { ShellBarTheme } from "../lib/shell-bar.ts";
|
|
18
|
-
import { stripAnsi } from "../lib/terminal-theme.ts";
|
|
19
24
|
import { createGentleAiExtension } from "../extensions/gentle-ai.ts";
|
|
20
25
|
import { decodeReviewStatusV3 } from "../lib/review-integration-v2.ts";
|
|
21
26
|
import { REVIEW_SIDEBAR_EVENT } from "../lib/review-sidebar-state.ts";
|
|
@@ -24,11 +29,15 @@ import { resolveVisualSettings, writeVisualSettings } from "../lib/visual-custom
|
|
|
24
29
|
import { resolveAnimationPolicy } from "../lib/animation-policy.ts";
|
|
25
30
|
import { resolveVimPolicy, writeVimPolicy } from "../lib/vim-policy.ts";
|
|
26
31
|
import { resolveHistoryCapturePolicy, writeHistoryCapturePolicy } from "../lib/history-capture-policy.ts";
|
|
32
|
+
import { setBundledInstallForTesting } from "../lib/gentle-shell-bundled.ts";
|
|
27
33
|
import { readBannerConfig } from "../extensions/startup-banner.ts";
|
|
28
34
|
import { listVisualProfiles, saveVisualProfile } from "../lib/visual-profiles.ts";
|
|
29
35
|
import { oddPhaseRegistry } from "../lib/odd-phase.ts";
|
|
30
36
|
import { CARD_STYLE, cardStyle, setCardStyle } from "../lib/shell-card.ts";
|
|
31
37
|
import { resolveCardStyle, writeCardStyle } from "../lib/card-style-policy.ts";
|
|
38
|
+
import { CARD_CONTENT, cardContent, resolveCardContent, setCardContent, writeCardContent } from "../lib/card-content-policy.ts";
|
|
39
|
+
import { claimNotificationOwner } from "../lib/notification-service.ts";
|
|
40
|
+
import { DEFAULT_NOTIFICATION_SETTINGS } from "../lib/notification-policy.ts";
|
|
32
41
|
|
|
33
42
|
|
|
34
43
|
// Vim fixtures claim the installed pi-tui release, which the adapter gate
|
|
@@ -52,7 +61,7 @@ function isolatedEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
|
52
61
|
isolatedConfigHomes.push(home);
|
|
53
62
|
return { ...env, GENTLE_PI_CONFIG_HOME: home };
|
|
54
63
|
}
|
|
55
|
-
const gentleShell: typeof installGentleShell = (pi, env, deps) => installGentleShell(pi, isolatedEnv(env), { resolveWorktree, gitRunner: (cwd) => async (args) => pi.exec("git", ["-C", cwd, ...args], { timeout: 5000 }), ...deps });
|
|
64
|
+
const gentleShell: typeof installGentleShell = (pi, env, deps) => installGentleShell(pi, isolatedEnv(env), { resolveWorktree, gitRunner: (cwd) => async (args) => pi.exec("git", ["-C", cwd, ...args], { timeout: 5000 }), rootExists: () => true, ...deps });
|
|
56
65
|
|
|
57
66
|
const plainTheme = {
|
|
58
67
|
fg(_color: string, value: string) {
|
|
@@ -151,6 +160,8 @@ function fakePi(script: GitScript[] = [{ numstat: "", porcelain: "" }], commands
|
|
|
151
160
|
async exec(_command: string, args: string[]) {
|
|
152
161
|
git.push(args);
|
|
153
162
|
if (args.includes("worktree")) return { stdout: "worktree /repo\0branch refs/heads/main\0\0", stderr: "", code: 0, killed: false };
|
|
163
|
+
// Roots outside the worktree list read as a detached HEAD.
|
|
164
|
+
if (args.includes("symbolic-ref")) return { stdout: "", stderr: "", code: 1, killed: false };
|
|
154
165
|
const isNumstat = args.includes("diff");
|
|
155
166
|
const step = script[Math.min(isNumstat ? round : round++, script.length - 1)];
|
|
156
167
|
return { stdout: isNumstat ? step.numstat : step.porcelain, stderr: "", code: 0, killed: false };
|
|
@@ -163,7 +174,7 @@ async function fire(handlers: Map<string, Array<(event: unknown, ctx: ExtensionC
|
|
|
163
174
|
for (const handler of handlers.get(event) ?? []) await handler({}, ctx);
|
|
164
175
|
}
|
|
165
176
|
|
|
166
|
-
function fakeContext(options: { hasUI?: boolean; entries?: unknown[]; oauth?: boolean; pending?: boolean; idle?: boolean; editorFactory?: unknown; token?: string; select?: (title: string, options: string[]) => Promise<string | undefined> } = {}): { ctx: ExtensionContext; ui: FakeUi; overlayReady: Promise<void> } {
|
|
177
|
+
function fakeContext(options: { theme?: typeof plainTheme & { getBgAnsi?(): string }; hasUI?: boolean; entries?: unknown[]; oauth?: boolean; pending?: boolean; idle?: boolean; editorFactory?: unknown; token?: string; select?: (title: string, options: string[]) => Promise<string | undefined> } = {}): { ctx: ExtensionContext; ui: FakeUi; overlayReady: Promise<void> } {
|
|
167
178
|
const ui: FakeUi = { footerFactory: undefined, editorFactory: options.editorFactory, widgets: new Map(), widgetSets: 0, workingVisible: undefined, notices: [], overlay: undefined, overlayView: undefined, closeOverlay: undefined };
|
|
168
179
|
let resolveOverlay: () => void;
|
|
169
180
|
const overlayReady = new Promise<void>((resolve) => { resolveOverlay = resolve; });
|
|
@@ -184,7 +195,7 @@ function fakeContext(options: { hasUI?: boolean; entries?: unknown[]; oauth?: bo
|
|
|
184
195
|
modelRegistry: { isUsingOAuth: () => options.oauth ?? true, getApiKeyForProvider: async () => options.token },
|
|
185
196
|
getContextUsage: () => ({ tokens: 122_400, contextWindow: 272_000, percent: 45 }),
|
|
186
197
|
ui: {
|
|
187
|
-
theme: plainTheme,
|
|
198
|
+
theme: options.theme ?? plainTheme,
|
|
188
199
|
getAllThemes: () => [{ name: "dark", path: undefined }, { name: "light", path: undefined }],
|
|
189
200
|
getTheme: (name: string) => name === "dark" || name === "light" ? { name } : undefined,
|
|
190
201
|
setTheme: (name: string) => ({ success: name === "dark" || name === "light" }),
|
|
@@ -280,6 +291,63 @@ test("buildShellBarModel shortens the home directory and hides effort for non-re
|
|
|
280
291
|
assert.equal(built.branch, null);
|
|
281
292
|
});
|
|
282
293
|
|
|
294
|
+
test("buildShellBarModel reports auto effort for NaN models that manage their own depth", () => {
|
|
295
|
+
const { pi } = fakePi();
|
|
296
|
+
const { ctx } = fakeContext();
|
|
297
|
+
const model = ctx.model as unknown as { id: string; provider: string };
|
|
298
|
+
model.provider = "nan";
|
|
299
|
+
model.id = "deepseek-v4-flash";
|
|
300
|
+
const footerData = {
|
|
301
|
+
getGitBranch: () => null,
|
|
302
|
+
getExtensionStatuses: () => new Map(),
|
|
303
|
+
getAvailableProviderCount: () => 1,
|
|
304
|
+
onBranchChange: () => () => {},
|
|
305
|
+
};
|
|
306
|
+
assert.equal(buildShellBarModel(pi, ctx, footerData, { home: "/home/alan" }).effort, "auto");
|
|
307
|
+
// A NaN model that honors the level keeps reporting the selected one.
|
|
308
|
+
model.id = "glm5.3";
|
|
309
|
+
assert.equal(buildShellBarModel(pi, ctx, footerData, { home: "/home/alan" }).effort, "medium");
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("a self-managing NaN model announces its automatic depth once per session", async () => {
|
|
313
|
+
const { pi, handlers } = fakePi();
|
|
314
|
+
gentleShell(pi, {});
|
|
315
|
+
const { ctx, ui } = fakeContext();
|
|
316
|
+
const adaptive = { id: "qwen3.8-flash", provider: "nan", name: "Qwen 3.8 Flash", reasoning: true };
|
|
317
|
+
const budget = { id: "glm5.3", provider: "nan", name: "GLM 5.3", reasoning: true };
|
|
318
|
+
(ctx as unknown as { model: unknown }).model = adaptive;
|
|
319
|
+
const emit = async (event: string, payload: unknown) => {
|
|
320
|
+
for (const handler of handlers.get(event) ?? []) await handler(payload, ctx);
|
|
321
|
+
};
|
|
322
|
+
await emit("thinking_level_select", { type: "thinking_level_select", level: "high", previousLevel: "medium" });
|
|
323
|
+
assert.deepEqual(ui.notices.filter((notice) => notice.includes("automatically")), [
|
|
324
|
+
"Qwen 3.8 Flash chooses its reasoning depth automatically; the selected thinking level is accepted but does not change it.",
|
|
325
|
+
]);
|
|
326
|
+
// Re-selecting the same model or level never repeats the notice.
|
|
327
|
+
await emit("model_select", { type: "model_select", model: adaptive, previousModel: budget, source: "set" });
|
|
328
|
+
assert.equal(ui.notices.filter((notice) => notice.includes("automatically")).length, 1);
|
|
329
|
+
// A model that applies the level never announces.
|
|
330
|
+
(ctx as unknown as { model: unknown }).model = budget;
|
|
331
|
+
await emit("model_select", { type: "model_select", model: budget, previousModel: adaptive, source: "set" });
|
|
332
|
+
assert.equal(ui.notices.filter((notice) => notice.includes("automatically")).length, 1);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("a session that starts on a self-managing NaN model announces it once", async () => {
|
|
336
|
+
const { pi, handlers } = fakePi();
|
|
337
|
+
gentleShell(pi, {});
|
|
338
|
+
const { ctx, ui } = fakeContext();
|
|
339
|
+
const adaptive = { id: "mimo-v2.6-flash", provider: "nan", name: "MiMo V2.6 Flash", reasoning: true };
|
|
340
|
+
(ctx as unknown as { model: unknown }).model = adaptive;
|
|
341
|
+
for (const handler of handlers.get("session_start") ?? []) await handler({}, ctx);
|
|
342
|
+
assert.deepEqual(ui.notices.filter((notice) => notice.includes("automatically")), [
|
|
343
|
+
"MiMo V2.6 Flash chooses its reasoning depth automatically; the selected thinking level is accepted but does not change it.",
|
|
344
|
+
]);
|
|
345
|
+
// A session that starts on a model which honors the level stays silent.
|
|
346
|
+
(ctx as unknown as { model: unknown }).model = { id: "gemma4", provider: "nan", name: "Gemma 4", reasoning: true };
|
|
347
|
+
for (const handler of handlers.get("session_start") ?? []) await handler({}, ctx);
|
|
348
|
+
assert.equal(ui.notices.filter((notice) => notice.includes("automatically")).length, 1);
|
|
349
|
+
});
|
|
350
|
+
|
|
283
351
|
test("gentleShell installs the footer on session_start when a UI exists", () => {
|
|
284
352
|
const { pi, handlers } = fakePi();
|
|
285
353
|
gentleShell(pi, {});
|
|
@@ -430,6 +498,71 @@ test("review sidebar rejects foreign-session events and unsubscribes on shutdown
|
|
|
430
498
|
}
|
|
431
499
|
});
|
|
432
500
|
|
|
501
|
+
test("jobs snapshots populate Status, use one compact fallback, and reject stale sessions", async () => {
|
|
502
|
+
const { pi, handlers } = fakePi();
|
|
503
|
+
gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" });
|
|
504
|
+
const { ctx, ui } = fakeContext();
|
|
505
|
+
await fire(handlers, "session_start", ctx);
|
|
506
|
+
const statuses = new Map([["gentle-jobs", "⧗ 1 job"], ["mcp", "MCP: 3 servers"]]);
|
|
507
|
+
const data = { getGitBranch: () => "main", getExtensionStatuses: () => statuses, getAvailableProviderCount: () => 1, onBranchChange: () => () => {} };
|
|
508
|
+
const tui = { mode: "fullscreen", terminal: { rows: 40, columns: 160 }, requestRender() {} };
|
|
509
|
+
const factory = ui.footerFactory as (tui: unknown, theme: ShellBarTheme, footerData: unknown) => { render(width: number): string[]; dispose(): void };
|
|
510
|
+
const component = factory(tui, plainTheme, data);
|
|
511
|
+
const state = sidebarState(tui as unknown as TUI);
|
|
512
|
+
const rail = state.parts.get("footer")!;
|
|
513
|
+
const header = state.parts.get("header")!;
|
|
514
|
+
const publish = (sessionId: string, jobs: unknown) => pi.events.emit("gentle-ai:jobs-sidebar", { sessionId, snapshot: { jobs } });
|
|
515
|
+
try {
|
|
516
|
+
publish("shell-session", [{ id: "job-1", label: "CI for PR 1860", startedAt: 1 }]);
|
|
517
|
+
assert.match(rail.render(46).join("\n"), /Jobs[\s\S]*CI for PR 1860/);
|
|
518
|
+
assert.match(rail.render(46).join("\n"), /MCP: 3 servers/);
|
|
519
|
+
assert.doesNotMatch(rail.render(46).join("\n"), /1 job/, "Jobs replaces the generic integration counter");
|
|
520
|
+
state.active = true;
|
|
521
|
+
state.ownsHost = () => true;
|
|
522
|
+
assert.doesNotMatch(header.render(160).join("\n"), /1 job/, "the visible Status card owns jobs details");
|
|
523
|
+
state.active = false;
|
|
524
|
+
tui.terminal.columns = 80;
|
|
525
|
+
assert.match(header.render(80).join("\n"), /1 job/, "mobile topbar preserves the count");
|
|
526
|
+
const before = rail.digest?.();
|
|
527
|
+
publish("foreign", []);
|
|
528
|
+
publish("shell-session", [{ label: "invalid" }]);
|
|
529
|
+
assert.equal(rail.digest?.(), before);
|
|
530
|
+
publish("shell-session", []);
|
|
531
|
+
assert.doesNotMatch(rail.render(46).join("\n"), /Jobs|CI for PR/);
|
|
532
|
+
assert.doesNotMatch(header.render(80).join("\n"), /1 job/);
|
|
533
|
+
publish("shell-session", [{ id: "job-2", label: "Build runtime", startedAt: 2 }]);
|
|
534
|
+
await fire(handlers, "session_start", ctx);
|
|
535
|
+
assert.doesNotMatch(rail.render(46).join("\n"), /Build runtime/);
|
|
536
|
+
await fire(handlers, "session_shutdown", ctx);
|
|
537
|
+
publish("shell-session", [{ id: "job-3", label: "late", startedAt: 3 }]);
|
|
538
|
+
assert.doesNotMatch(rail.render(46).join("\n"), /late/);
|
|
539
|
+
} finally { component.dispose(); }
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
test("hidden Status leaves only a jobs count in regular bottom and fullscreen header chrome", async (t) => {
|
|
543
|
+
const home = scopedDoubleEscCancelConfigHome(t);
|
|
544
|
+
writeVisualSettings({ ...resolveVisualSettings({ gentlePiConfigHome: home }).settings, statusPlacement: "hidden" }, { gentlePiConfigHome: home });
|
|
545
|
+
const { pi, handlers } = fakePi();
|
|
546
|
+
gentleShell(pi, { GENTLE_PI_CONFIG_HOME: home, GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" });
|
|
547
|
+
const { ctx, ui } = fakeContext();
|
|
548
|
+
await fire(handlers, "session_start", ctx);
|
|
549
|
+
const tui = { mode: "regular", terminal: { rows: 40, columns: 160 }, requestRender() {} };
|
|
550
|
+
const data = { getGitBranch: () => "main", getExtensionStatuses: () => new Map(), getAvailableProviderCount: () => 1, onBranchChange: () => () => {} };
|
|
551
|
+
const factory = ui.footerFactory as (tui: unknown, theme: ShellBarTheme, footerData: unknown) => { render(width: number): string[]; dispose(): void };
|
|
552
|
+
const component = factory(tui, plainTheme, data);
|
|
553
|
+
try {
|
|
554
|
+
pi.events.emit("gentle-ai:jobs-sidebar", { sessionId: "shell-session", snapshot: { jobs: [{ id: "job-1", label: "CI for PR 1860", startedAt: 1 }] } });
|
|
555
|
+
assert.equal(component.render(80).join("\n"), "⧗ 1 job");
|
|
556
|
+
assert.doesNotMatch(component.render(80).join("\n"), /CI for PR|gpt-5/);
|
|
557
|
+
tui.mode = "fullscreen";
|
|
558
|
+
const header = sidebarState(tui as unknown as TUI).parts.get("header")!;
|
|
559
|
+
assert.match(header.render(160).join("\n"), /⧗ 1 job/);
|
|
560
|
+
assert.doesNotMatch(component.render(160).join("\n"), /1 job/, "the hidden fullscreen footer does not duplicate the topbar count");
|
|
561
|
+
pi.events.emit("gentle-ai:jobs-sidebar", { sessionId: "shell-session", snapshot: { jobs: [] } });
|
|
562
|
+
assert.doesNotMatch(header.render(160).join("\n"), /jobs?/);
|
|
563
|
+
} finally { component.dispose(); await fire(handlers, "session_shutdown", ctx); }
|
|
564
|
+
});
|
|
565
|
+
|
|
433
566
|
test("the fullscreen Status rail carries a live digest so a profile switch refreshes it", async () => {
|
|
434
567
|
const { pi, handlers } = fakePi();
|
|
435
568
|
let profile: string | undefined = "team";
|
|
@@ -622,7 +755,9 @@ test("profile polling refreshes both fullscreen surfaces only on change and stop
|
|
|
622
755
|
t.mock.method(globalThis, "clearInterval", (handle: { timer: (typeof intervals)[number] }) => { handle.timer.stopped = true; });
|
|
623
756
|
const { pi, handlers } = fakePi();
|
|
624
757
|
let resolutions = 0;
|
|
625
|
-
|
|
758
|
+
// follow mode keeps the live p → P → G resolution, so a pin edit reaches the
|
|
759
|
+
// open session through the poll (gentle-shell#1064 slice 3b-ii).
|
|
760
|
+
gentleShell(pi, { GENTLE_PI_CONFIG_HOME: home, GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off", GENTLE_PI_PROFILE_FOLLOW: "1" }, {
|
|
626
761
|
resolveWorktree: () => { resolutions++; return { root: repo, commonDir }; },
|
|
627
762
|
});
|
|
628
763
|
const first = fakeContext();
|
|
@@ -736,6 +871,16 @@ test("T2 float prompt installed editor reads live toolSuccessBg and preserves Es
|
|
|
736
871
|
} finally { themeHost.theme = originalTheme; editor.dispose(); setCardStyle(previous); }
|
|
737
872
|
});
|
|
738
873
|
|
|
874
|
+
// Herdr's native Pi manifest detects activity from Pi's standard loader row
|
|
875
|
+
// ("⠋ Working..."); the petal frame alone reads as idle to it.
|
|
876
|
+
test("gentleShell keeps Pi's native Working row inside Herdr so native detection sees activity", () => {
|
|
877
|
+
const { pi, handlers } = fakePi();
|
|
878
|
+
gentleShell(pi, { HERDR_ENV: "1" });
|
|
879
|
+
const { ctx, ui } = fakeContext();
|
|
880
|
+
installedPrompt(ctx, ui, handlers);
|
|
881
|
+
assert.notEqual(ui.workingVisible, false, "Herdr needs Pi's standard Working row");
|
|
882
|
+
});
|
|
883
|
+
|
|
739
884
|
test("gentleShell frames the editor with the petal prompt and a hint while empty", () => {
|
|
740
885
|
const { pi, handlers } = fakePi();
|
|
741
886
|
gentleShell(pi, {});
|
|
@@ -1499,6 +1644,49 @@ test("GentlePromptEditor keeps visual selection when autocomplete offers printab
|
|
|
1499
1644
|
} finally { editor.dispose(); }
|
|
1500
1645
|
});
|
|
1501
1646
|
|
|
1647
|
+
// gentle-shell#1565: in ordinary editing the Gentle Agents alt+a shortcut
|
|
1648
|
+
// must win over prompt select-all, for empty and non-empty drafts alike.
|
|
1649
|
+
test("GentlePromptEditor gives extension shortcuts precedence over selection chords", () => {
|
|
1650
|
+
const { pi, handlers } = fakePi(); gentleShell(pi, {});
|
|
1651
|
+
const { ctx, ui } = fakeContext(); const editor = installedPrompt(ctx, ui, handlers);
|
|
1652
|
+
try {
|
|
1653
|
+
const seen: string[] = [];
|
|
1654
|
+
editor.onExtensionShortcut = (data) => { seen.push(data); return matchesKey(data, "alt+a"); };
|
|
1655
|
+
for (const altA of ["\x1ba", "\x1b[97;3u", "\x1b[97;3:1u"]) {
|
|
1656
|
+
seen.length = 0;
|
|
1657
|
+
editor.setText("");
|
|
1658
|
+
editor.handleInput(altA);
|
|
1659
|
+
assert.deepEqual(seen, [altA], "empty draft dispatches the shortcut");
|
|
1660
|
+
seen.length = 0;
|
|
1661
|
+
editor.setText("abc\ndef");
|
|
1662
|
+
editor.handleInput(altA);
|
|
1663
|
+
assert.deepEqual(seen, [altA], "non-empty draft dispatches the shortcut");
|
|
1664
|
+
assert.doesNotMatch(editor.render(40).join("\n"), /\x1b\[7mabc/);
|
|
1665
|
+
editor.handleInput("\x7f");
|
|
1666
|
+
assert.equal(editor.getText(), "abc\nde", "the draft was not selected");
|
|
1667
|
+
}
|
|
1668
|
+
// alt+e is the dedicated select-all chord while alt+a belongs to Agents.
|
|
1669
|
+
editor.setText("abc\ndef");
|
|
1670
|
+
editor.handleInput("\x1be");
|
|
1671
|
+
assert.match(editor.render(40).join("\n"), /\x1b\[7mabc/);
|
|
1672
|
+
editor.handleInput("\x7f");
|
|
1673
|
+
assert.equal(editor.getText(), "");
|
|
1674
|
+
} finally { editor.dispose(); }
|
|
1675
|
+
});
|
|
1676
|
+
|
|
1677
|
+
test("GentlePromptEditor keeps alt+a select-all when no extension claims it", () => {
|
|
1678
|
+
const { pi, handlers } = fakePi(); gentleShell(pi, {});
|
|
1679
|
+
const { ctx, ui } = fakeContext(); const editor = installedPrompt(ctx, ui, handlers);
|
|
1680
|
+
try {
|
|
1681
|
+
editor.onExtensionShortcut = () => false;
|
|
1682
|
+
editor.setText("abc\ndef");
|
|
1683
|
+
editor.handleInput("\x1ba");
|
|
1684
|
+
assert.match(editor.render(40).join("\n"), /\x1b\[7mabc/);
|
|
1685
|
+
editor.handleInput("\x7f");
|
|
1686
|
+
assert.equal(editor.getText(), "");
|
|
1687
|
+
} finally { editor.dispose(); }
|
|
1688
|
+
});
|
|
1689
|
+
|
|
1502
1690
|
test("VISUAL autocomplete leaves app shortcut ownership ahead of c and p", () => {
|
|
1503
1691
|
const { pi, handlers } = fakePi(); gentleShell(pi, {});
|
|
1504
1692
|
const { ctx, ui } = fakeContext(); const editor = installedPrompt(ctx, ui, handlers);
|
|
@@ -1618,6 +1806,8 @@ function reverseColumns(row: string): number[] {
|
|
|
1618
1806
|
let inverse = false;
|
|
1619
1807
|
let column = 0;
|
|
1620
1808
|
const selected: number[] = [];
|
|
1809
|
+
const host = TuiMainScreen.prototype as unknown as { resolveFakeCursors(rows: string[]): string[] };
|
|
1810
|
+
row = host.resolveFakeCursors.call({ showHardwareCursor: false }, [row])[0]!;
|
|
1621
1811
|
const tokens = row.match(/\x1b\[[0-9;]*m|\x1b_pi:c\x07|[^\x1b]/g) ?? [];
|
|
1622
1812
|
assert.equal(tokens.join(""), row);
|
|
1623
1813
|
for (const token of tokens) {
|
|
@@ -1833,8 +2023,7 @@ test("GentlePromptEditor autocomplete respects narrow frame widths", () => {
|
|
|
1833
2023
|
test("registered prompt stays transparent while idle, working, and queued", () => {
|
|
1834
2024
|
const { pi, handlers, tools } = fakePi();
|
|
1835
2025
|
gentleShell(pi, {});
|
|
1836
|
-
const { ctx, ui } = fakeContext();
|
|
1837
|
-
ctx.ui.theme = { ...plainTheme, getBgAnsi: () => "\x1b[44m" } as typeof ctx.ui.theme;
|
|
2026
|
+
const { ctx, ui } = fakeContext({ theme: { ...plainTheme, getBgAnsi: () => "\x1b[44m" } });
|
|
1838
2027
|
const editor = installedPrompt(ctx, ui, handlers);
|
|
1839
2028
|
try {
|
|
1840
2029
|
for (const state of ["idle", "working", "queued"]) {
|
|
@@ -1895,8 +2084,8 @@ test("visual customization and Vim register once and remain independently discov
|
|
|
1895
2084
|
assert.equal(JSON.parse(readFileSync(join(home, "vim.json"), "utf8")).policy, "on");
|
|
1896
2085
|
});
|
|
1897
2086
|
|
|
1898
|
-
test(
|
|
1899
|
-
assert.
|
|
2087
|
+
test(`actual Pi ${INSTALLED_PI} enables a live prompt and enters NORMAL without a compatibility fallback`, async () => {
|
|
2088
|
+
assert.ok(isAuditedPiEditorVersion(INSTALLED_PI), `actual installed Pi ${INSTALLED_PI} must be audited`);
|
|
1900
2089
|
const configHome = mkdtempSync(join(tmpdir(), "gentle-vim-shell-"));
|
|
1901
2090
|
const { pi, handlers, commands } = fakePi();
|
|
1902
2091
|
gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome });
|
|
@@ -2810,7 +2999,7 @@ function scopedDoubleEscCancelConfigHome(t: { after(callback: () => void): void
|
|
|
2810
2999
|
function findCustomizeRow(ui: FakeUi, label: string, width = 90): boolean {
|
|
2811
3000
|
const view = ui.overlayView!;
|
|
2812
3001
|
view.handleInput("\x1b[D");
|
|
2813
|
-
for (let category = 0; category <
|
|
3002
|
+
for (let category = 0; category < 11; category++) {
|
|
2814
3003
|
view.handleInput("\x1b[C");
|
|
2815
3004
|
for (let index = 0; index < 35; index++) {
|
|
2816
3005
|
if (view.render(width).some((line) => line.includes(`▸ ${label}`))) return true;
|
|
@@ -2830,6 +3019,61 @@ async function customizeAction(ui: FakeUi, label: string): Promise<void> {
|
|
|
2830
3019
|
assert.ok(ui.notices.length > notices, `action did not finish: ${label}`);
|
|
2831
3020
|
}
|
|
2832
3021
|
|
|
3022
|
+
test("customize Notifications applies audio controls directly in the same overlay, without nested menus or visual writes", async (t) => {
|
|
3023
|
+
const home = scopedDoubleEscCancelConfigHome(t);
|
|
3024
|
+
const { pi, commands } = fakePi();
|
|
3025
|
+
gentleShell(pi, { GENTLE_PI_CONFIG_HOME: home }); // Shell loads BEFORE owner.
|
|
3026
|
+
const child = process.env.GENTLE_PI_AGENTS_CHILD;
|
|
3027
|
+
process.env.GENTLE_PI_AGENTS_CHILD = "0";
|
|
3028
|
+
t.after(() => { if (child === undefined) delete process.env.GENTLE_PI_AGENTS_CHILD; else process.env.GENTLE_PI_AGENTS_CHILD = child; });
|
|
3029
|
+
let nested = 0; let writes = 0;
|
|
3030
|
+
const { ctx, ui, overlayReady } = fakeContext({ select: async () => { nested++; return undefined; } });
|
|
3031
|
+
const uiSpies = ctx.ui as unknown as Record<string, unknown>;
|
|
3032
|
+
uiSpies.input = async () => { nested++; return undefined; };
|
|
3033
|
+
uiSpies.confirm = async () => { nested++; return false; };
|
|
3034
|
+
const owner = claimNotificationOwner(() => {}, { env: {},
|
|
3035
|
+
read: () => ({ settings: structuredClone(DEFAULT_NOTIFICATION_SETTINGS), source: "default", globalFile: join(home, "notifications.json"), malformed: false, readError: false }),
|
|
3036
|
+
write: () => { writes++; return join(home, "notifications.json"); } });
|
|
3037
|
+
owner.attach(ctx); t.after(() => owner.retire());
|
|
3038
|
+
let settled = false;
|
|
3039
|
+
const pending = commands.get("gentle:customize")!.handler("", ctx).then(() => { settled = true; });
|
|
3040
|
+
await overlayReady;
|
|
3041
|
+
try {
|
|
3042
|
+
assert.ok(findCustomizeRow(ui, "Audio notifications: off"), "global switch is a direct row");
|
|
3043
|
+
assert.ok(findCustomizeRow(ui, "Audio: unmuted"), "mute is a direct row");
|
|
3044
|
+
assert.equal(findCustomizeRow(ui, "Audio availability: check"), false, "availability row is removed from the card");
|
|
3045
|
+
assert.equal(findCustomizeRow(ui, "Audio: restore preset"), false, "restore row is removed from the card");
|
|
3046
|
+
assert.ok(findCustomizeRow(ui, "Success:"), "Success group is a direct row");
|
|
3047
|
+
assert.ok(findCustomizeRow(ui, "Error:"), "Error group is a direct row");
|
|
3048
|
+
assert.ok(findCustomizeRow(ui, "Attention:"), "Attention group is a direct row");
|
|
3049
|
+
assert.match(ui.overlayView!.render(90).join("\n"), /Audio notifications/);
|
|
3050
|
+
assert.equal(settled, false, "the overlay never closes to open a panel");
|
|
3051
|
+
// Per-event exceptions stay folded until Advanced is expanded in the same overlay.
|
|
3052
|
+
assert.ok(findCustomizeRow(ui, "Advanced:"), "Advanced toggle is a direct row");
|
|
3053
|
+
assert.doesNotMatch(ui.overlayView!.render(90).join("\n"), /agent\.failed:/, "per-event rows stay folded by default");
|
|
3054
|
+
ui.overlayView!.handleInput("\r");
|
|
3055
|
+
// Let the toggle's busy microtask settle before activating a revealed row.
|
|
3056
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
3057
|
+
const notices = ui.notices.length;
|
|
3058
|
+
assert.ok(findCustomizeRow(ui, "agent.failed:"));
|
|
3059
|
+
ui.overlayView!.handleInput("\r");
|
|
3060
|
+
for (let attempt = 0; attempt < 100 && ui.notices.length === notices; attempt++) await new Promise<void>((resolve) => setTimeout(resolve, 5));
|
|
3061
|
+
assert.ok(ui.notices.length > notices, "direct Enter action finished");
|
|
3062
|
+
assert.equal(writes, 1);
|
|
3063
|
+
assert.match(ui.overlayView!.render(90).join("\n"), /agent\.failed: builtin:attention/);
|
|
3064
|
+
assert.equal(nested, 0, "no ctx.ui.select/input/confirm anywhere in the flow");
|
|
3065
|
+
assert.equal(settled, false, "the notification action never closed the customize overlay");
|
|
3066
|
+
assert.equal(existsSync(join(home, "visual.json")), false);
|
|
3067
|
+
assert.equal(existsSync(join(home, "notifications.json")), false, "stubbed write left no file");
|
|
3068
|
+
} finally { ui.closeOverlay?.(); await pending; }
|
|
3069
|
+
});
|
|
3070
|
+
|
|
3071
|
+
test("production customize never wires the legacy notification panel", () => {
|
|
3072
|
+
const source = readFileSync(new URL("../extensions/gentle-shell.ts", import.meta.url), "utf8");
|
|
3073
|
+
assert.doesNotMatch(source, /openNotificationPanel|notification-ui\.ts/);
|
|
3074
|
+
assert.match(source, /buildNotificationRows/);
|
|
3075
|
+
});
|
|
3076
|
+
|
|
2833
3077
|
test("customize Editor rows preview global preference without applying until Enter or Space", async (t) => {
|
|
2834
3078
|
const home = scopedDoubleEscCancelConfigHome(t);
|
|
2835
3079
|
const { pi, commands } = fakePi();
|
|
@@ -2886,7 +3130,7 @@ test("customize Cards rows persist the card style and switch live conversation c
|
|
|
2886
3130
|
const pending = commands.get("gentle:customize")!.handler("", ctx);
|
|
2887
3131
|
await overlayReady;
|
|
2888
3132
|
assert.ok(findCustomizeRow(ui, "Card style: float (current)"));
|
|
2889
|
-
assert.match(ui.overlayView!.render(90).join("\n"), /Cards · 2\/
|
|
3133
|
+
assert.match(ui.overlayView!.render(90).join("\n"), /Cards · 2\/4/);
|
|
2890
3134
|
assert.ok(findCustomizeRow(ui, "Card style: neon"));
|
|
2891
3135
|
assert.ok(!ui.overlayView!.render(90).some((line) => line.includes("▸ Card style: neon (current)")), "neon is not current without a saved preference");
|
|
2892
3136
|
assert.equal(existsSync(join(home, "card-style.json")), false, "highlighting never applies");
|
|
@@ -2910,6 +3154,32 @@ test("customize Cards rows persist the card style and switch live conversation c
|
|
|
2910
3154
|
ui.overlayView!.handleInput("\x1b"); await pending;
|
|
2911
3155
|
});
|
|
2912
3156
|
|
|
3157
|
+
test("customize Cards rows persist the card content level and switch the live quiet tools", async (t) => {
|
|
3158
|
+
const home = scopedDoubleEscCancelConfigHome(t);
|
|
3159
|
+
const previous = cardContent();
|
|
3160
|
+
t.after(() => setCardContent(previous));
|
|
3161
|
+
const { pi, commands } = fakePi();
|
|
3162
|
+
gentleShell(pi, { GENTLE_PI_CONFIG_HOME: home });
|
|
3163
|
+
assert.equal(cardContent(), CARD_CONTENT.DEFAULT, "no preference file means default");
|
|
3164
|
+
const { ctx, ui, overlayReady } = fakeContext();
|
|
3165
|
+
const pending = commands.get("gentle:customize")!.handler("", ctx);
|
|
3166
|
+
await overlayReady;
|
|
3167
|
+
assert.ok(findCustomizeRow(ui, "Card content: default (current)"));
|
|
3168
|
+
assert.ok(findCustomizeRow(ui, "Card content: minimal"));
|
|
3169
|
+
assert.equal(existsSync(join(home, "card-content.json")), false, "highlighting never applies");
|
|
3170
|
+
await customizeAction(ui, "Card content: minimal");
|
|
3171
|
+
await new Promise<void>(resolve => setImmediate(resolve));
|
|
3172
|
+
assert.equal(resolveCardContent({ gentlePiConfigHome: home }).content, "minimal");
|
|
3173
|
+
assert.equal(cardContent(), CARD_CONTENT.MINIMAL, "the live slot follows the choice");
|
|
3174
|
+
assert.match(ui.notices.at(-1)!, /Card content: minimal/);
|
|
3175
|
+
assert.ok(findCustomizeRow(ui, "Card content: minimal (current)"));
|
|
3176
|
+
assert.ok(findCustomizeRow(ui, "Card content: default"));
|
|
3177
|
+
await customizeAction(ui, "Card content: default");
|
|
3178
|
+
assert.equal(resolveCardContent({ gentlePiConfigHome: home }).content, "default");
|
|
3179
|
+
assert.equal(cardContent(), CARD_CONTENT.DEFAULT);
|
|
3180
|
+
ui.overlayView!.handleInput("\x1b"); await pending;
|
|
3181
|
+
});
|
|
3182
|
+
|
|
2913
3183
|
test("T3 live style Cards action refreshes cached TODO, header, footer and prompt in both directions", async (t) => {
|
|
2914
3184
|
const home = scopedDoubleEscCancelConfigHome(t);
|
|
2915
3185
|
const previous = cardStyle();
|
|
@@ -3032,6 +3302,20 @@ test("the saved card style applies at startup and on every session start", async
|
|
|
3032
3302
|
assert.equal(cardStyle(), CARD_STYLE.FLOAT);
|
|
3033
3303
|
});
|
|
3034
3304
|
|
|
3305
|
+
test("the saved card content applies at startup and on every session start", async (t) => {
|
|
3306
|
+
const home = scopedDoubleEscCancelConfigHome(t);
|
|
3307
|
+
const found = cardContent();
|
|
3308
|
+
t.after(() => setCardContent(found));
|
|
3309
|
+
writeCardContent("minimal", { gentlePiConfigHome: home });
|
|
3310
|
+
const { pi, handlers } = fakePi();
|
|
3311
|
+
gentleShell(pi, { GENTLE_PI_CONFIG_HOME: home });
|
|
3312
|
+
assert.equal(cardContent(), CARD_CONTENT.MINIMAL);
|
|
3313
|
+
writeCardContent("default", { gentlePiConfigHome: home });
|
|
3314
|
+
const { ctx } = fakeContext({ hasUI: false });
|
|
3315
|
+
for (const handler of handlers.get("session_start") ?? []) await handler({}, ctx);
|
|
3316
|
+
assert.equal(cardContent(), CARD_CONTENT.DEFAULT);
|
|
3317
|
+
});
|
|
3318
|
+
|
|
3035
3319
|
test("customize Cards rows refuse to overwrite a malformed preference", async (t) => {
|
|
3036
3320
|
const home = scopedDoubleEscCancelConfigHome(t);
|
|
3037
3321
|
const found = cardStyle();
|
|
@@ -3143,7 +3427,7 @@ test("customize Vim reports a persistence error without changing the live prompt
|
|
|
3143
3427
|
chmodSync(home, 0o500);
|
|
3144
3428
|
try {
|
|
3145
3429
|
await customizeAction(ui, "Vim: enable");
|
|
3146
|
-
assert.match(ui.notices.at(-1)!, /
|
|
3430
|
+
assert.match(ui.notices.at(-1)!, /Customization:/);
|
|
3147
3431
|
assert.equal(editor.effectiveVimPolicy, "off");
|
|
3148
3432
|
assert.equal(resolveVimPolicy({ gentlePiConfigHome: home }).policy, "off");
|
|
3149
3433
|
} finally {
|
|
@@ -3277,7 +3561,7 @@ test("T4d unified float footer uses one live owner and restores legacy Changes o
|
|
|
3277
3561
|
const home = scopedDoubleEscCancelConfigHome(t);
|
|
3278
3562
|
writeCardStyle("float", { gentlePiConfigHome: home });
|
|
3279
3563
|
writeVisualSettings({ ...resolveVisualSettings({ gentlePiConfigHome: home }).settings, headerPlacement: "below-input" }, { gentlePiConfigHome: home });
|
|
3280
|
-
const { pi, handlers, commands } = fakePi();
|
|
3564
|
+
const { pi, handlers, commands } = fakePi([{ numstat: "2\t0\tlib/live.ts\n", porcelain: " M lib/live.ts\0" }, { numstat: "3\t0\tlib/live.ts\n", porcelain: " M lib/live.ts\0" }]);
|
|
3281
3565
|
gentleShell(pi, { GENTLE_PI_CONFIG_HOME: home });
|
|
3282
3566
|
const { ctx, ui, overlayReady } = fakeContext();
|
|
3283
3567
|
await fire(handlers, "session_start", ctx);
|
|
@@ -3301,8 +3585,8 @@ test("T4d unified float footer uses one live owner and restores legacy Changes o
|
|
|
3301
3585
|
assert.equal(footer.handleMouse?.(click(initialUsageX, 3, 139)), undefined);
|
|
3302
3586
|
assert.equal(footer.handleMouse?.(click(initialUsageX, 2, 139))?.handled, true);
|
|
3303
3587
|
ui.closeOverlay?.();
|
|
3304
|
-
sessionChange(ctx, "t4d-a", "/repo", "lib/live.ts", "", "one\ntwo\n");
|
|
3305
3588
|
await fire(handlers, "agent_end", ctx);
|
|
3589
|
+
await changesSettled();
|
|
3306
3590
|
const changes = widget("gentle-shell-changes");
|
|
3307
3591
|
for (const width of [139, 140, 240]) {
|
|
3308
3592
|
tui.terminal.columns = width;
|
|
@@ -3325,9 +3609,9 @@ test("T4d unified float footer uses one live owner and restores legacy Changes o
|
|
|
3325
3609
|
statuses.set("mcp", "MCP updated");
|
|
3326
3610
|
assert.notEqual(railHeader.digest!(), digest, "live status changes update the grouped digest");
|
|
3327
3611
|
const statusDigest = railHeader.digest!();
|
|
3328
|
-
sessionChange(ctx, "t4d-b", "/repo", "lib/live.ts", "one\ntwo\n", "one\ntwo\nthree\n");
|
|
3329
3612
|
await fire(handlers, "agent_end", ctx);
|
|
3330
|
-
|
|
3613
|
+
await changesSettled();
|
|
3614
|
+
assert.notEqual(railHeader.digest!(), statusDigest, "Git changes update the grouped digest");
|
|
3331
3615
|
assert.match(stripAnsi(footer.render(240).join("\n")), /\+3 −0/);
|
|
3332
3616
|
assert.match(stripAnsi(footer.render(240).join("\n")), /MCP updated/);
|
|
3333
3617
|
state.active = true;
|
|
@@ -4390,49 +4674,69 @@ function renderFooter(ui: FakeUi): string {
|
|
|
4390
4674
|
return factory(fakeTui, plainTheme, footerData).render(160)[0];
|
|
4391
4675
|
}
|
|
4392
4676
|
|
|
4393
|
-
|
|
4394
|
-
|
|
4395
|
-
entries.push({ type: "custom", customType: "gentle-pi.session-change/v1", data: {
|
|
4396
|
-
sessionId: ctx.sessionManager.getSessionId(),
|
|
4397
|
-
evidence: { id, root, path, before: before ? {kind:"text",text:before} : {kind:"absent"}, after:{kind:"text",text:after} },
|
|
4398
|
-
} });
|
|
4399
|
-
}
|
|
4677
|
+
// Changes refreshes from Git off the turn-end path; let the fake scan land.
|
|
4678
|
+
const changesSettled = () => backgroundChangeRefreshesSettled();
|
|
4400
4679
|
|
|
4401
|
-
|
|
4402
|
-
|
|
4680
|
+
// Changes reads each root's Git state and nothing else: worktree labels,
|
|
4681
|
+
// status and numstat against HEAD, and a lazy diff of the selected file.
|
|
4682
|
+
const gitStateOnly = (git: readonly string[][]) => git.length > 0 && git.every((args) => {
|
|
4683
|
+
const command = args[0] === "-C" ? args.slice(2) : args;
|
|
4684
|
+
return (command[0] === "worktree" && command[1] === "list") || (command[0] === "status" && command.includes("--porcelain=v1"))
|
|
4685
|
+
|| (command[0] === "diff" && command.includes("HEAD")) || command[0] === "symbolic-ref" || (command[0] === "rev-parse" && command.includes("--verify"));
|
|
4686
|
+
});
|
|
4687
|
+
|
|
4688
|
+
test("Changes lists the session root's Git state at turn end, never at startup", async () => {
|
|
4689
|
+
const { pi, handlers, git } = fakePi([{numstat:"2\t0\tlib/b.ts\n",porcelain:" M lib/b.ts\0?? other.ts\0"},{numstat:"3\t0\tlib/b.ts\n",porcelain:" M lib/b.ts\0?? other.ts\0"}]);
|
|
4403
4690
|
gentleShell(pi,{});
|
|
4404
4691
|
const {ctx,ui}=fakeContext();
|
|
4405
4692
|
await fire(handlers,"session_start",ctx);
|
|
4406
4693
|
assert.equal(git.length,0);
|
|
4407
4694
|
assert.equal(ui.widgets.has("gentle-shell-changes"),false);
|
|
4408
|
-
|
|
4409
|
-
|
|
4410
|
-
await new Promise(resolve=>setImmediate(resolve));
|
|
4695
|
+
await fire(handlers,"agent_end",ctx);
|
|
4696
|
+
await changesSettled();
|
|
4411
4697
|
const factory=ui.widgets.get("gentle-shell-changes") as any;
|
|
4412
|
-
assert.match(factory(fakeTui,plainTheme).render(140)[0],/
|
|
4413
|
-
assert.match(renderFooter(ui),/main ±
|
|
4698
|
+
assert.match(factory(fakeTui,plainTheme).render(140)[0],/2 files · \+2 −0/, "files no agent wrote are listed too: Git decides");
|
|
4699
|
+
assert.match(renderFooter(ui),/main ±2/);
|
|
4414
4700
|
const rail = sidebarState(fakeTui as unknown as TUI).parts.get("footer")!;
|
|
4415
4701
|
const digest = rail.digest!();
|
|
4416
|
-
assert.match(rail.render(46).join("\n"), /
|
|
4417
|
-
sessionChange(ctx,"b","/repo","lib/b.ts","one\ntwo\n","one\ntwo\nthree\n");
|
|
4702
|
+
assert.match(rail.render(46).join("\n"), /2 files · \+2 −0/);
|
|
4418
4703
|
await fire(handlers,"agent_end",ctx);
|
|
4704
|
+
await changesSettled();
|
|
4419
4705
|
assert.notEqual(rail.digest!(), digest, "same-count line changes invalidate the unified Status");
|
|
4420
|
-
assert.match(rail.render(46).join("\n"), /
|
|
4421
|
-
assert.
|
|
4706
|
+
assert.match(rail.render(46).join("\n"), /2 files · \+3 −0/);
|
|
4707
|
+
assert.ok(gitStateOnly(git), JSON.stringify(git));
|
|
4708
|
+
assert.ok(git.every((args) => args[1] === "/repo"), "only the session's own root is scanned");
|
|
4422
4709
|
await fire(handlers,"session_shutdown",ctx);
|
|
4423
4710
|
});
|
|
4424
4711
|
|
|
4712
|
+
test("committed work leaves the widget and the footer counter at the next turn end", async () => {
|
|
4713
|
+
const { handlers, git, pi } = fakePi([{numstat:"1\t0\tlib/b.ts\n",porcelain:" M lib/b.ts\0"},{numstat:"",porcelain:""}]);
|
|
4714
|
+
gentleShell(pi,{});
|
|
4715
|
+
const {ctx,ui}=fakeContext();
|
|
4716
|
+
await fire(handlers,"session_start",ctx);
|
|
4717
|
+
assert.equal(git.length,0,"startup never scans");
|
|
4718
|
+
await fire(handlers,"agent_end",ctx);
|
|
4719
|
+
await changesSettled();
|
|
4720
|
+
assert.match(renderFooter(ui),/main ±1/);
|
|
4721
|
+
await fire(handlers,"agent_end",ctx);
|
|
4722
|
+
await changesSettled();
|
|
4723
|
+
assert.doesNotMatch(renderFooter(ui),/±/);
|
|
4724
|
+
assert.equal(ui.widgets.has("gentle-shell-changes"),false);
|
|
4725
|
+
await fire(handlers,"session_shutdown",ctx);
|
|
4726
|
+
});
|
|
4425
4727
|
|
|
4426
|
-
|
|
4427
|
-
|
|
4428
|
-
|
|
4429
|
-
|
|
4430
|
-
const
|
|
4431
|
-
|
|
4728
|
+
test("a turn end never waits for Git", async () => {
|
|
4729
|
+
const { pi, handlers } = fakePi();
|
|
4730
|
+
(pi as unknown as { exec: () => Promise<never> }).exec = () => new Promise<never>(() => {});
|
|
4731
|
+
gentleShell(pi,{});
|
|
4732
|
+
const {ctx}=fakeContext();
|
|
4733
|
+
await fire(handlers,"session_start",ctx);
|
|
4734
|
+
await Promise.race([fire(handlers,"agent_end",ctx), new Promise((_, reject) => setTimeout(() => reject(new Error("agent_end waited for Git")), 100))]);
|
|
4735
|
+
await fire(handlers,"session_shutdown",ctx);
|
|
4432
4736
|
});
|
|
4433
4737
|
|
|
4434
|
-
test("
|
|
4435
|
-
const { pi, handlers, commands, tools
|
|
4738
|
+
test("explicit registration validates the clone, and a clean registered root shows no Changes", async () => {
|
|
4739
|
+
const { pi, handlers, commands, tools } = fakePi();
|
|
4436
4740
|
let bootstrapped = false;
|
|
4437
4741
|
gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, {
|
|
4438
4742
|
resolveWorktree: (path) => bootstrapped ? { root: path === "." ? "/repo" : path, commonDir: path === "/foreign" ? "/foreign/git" : "/repo/.git" } : undefined,
|
|
@@ -4446,70 +4750,45 @@ test("same-session explicit registration after bootstrap does not claim Changes"
|
|
|
4446
4750
|
await register.execute("after", { path: "/repo" }, undefined, undefined, ctx);
|
|
4447
4751
|
await register.execute("dedup", { path: "/repo" }, undefined, undefined, ctx);
|
|
4448
4752
|
await assert.rejects(register.execute("foreign", { path: "/foreign" }, undefined, undefined, ctx), /same Git clone/);
|
|
4753
|
+
await assert.rejects(register.execute("foreign", { path: "/foreign" }, undefined, undefined, ctx), /subagent_run with repository_root/);
|
|
4449
4754
|
assert.equal(ctx.sessionManager, manager);
|
|
4450
4755
|
assert.equal(ctx.sessionManager.getEntries().length, 1);
|
|
4451
4756
|
await commands.get("gentle:changes")!.handler("", ctx);
|
|
4452
|
-
assert.match(ui.notices.join("\n"), /No
|
|
4757
|
+
assert.match(ui.notices.join("\n"), /No uncommitted changes/);
|
|
4453
4758
|
assert.equal(ui.overlay, undefined);
|
|
4454
|
-
assert.deepEqual(git, []);
|
|
4455
4759
|
await fire(handlers, "session_shutdown", ctx);
|
|
4456
4760
|
});
|
|
4457
4761
|
|
|
4458
|
-
test("
|
|
4459
|
-
const {pi,handlers,commands,tools,git}=fakePi();
|
|
4762
|
+
test("a registered worktree opens Changes as its own group", async () => {
|
|
4763
|
+
const {pi,handlers,commands,tools,git}=fakePi([{numstat:"",porcelain:" M file.ts\0?? dirty.ts\0"}]);
|
|
4460
4764
|
gentleShell(pi,{});
|
|
4461
4765
|
const {ctx,ui,overlayReady}=fakeContext();
|
|
4462
4766
|
await fire(handlers,"session_start",ctx);
|
|
4463
4767
|
await tools.get("session_worktree_register")!.execute("r",{path:"/linked"},undefined,undefined,ctx);
|
|
4464
|
-
await commands.get("gentle:changes")!.handler("",ctx);
|
|
4465
|
-
assert.match(ui.notices.join("\n"),/No captured agent changes/);
|
|
4466
|
-
assert.equal(ui.overlay,undefined);
|
|
4467
|
-
sessionChange(ctx,"a","/linked","file.ts");
|
|
4468
|
-
await fire(handlers,"agent_end",ctx);
|
|
4469
|
-
const opened=commands.get("gentle:changes")!.handler("",ctx);
|
|
4470
|
-
await overlayReady;
|
|
4471
|
-
assert.match(ui.overlayView!.render(140).join("\n"),/linked/);
|
|
4472
|
-
assert.ok(onlyHeadLabels(git), `overlay ran more than HEAD labelling: ${JSON.stringify(git)}`);
|
|
4473
|
-
ui.closeOverlay?.(); await opened;
|
|
4474
|
-
await fire(handlers,"session_shutdown",ctx);
|
|
4475
|
-
});
|
|
4476
|
-
|
|
4477
|
-
test("overlay groups captured roots and refreshes same-count diffs without HEAD or external files", async () => {
|
|
4478
|
-
const {pi,handlers,commands,git}=fakePi();
|
|
4479
|
-
gentleShell(pi,{GENTLE_PI_SHELL_CHANGES_POLL_MS:"5"});
|
|
4480
|
-
const {ctx,ui,overlayReady}=fakeContext();
|
|
4481
|
-
sessionChange(ctx,"a","/repo","same.ts","old\n","first\n");
|
|
4482
|
-
sessionChange(ctx,"child:a","/linked","same.ts","old\n","child\n");
|
|
4483
|
-
await fire(handlers,"session_start",ctx);
|
|
4484
4768
|
const opened=commands.get("gentle:changes")!.handler("",ctx);
|
|
4485
4769
|
await overlayReady;
|
|
4486
4770
|
try {
|
|
4487
|
-
ui.overlayView!.
|
|
4488
|
-
|
|
4489
|
-
assert.match(
|
|
4490
|
-
|
|
4491
|
-
pi.events.emit("gentle-pi:session-change",{sessionId:ctx.sessionManager.getSessionId()});
|
|
4492
|
-
await new Promise(resolve=>setTimeout(resolve,20));
|
|
4493
|
-
assert.match(ui.overlayView!.render(140).join("\n"),/second/);
|
|
4494
|
-
assert.doesNotMatch(ui.overlayView!.render(140).join("\n"),/first/);
|
|
4495
|
-
assert.ok(onlyHeadLabels(git), `overlay ran more than HEAD labelling: ${JSON.stringify(git)}`);
|
|
4771
|
+
const rendered=ui.overlayView!.render(140).join("\n");
|
|
4772
|
+
assert.match(rendered,/main · repo/);
|
|
4773
|
+
assert.match(rendered,/detached · linked/, "a root outside the worktree list asks for its own HEAD");
|
|
4774
|
+
assert.ok(gitStateOnly(git), JSON.stringify(git));
|
|
4496
4775
|
} finally { ui.closeOverlay?.(); await opened; await fire(handlers,"session_shutdown",ctx); }
|
|
4497
4776
|
});
|
|
4498
4777
|
|
|
4499
|
-
test("new sessions
|
|
4778
|
+
test("new sessions do not inherit another session's worktrees", async () => {
|
|
4500
4779
|
const h=fakePi(); gentleShell(h.pi,{});
|
|
4501
4780
|
const first=fakeContext();
|
|
4502
|
-
sessionChange(first.ctx,"a","/repo","own.ts");
|
|
4503
|
-
await fire(h.handlers,"session_start",first.ctx);
|
|
4504
|
-
assert.match(renderFooter(first.ui),/±1/);
|
|
4505
4781
|
await fire(h.handlers,"session_start",first.ctx);
|
|
4506
|
-
|
|
4782
|
+
await h.tools.get("session_worktree_register")!.execute("r",{path:"/linked"},undefined,undefined,first.ctx);
|
|
4783
|
+
await fire(h.handlers,"session_shutdown",first.ctx);
|
|
4507
4784
|
const next=fakeContext({entries:[...first.ctx.sessionManager.getEntries()]});
|
|
4508
4785
|
(next.ctx.sessionManager as any).getSessionId=()=>"new-session";
|
|
4509
4786
|
await fire(h.handlers,"session_start",next.ctx);
|
|
4510
|
-
h.
|
|
4511
|
-
|
|
4512
|
-
|
|
4787
|
+
h.git.length=0;
|
|
4788
|
+
await fire(h.handlers,"agent_end",next.ctx);
|
|
4789
|
+
await changesSettled();
|
|
4790
|
+
assert.ok(h.git.length>0);
|
|
4791
|
+
assert.ok(h.git.every((args)=>args[1]==="/repo"), JSON.stringify(h.git));
|
|
4513
4792
|
await fire(h.handlers,"session_shutdown",next.ctx);
|
|
4514
4793
|
});
|
|
4515
4794
|
|
|
@@ -4551,7 +4830,7 @@ test("registered canonical root governs real Git discovery, status and diff desp
|
|
|
4551
4830
|
installGentleShell(h.pi, isolatedEnv({ GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }), { devBinary: () => undefined, gitRunner: (cwd) => shellGitRunner(cwd, poisoned) });
|
|
4552
4831
|
await fire(h.handlers, "session_start", ctx);
|
|
4553
4832
|
t.after(() => fire(h.handlers, "session_shutdown", ctx));
|
|
4554
|
-
assert.equal(ui.widgets.has("gentle-shell-changes"), false, "
|
|
4833
|
+
assert.equal(ui.widgets.has("gentle-shell-changes"), false, "startup never scans the repository");
|
|
4555
4834
|
const diff = await loadFileDiff(run, { path: "tracked.txt", added: 1, deleted: 1, status: CHANGE_STATUS.MODIFIED });
|
|
4556
4835
|
assert.match(diff, /\+selected change/);
|
|
4557
4836
|
assert.doesNotMatch(diff, /foreign change/);
|
|
@@ -4565,8 +4844,15 @@ test("registered canonical root governs real Git discovery, status and diff desp
|
|
|
4565
4844
|
assert.equal(largeDiff.code, 0);
|
|
4566
4845
|
assert.ok(largeDiff.stdout.length > 1024 * 1024, "output must not inherit execFile's default one MiB cap");
|
|
4567
4846
|
assert.match(largeDiff.stdout, /\+selected final marker/);
|
|
4568
|
-
|
|
4569
|
-
assert.equal(
|
|
4847
|
+
const bounded = shellGitRunner(selected, poisoned, execFile, DIFF_MAX_BYTES);
|
|
4848
|
+
assert.equal((await bounded(["diff", "HEAD", "--", "tracked.txt"])).truncated, true, "the diff runner stops at its bound instead of buffering everything");
|
|
4849
|
+
assert.ok(await loadFileDiff(bounded, { path: "tracked.txt", added: 70_001, deleted: 1, status: CHANGE_STATUS.MODIFIED }) === DIFF_TOO_LARGE);
|
|
4850
|
+
const opened = h.commands.get("gentle:changes")!.handler("", ctx);
|
|
4851
|
+
await overlayReady;
|
|
4852
|
+
const trees = (ui.overlayView as unknown as { trees: Array<{ root: string; model: { files: Array<{ path: string }> } }> }).trees;
|
|
4853
|
+
assert.deepEqual(trees.map((tree) => [tree.root, tree.model.files.map((file) => file.path)]), [[selected, ["selected-only.txt", "tracked.txt"]]], "inherited routing never lists the foreign repository");
|
|
4854
|
+
ui.closeOverlay?.();
|
|
4855
|
+
await opened;
|
|
4570
4856
|
});
|
|
4571
4857
|
|
|
4572
4858
|
test("loadFileDiff asks git for a HEAD diff, or a no-index diff for untracked files", async () => {
|
|
@@ -4583,9 +4869,20 @@ test("loadFileDiff asks git for a HEAD diff, or a no-index diff for untracked fi
|
|
|
4583
4869
|
]);
|
|
4584
4870
|
});
|
|
4585
4871
|
|
|
4872
|
+
test("loadFileDiff replaces one oversized diff with a short open-in-editor line", async () => {
|
|
4873
|
+
const answer = (stdout: string, truncated?: boolean) => async () => ({ stdout, code: 0, ...(truncated ? { truncated } : {}) });
|
|
4874
|
+
const file = { path: "big.ts", added: 1, deleted: 0, status: CHANGE_STATUS.MODIFIED };
|
|
4875
|
+
// assert.ok keeps a failure from printing megabytes of diff.
|
|
4876
|
+
assert.ok(await loadFileDiff(answer("+x\n".repeat(DIFF_MAX_LINES + 1)), file) === DIFF_TOO_LARGE, "too many lines");
|
|
4877
|
+
assert.ok(await loadFileDiff(answer("+" + "x".repeat(DIFF_MAX_BYTES)), file) === DIFF_TOO_LARGE, "too many bytes");
|
|
4878
|
+
assert.ok(await loadFileDiff(answer("+partial", true), file) === DIFF_TOO_LARGE, "a runner that hit its buffer bound");
|
|
4879
|
+
assert.match(DIFF_TOO_LARGE, /too large.*o.*editor/i);
|
|
4880
|
+
assert.equal(await loadFileDiff(answer("+x\n".repeat(10)), file), "+x\n".repeat(10));
|
|
4881
|
+
});
|
|
4882
|
+
|
|
4586
4883
|
test("shell Git runner hides initial and repeated background polling children", async () => {
|
|
4587
4884
|
const calls: Array<{ command: string; args: readonly string[]; options: Record<string, unknown> }> = [];
|
|
4588
|
-
const run = ((command: string, args: readonly string[], options: Record<string, unknown>, callback: (error: Error | null, stdout: string) => void) => {
|
|
4885
|
+
const run = ((command: string, args: readonly string[], options: Record<string, unknown>, callback: (error: Error | null, stdout: string, stderr: string) => void) => {
|
|
4589
4886
|
calls.push({ command, args, options });
|
|
4590
4887
|
callback(null, "", "");
|
|
4591
4888
|
}) as typeof import("node:child_process").execFile;
|
|
@@ -4638,23 +4935,24 @@ test("gentleShell binds the changes shortcut to the same handler as the command"
|
|
|
4638
4935
|
const shortcut = shortcuts.get("alt+g");
|
|
4639
4936
|
assert.ok(shortcut, "alt+g not registered");
|
|
4640
4937
|
await shortcut.handler(ctx);
|
|
4641
|
-
assert.match(ui.notices.join("\n"), /No
|
|
4938
|
+
assert.match(ui.notices.join("\n"), /No uncommitted changes/);
|
|
4642
4939
|
|
|
4643
4940
|
const silent = fakePi();
|
|
4644
4941
|
gentleShell(silent.pi, { GENTLE_PI_SHELL_CHANGES_KEY: "off" });
|
|
4645
4942
|
assert.equal(silent.shortcuts.has("alt+g"), false, "the changes shortcut must not register when disabled");
|
|
4646
4943
|
});
|
|
4647
4944
|
|
|
4648
|
-
test("
|
|
4945
|
+
test("Changes never polls Git in the background; external edits appear at turn end", async () => {
|
|
4649
4946
|
const {pi,handlers,git}=fakePi([{numstat:"4\t2\texternal.ts\n",porcelain:" M external.ts\0"}]);
|
|
4650
4947
|
gentleShell(pi,{GENTLE_PI_SHELL_CHANGES_WATCH_MS:"5"});
|
|
4651
4948
|
const {ctx,ui}=fakeContext();
|
|
4652
4949
|
await fire(handlers,"session_start",ctx);
|
|
4653
4950
|
await new Promise(resolve=>setTimeout(resolve,30));
|
|
4654
|
-
await fire(handlers,"agent_end",ctx);
|
|
4655
4951
|
assert.equal(git.length,0);
|
|
4656
4952
|
assert.equal(ui.widgets.has("gentle-shell-changes"),false);
|
|
4657
|
-
|
|
4953
|
+
await fire(handlers,"agent_end",ctx);
|
|
4954
|
+
await changesSettled();
|
|
4955
|
+
assert.match(renderFooter(ui),/±1/);
|
|
4658
4956
|
await fire(handlers,"session_shutdown",ctx);
|
|
4659
4957
|
});
|
|
4660
4958
|
|
|
@@ -5059,6 +5357,28 @@ test("gentleShell records SSE rate-limit headers from provider responses", async
|
|
|
5059
5357
|
assert.doesNotMatch(renderFooter(ui), /codex/);
|
|
5060
5358
|
});
|
|
5061
5359
|
|
|
5360
|
+
test("gentleShell ignores incomplete Codex headers and retains the last real quota without assuming 5h", async () => {
|
|
5361
|
+
const { pi, handlers } = fakePi();
|
|
5362
|
+
gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { fetch: fakeFetch({}, false).fetchFn, now: () => 0 });
|
|
5363
|
+
const { ctx, ui } = fakeContext();
|
|
5364
|
+
await fire(handlers, "session_start", ctx);
|
|
5365
|
+
const respond = (headers: Record<string, string>) => {
|
|
5366
|
+
for (const handler of handlers.get("after_provider_response") ?? []) handler({ status: 200, headers }, ctx);
|
|
5367
|
+
};
|
|
5368
|
+
respond({ "x-codex-primary-used-percent": "0" });
|
|
5369
|
+
assert.doesNotMatch(renderFooter(ui), /codex|0m/, "missing duration is not a real quota");
|
|
5370
|
+
respond({ "x-codex-secondary-used-percent": "31", "x-codex-secondary-window-minutes": "10080" });
|
|
5371
|
+
const valid = renderFooter(ui);
|
|
5372
|
+
assert.match(valid, /codex week .*31%/);
|
|
5373
|
+
assert.doesNotMatch(valid, /5h|0m/);
|
|
5374
|
+
for (const minutes of [undefined, "0", "invalid"]) {
|
|
5375
|
+
const headers: Record<string, string> = { "x-codex-primary-used-percent": "0" };
|
|
5376
|
+
if (minutes !== undefined) headers["x-codex-primary-window-minutes"] = minutes;
|
|
5377
|
+
respond(headers);
|
|
5378
|
+
assert.equal(renderFooter(ui), valid, "a malformed response must not replace the known weekly quota");
|
|
5379
|
+
}
|
|
5380
|
+
});
|
|
5381
|
+
|
|
5062
5382
|
test("gentleShell registers /gentle:usage and opens the subscriptions overlay", async () => {
|
|
5063
5383
|
const { pi, handlers, commands } = fakePi();
|
|
5064
5384
|
gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { fetch: fakeFetch().fetchFn, now: () => 1_788_600_000_000 });
|
|
@@ -5209,14 +5529,14 @@ test("/gentle:commands shows only curated, registered commands, grouped, by thei
|
|
|
5209
5529
|
assert.match(rendered, /Skills/);
|
|
5210
5530
|
assert.doesNotMatch(rendered, /\bSDD\b/, "the SDD group has no registered commands and must not appear");
|
|
5211
5531
|
assert.match(rendered, /Assign models and effort/);
|
|
5212
|
-
assert.match(rendered, /Browse
|
|
5532
|
+
assert.match(rendered, /Browse changes/);
|
|
5213
5533
|
assert.match(rendered, /Gentle AI status/);
|
|
5214
5534
|
assert.match(rendered, /Refresh skill registry/);
|
|
5215
5535
|
assert.doesNotMatch(rendered, /gentle:models|gentle:changes|gentle:status|skill-registry:refresh/, "raw command names must not leak; only labels are shown");
|
|
5216
5536
|
assert.doesNotMatch(rendered, /gentle:not-in-catalog/);
|
|
5217
5537
|
assert.doesNotMatch(rendered, /skill:foo/);
|
|
5218
|
-
const changesLine = lines.find((line) => line.includes("Browse
|
|
5219
|
-
assert.match(changesLine ?? "", /alt\+g/, "expected the configured alt+g shortcut hint next to Browse
|
|
5538
|
+
const changesLine = lines.find((line) => line.includes("Browse changes"));
|
|
5539
|
+
assert.match(changesLine ?? "", /alt\+g/, "expected the configured alt+g shortcut hint next to Browse changes");
|
|
5220
5540
|
ui.overlayView!.handleInput("\x1b");
|
|
5221
5541
|
await opened;
|
|
5222
5542
|
});
|
|
@@ -5633,7 +5953,10 @@ test("after a profile switch the panel stops presenting the old profile's provid
|
|
|
5633
5953
|
ui.closeOverlay?.();
|
|
5634
5954
|
await opened;
|
|
5635
5955
|
|
|
5636
|
-
|
|
5956
|
+
// An open session changes profile only through its own Enter (gentle-shell#1064
|
|
5957
|
+
// slice 3b-ii): a new global default would leave its frozen routing in place.
|
|
5958
|
+
bindSessionProfile("shell-session", "solo", {});
|
|
5959
|
+
t.after(() => resetSessionProfileBindingsForTesting());
|
|
5637
5960
|
const reopened = commands.get("gentle:usage")!.handler("", ctx);
|
|
5638
5961
|
await settle();
|
|
5639
5962
|
assert.equal(ui.overlayView!.render(90).map(stripAnsi).some((line) => line.includes("nan")), false, "a provider recorded under the previous profile's routing is not current scope");
|
|
@@ -5641,6 +5964,61 @@ test("after a profile switch the panel stops presenting the old profile's provid
|
|
|
5641
5964
|
await reopened;
|
|
5642
5965
|
});
|
|
5643
5966
|
|
|
5967
|
+
test("an open session keeps its frozen profile in the status and Usage scope when the defaults change", async (t) => {
|
|
5968
|
+
const home = mkdtempSync(join(tmpdir(), "shell-frozen-"));
|
|
5969
|
+
const commonDir = mkdtempSync(join(tmpdir(), "shell-frozen-git-"));
|
|
5970
|
+
t.after(() => {
|
|
5971
|
+
rmSync(home, { recursive: true, force: true });
|
|
5972
|
+
rmSync(commonDir, { recursive: true, force: true });
|
|
5973
|
+
});
|
|
5974
|
+
mkdirSync(join(commonDir, "gentle-ai"), { recursive: true });
|
|
5975
|
+
writeProfilesStore(home, { team: { reviewer: { model: "nan/glm5.3" } }, solo: { reviewer: { model: "openai-codex/gpt-5.5" } } }, "team");
|
|
5976
|
+
const { pi, handlers, commands } = fakePi();
|
|
5977
|
+
gentleShell(pi, { GENTLE_PI_CONFIG_HOME: home, GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { fetch: fakeFetch(NAN_QUOTA_PAYLOAD).fetchFn, now: () => 1_788_600_000_000, resolveWorktree: () => ({ root: "/repo", commonDir }) });
|
|
5978
|
+
const { ctx, ui } = fakeContext({ token: JWT });
|
|
5979
|
+
await fire(handlers, "session_start", ctx);
|
|
5980
|
+
assert.equal(readFrozenInheritedProfile("shell-session")?.profile?.name, "team", "the session froze the global active profile at start");
|
|
5981
|
+
writeProfilesStore(home, { team: { reviewer: { model: "nan/glm5.3" } }, solo: { reviewer: { model: "openai-codex/gpt-5.5" } } }, "solo");
|
|
5982
|
+
writeFileSync(join(commonDir, "gentle-ai", "profile-pin.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profile_pin", version: 1, profile: "solo" }));
|
|
5983
|
+
const opened = commands.get("gentle:usage")!.handler("", ctx);
|
|
5984
|
+
await settle();
|
|
5985
|
+
assert.ok(openPanelLines(ui).some((line) => /^│ nan ·/.test(line)), "Usage keeps the frozen profile's nan route");
|
|
5986
|
+
ui.closeOverlay?.();
|
|
5987
|
+
await opened;
|
|
5988
|
+
const factory = ui.footerFactory as (tui: unknown, theme: ShellBarTheme, data: unknown) => { dispose(): void };
|
|
5989
|
+
const tui = { terminal: { rows: 40, columns: 160 }, requestRender() {} };
|
|
5990
|
+
const component = factory(tui, plainTheme, { getGitBranch: () => "main", getExtensionStatuses: () => new Map(), getAvailableProviderCount: () => 1, onBranchChange: () => () => {} });
|
|
5991
|
+
try {
|
|
5992
|
+
const status = (sidebarState(tui as unknown as TUI).parts.get("footer") as SidebarRail).render(60).join("\n");
|
|
5993
|
+
assert.match(status, /Profile.*team/, "the status keeps the bare global name of the frozen profile");
|
|
5994
|
+
assert.doesNotMatch(status, /solo/);
|
|
5995
|
+
} finally {
|
|
5996
|
+
component.dispose();
|
|
5997
|
+
}
|
|
5998
|
+
});
|
|
5999
|
+
|
|
6000
|
+
test("active profile reader: a bound session shows its frozen profile, and follow shows the live default", (t) => {
|
|
6001
|
+
const root = mkdtempSync(join(tmpdir(), "shell-profile-frozen-"));
|
|
6002
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
6003
|
+
const commonDir = join(root, "git");
|
|
6004
|
+
mkdirSync(join(commonDir, "gentle-ai"), { recursive: true });
|
|
6005
|
+
writeFileSync(join(root, "profiles.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, active: "team", profiles: { team: {}, other: {} } }));
|
|
6006
|
+
const pin = (profile: string) => writeFileSync(join(commonDir, "gentle-ai", "profile-pin.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profile_pin", version: 1, profile }));
|
|
6007
|
+
pin("other");
|
|
6008
|
+
const frozen = createActiveProfileReader({ GENTLE_PI_CONFIG_HOME: root });
|
|
6009
|
+
frozen.bind(root, () => ({ root, commonDir }), "session-frozen");
|
|
6010
|
+
assert.equal(frozen(), "other (local)");
|
|
6011
|
+
const follow = createActiveProfileReader({ GENTLE_PI_CONFIG_HOME: root, GENTLE_PI_PROFILE_FOLLOW: "1" });
|
|
6012
|
+
follow.bind(root, () => ({ root, commonDir }), "session-follow");
|
|
6013
|
+
assert.equal(follow(), "other (local)");
|
|
6014
|
+
rmSync(join(commonDir, "gentle-ai", "profile-pin.json"));
|
|
6015
|
+
assert.equal(frozen.refresh(), false, "the frozen label does not move");
|
|
6016
|
+
assert.equal(frozen(), "other (local)");
|
|
6017
|
+
assert.equal(follow.refresh(), true);
|
|
6018
|
+
assert.equal(follow(), "team", "follow falls to the global active profile");
|
|
6019
|
+
assert.equal(readFrozenInheritedProfile("session-follow"), undefined);
|
|
6020
|
+
});
|
|
6021
|
+
|
|
5644
6022
|
function openPanelLines(ui: FakeUi): string[] {
|
|
5645
6023
|
return ui.overlayView!.render(90).map(stripAnsi);
|
|
5646
6024
|
}
|
|
@@ -5832,3 +6210,136 @@ test("an older overlapping refresh cannot mark a provider failed after a newer o
|
|
|
5832
6210
|
ui.closeOverlay?.();
|
|
5833
6211
|
await opened;
|
|
5834
6212
|
});
|
|
6213
|
+
|
|
6214
|
+
test("active profile reader prefers a session binding, labels it (session), and falls back when it clears", (t) => {
|
|
6215
|
+
const root = mkdtempSync(join(tmpdir(), "shell-profile-session-"));
|
|
6216
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
6217
|
+
const path = join(root, "profiles.json");
|
|
6218
|
+
writeFileSync(path, JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, active: "team", profiles: { team: {}, work: {} } }));
|
|
6219
|
+
resetSessionProfileBindingsForTesting();
|
|
6220
|
+
bindSessionProfile("session-1", "work", { worker: { model: "zai/glm-4.7" } });
|
|
6221
|
+
const read = createActiveProfileReader({ GENTLE_PI_CONFIG_HOME: root });
|
|
6222
|
+
assert.equal(read(), "team", "an unbound reader still reads the global active profile");
|
|
6223
|
+
read.bind(root, () => undefined, "session-1");
|
|
6224
|
+
assert.equal(read(), "work (session)", "a bound session outranks the global active profile");
|
|
6225
|
+
assert.equal(read(), "work (session)", "the session label is stable across reads");
|
|
6226
|
+
clearSessionProfileBinding("session-1");
|
|
6227
|
+
assert.equal(read.refresh(), true, "refresh detects the cleared binding");
|
|
6228
|
+
assert.equal(read(), "team", "the read falls back once the binding clears");
|
|
6229
|
+
read.reset();
|
|
6230
|
+
assert.equal(read(), "team", "reset keeps the global read working");
|
|
6231
|
+
resetSessionProfileBindingsForTesting();
|
|
6232
|
+
});
|
|
6233
|
+
|
|
6234
|
+
test("active profile reader keeps the pin label when a different session is bound", (t) => {
|
|
6235
|
+
const root = mkdtempSync(join(tmpdir(), "shell-profile-session-pin-"));
|
|
6236
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
6237
|
+
writeFileSync(join(root, "profiles.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, active: "team", profiles: { team: {}, work: {} } }));
|
|
6238
|
+
resetSessionProfileBindingsForTesting();
|
|
6239
|
+
bindSessionProfile("session-other", "work", { worker: { model: "zai/glm-4.7" } });
|
|
6240
|
+
const read = createActiveProfileReader({ GENTLE_PI_CONFIG_HOME: root });
|
|
6241
|
+
read.bind(root, () => ({ root, commonDir: root }), "session-1");
|
|
6242
|
+
assert.equal(read(), "team", "no pin file and no binding for this session: the global active profile governs");
|
|
6243
|
+
clearSessionProfileBinding("session-other");
|
|
6244
|
+
resetSessionProfileBindingsForTesting();
|
|
6245
|
+
});
|
|
6246
|
+
|
|
6247
|
+
test("an explicit session binding routes the Usage scope ahead of the pin and the global layers", async (t) => {
|
|
6248
|
+
const home = mkdtempSync(join(tmpdir(), "shell-usage-session-"));
|
|
6249
|
+
const commonDir = mkdtempSync(join(tmpdir(), "shell-usage-session-git-"));
|
|
6250
|
+
t.after(() => {
|
|
6251
|
+
rmSync(home, { recursive: true, force: true });
|
|
6252
|
+
rmSync(commonDir, { recursive: true, force: true });
|
|
6253
|
+
});
|
|
6254
|
+
mkdirSync(join(commonDir, "gentle-ai"), { recursive: true });
|
|
6255
|
+
// Every shared layer points at openai-codex (the pin wins over the global
|
|
6256
|
+
// active profile), while the session binds nan explicitly: the Usage panel
|
|
6257
|
+
// must refresh the binding's provider, not the layers underneath it.
|
|
6258
|
+
writeProfilesStore(home, {
|
|
6259
|
+
team: { reviewer: { model: "openai-codex/gpt-5.5" } },
|
|
6260
|
+
solo: { reviewer: { model: "openai-codex/gpt-5.5" } },
|
|
6261
|
+
bound: { reviewer: { model: "nan/glm5.3" } },
|
|
6262
|
+
}, "team");
|
|
6263
|
+
writeFileSync(join(commonDir, "gentle-ai", "profile-pin.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profile_pin", version: 1, profile: "solo" }));
|
|
6264
|
+
resetSessionProfileBindingsForTesting();
|
|
6265
|
+
bindSessionProfile("shell-session", "bound", { reviewer: { model: "nan/glm5.3" } });
|
|
6266
|
+
try {
|
|
6267
|
+
const { pi, commands } = fakePi();
|
|
6268
|
+
gentleShell(pi, { GENTLE_PI_CONFIG_HOME: home, GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { fetch: fakeFetch(NAN_QUOTA_PAYLOAD).fetchFn, now: () => 1_788_600_000_000, resolveWorktree: () => ({ root: "/repo", commonDir }) });
|
|
6269
|
+
const { ctx, ui } = fakeContext({ token: JWT });
|
|
6270
|
+
const opened = commands.get("gentle:usage")!.handler("", ctx);
|
|
6271
|
+
await settle();
|
|
6272
|
+
const lines = openPanelLines(ui).join("\n");
|
|
6273
|
+
assert.ok(lines.split("\n").some((line) => /^│ nan ·/.test(line)), "the Usage panel refreshes the session binding's nan provider");
|
|
6274
|
+
assert.ok(!lines.split("\n").some((line) => /^│ openai-codex ·/.test(line)), "the pinned codex route stays out of the bound session's Usage scope");
|
|
6275
|
+
ui.closeOverlay?.();
|
|
6276
|
+
await opened;
|
|
6277
|
+
} finally {
|
|
6278
|
+
resetSessionProfileBindingsForTesting();
|
|
6279
|
+
}
|
|
6280
|
+
});
|
|
6281
|
+
|
|
6282
|
+
// S9: the config home each of these reads resolves, per case. The OS home is
|
|
6283
|
+
// a temporary folder in every case, so no case can reach the real one.
|
|
6284
|
+
// "unset" and "empty" are exactly as before: an empty GENTLE_PI_CONFIG_HOME
|
|
6285
|
+
// names the current folder. "bundled" stands in for this package running from
|
|
6286
|
+
// a bundled install (testing overlay) whose prefix the launcher passed; the
|
|
6287
|
+
// user's own GENTLE_PI_CONFIG_HOME is then not this install's.
|
|
6288
|
+
type ConfigHomeCase = "unset" | "empty" | "bundled";
|
|
6289
|
+
function configHomeCase(t: { after: (fn: () => void) => void }, kind: ConfigHomeCase): { env: NodeJS.ProcessEnv; dir: string; userConfig: string } {
|
|
6290
|
+
const base = realpathSync(mkdtempSync(join(tmpdir(), `config-home-${kind}-`)));
|
|
6291
|
+
const saved = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE, GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME, GENTLE_SHELL_PREFIX: process.env.GENTLE_SHELL_PREFIX };
|
|
6292
|
+
const cwd = process.cwd();
|
|
6293
|
+
process.env.HOME = join(base, "home");
|
|
6294
|
+
process.env.USERPROFILE = join(base, "home");
|
|
6295
|
+
delete process.env.GENTLE_PI_CONFIG_HOME;
|
|
6296
|
+
delete process.env.GENTLE_SHELL_PREFIX;
|
|
6297
|
+
t.after(() => {
|
|
6298
|
+
process.chdir(cwd);
|
|
6299
|
+
setBundledInstallForTesting(undefined);
|
|
6300
|
+
for (const [name, value] of Object.entries(saved)) {
|
|
6301
|
+
if (value === undefined) delete process.env[name];
|
|
6302
|
+
else process.env[name] = value;
|
|
6303
|
+
}
|
|
6304
|
+
rmSync(base, { recursive: true, force: true });
|
|
6305
|
+
});
|
|
6306
|
+
const userConfig = join(base, "user-config");
|
|
6307
|
+
if (kind === "unset") return { env: {}, dir: join(base, "home", ".pi", "gentle-ai"), userConfig };
|
|
6308
|
+
if (kind === "empty") {
|
|
6309
|
+
mkdirSync(join(base, "cwd"));
|
|
6310
|
+
process.chdir(join(base, "cwd"));
|
|
6311
|
+
return { env: { GENTLE_PI_CONFIG_HOME: "" }, dir: join(base, "cwd"), userConfig };
|
|
6312
|
+
}
|
|
6313
|
+
const prefix = join(base, "prefix");
|
|
6314
|
+
mkdirSync(prefix);
|
|
6315
|
+
setBundledInstallForTesting({ root: prefix });
|
|
6316
|
+
return { env: { GENTLE_SHELL_PREFIX: prefix, GENTLE_PI_CONFIG_HOME: userConfig }, dir: join(prefix, "config", "gentle-ai"), userConfig };
|
|
6317
|
+
}
|
|
6318
|
+
|
|
6319
|
+
function profileStore(dir: string, active: string, names: string[]) {
|
|
6320
|
+
mkdirSync(dir, { recursive: true });
|
|
6321
|
+
writeFileSync(join(dir, "profiles.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, active, profiles: Object.fromEntries(names.map((name) => [name, {}])) }));
|
|
6322
|
+
}
|
|
6323
|
+
|
|
6324
|
+
for (const kind of ["unset", "empty", "bundled"] as const) {
|
|
6325
|
+
test(`the profile reader reads the global store from the ${kind} config home`, (t) => {
|
|
6326
|
+
const { env, dir, userConfig } = configHomeCase(t, kind);
|
|
6327
|
+
// The user's own store names another profile: only the bundled case must ignore it.
|
|
6328
|
+
profileStore(userConfig, "team", ["team"]);
|
|
6329
|
+
profileStore(dir, "other", ["team", "other"]);
|
|
6330
|
+
assert.equal(createActiveProfileReader(env)(), "other");
|
|
6331
|
+
});
|
|
6332
|
+
|
|
6333
|
+
test(`the bound profile reader resolves pins against the ${kind} config home`, (t) => {
|
|
6334
|
+
const { env, dir, userConfig } = configHomeCase(t, kind);
|
|
6335
|
+
profileStore(userConfig, "team", ["team"]);
|
|
6336
|
+
profileStore(dir, "team", ["team", "other"]);
|
|
6337
|
+
const repo = join(dir, "..", `repo-${kind}`);
|
|
6338
|
+
mkdirSync(join(repo, ".pi", "gentle-ai"), { recursive: true });
|
|
6339
|
+
writeFileSync(join(repo, ".pi", "gentle-ai", "profile.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profile_pin", version: 1, profile: "other" }));
|
|
6340
|
+
const read = createActiveProfileReader(env);
|
|
6341
|
+
read.bind(repo, () => ({ root: repo, commonDir: join(repo, ".git-common") }));
|
|
6342
|
+
read.refresh();
|
|
6343
|
+
assert.equal(read(), "other (repo)", "the pinned profile exists only in that config home's store");
|
|
6344
|
+
});
|
|
6345
|
+
}
|