gentle-pi 3.7.0 → 4.1.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 +155 -10
- package/assets/agents/gentle-ai-explore.md +13 -6
- package/assets/agents/gentle-ai-verify.md +27 -6
- package/assets/agents/gentle-ai-worker.md +13 -15
- 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 +75 -169
- package/assets/orchestrator-memory.md +12 -26
- package/assets/orchestrator-prompts.md +48 -0
- package/assets/orchestrator-skills.md +1 -1
- 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 +30 -38
- 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/assets/support/strict-tdd-verify.md +4 -266
- package/assets/support/strict-tdd.md +8 -360
- package/bin/engram-guard +9 -0
- package/bin/gentle-shell-install.mjs +302 -0
- package/bin/gentle-shell.mjs +956 -50
- 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/bundled-install.md +397 -0
- package/docs/delegated-verification.md +27 -4
- package/docs/gentle-agents-activity.md +537 -0
- package/docs/gentle-shell-uninstall.md +136 -0
- package/docs/gentle-shell.md +165 -51
- package/docs/install-wizard.md +2040 -0
- package/docs/native-authority-architecture.md +3 -3
- package/docs/permission-modes.md +82 -0
- package/docs/prompt-history.md +280 -0
- package/docs/readme-reference.md +364 -231
- package/docs/render-cache-performance.md +28 -0
- package/docs/sound-notifications-proposal.md +191 -0
- package/docs/sound-notifications.md +114 -0
- package/docs/telemetry.md +1 -1
- package/docs/yolo-mode.md +86 -0
- package/extensions/child-context.ts +35 -0
- package/extensions/child-safety.ts +25 -0
- package/extensions/codegraph-tools.ts +18 -5
- package/extensions/gentle-agents.ts +1093 -326
- package/extensions/gentle-ai.ts +1636 -890
- package/extensions/gentle-herdr-activity.ts +81 -0
- package/extensions/gentle-notifications.ts +104 -0
- package/extensions/gentle-shell.ts +1619 -136
- package/extensions/gentle-stats.ts +101 -0
- package/extensions/gentle-todo.ts +126 -20
- package/extensions/history/atomic-write.ts +38 -0
- package/extensions/history/hide-prompts.ts +183 -0
- package/extensions/history/index.ts +1424 -0
- package/extensions/history/load-shared-history.ts +39 -0
- package/extensions/history/selector-helpers.ts +538 -0
- package/extensions/history/session-scan.ts +233 -0
- package/extensions/history/store.ts +1119 -0
- package/extensions/nan-provider.ts +6 -0
- package/extensions/pi-pretty.ts +39 -3
- package/extensions/quiet-tools.ts +254 -92
- package/extensions/resume-hint.ts +60 -0
- package/extensions/skill-registry.ts +191 -58
- package/extensions/startup-banner.ts +71 -33
- package/lib/agent-assets.ts +604 -0
- package/lib/agent-home.ts +10 -1
- package/lib/agent-model-resolution.ts +80 -0
- package/lib/agent-profile-pin.ts +12 -0
- package/lib/agent-profiles.ts +13 -3
- package/lib/agents-completion-delivery.ts +11 -3
- package/lib/agents-message-delivery.ts +181 -0
- package/lib/agents-messaging.ts +1 -1
- package/lib/agents-protocol.ts +99 -0
- package/lib/agents-runner.ts +180 -107
- package/lib/agents-view.ts +26 -3
- package/lib/agents-widget.ts +16 -9
- package/lib/append-system-prompt.ts +21 -0
- package/lib/auto-permission-classifier.ts +280 -0
- package/lib/auto-permission-runtime.ts +268 -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 +220 -0
- package/lib/builtin-codemode-optout.ts +166 -0
- package/lib/card-content-policy.ts +85 -0
- package/lib/card-style-policy.ts +60 -0
- package/lib/child-context-files.ts +166 -0
- package/lib/child-package-injection.ts +62 -0
- package/lib/codemode-renderer.ts +195 -0
- package/lib/command-palette-catalog.ts +5 -10
- package/lib/command-palette.ts +25 -14
- package/lib/context-threshold.ts +26 -0
- package/lib/destructive-command-guard.ts +144 -0
- package/lib/gentle-ai-binary.ts +109 -4
- package/lib/gentle-ai-elapsed-store.ts +87 -0
- package/lib/gentle-ai-renderer.ts +196 -39
- package/lib/gentle-shell-bundled.ts +517 -0
- package/lib/gentle-shell-launcher.ts +89 -27
- package/lib/gentle-shell-resume-hint.ts +176 -0
- 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/history-capture-policy.ts +95 -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 +5 -1
- package/lib/nan-provider.ts +249 -0
- package/lib/native-review-cli.ts +242 -180
- 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-inference.ts +231 -0
- package/lib/odd-phase.ts +141 -0
- 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/overlay-repaint.ts +26 -0
- package/lib/permission-mode-policy.ts +59 -0
- package/lib/pi-tui-keys.ts +53 -0
- package/lib/review-candidate-view-owner.ts +73 -21
- package/lib/review-candidate-view.ts +115 -27
- 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-reminder-receipt.ts +48 -8
- package/lib/review-risk-assessment.ts +227 -56
- package/lib/review-sidebar-state.ts +224 -0
- 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 +527 -0
- package/lib/session-change-capture.ts +5 -4
- package/lib/session-changes.ts +27 -22
- package/lib/session-messaging-grants.ts +135 -0
- package/lib/session-profile-binding.ts +83 -0
- package/lib/session-profile-freeze.ts +266 -0
- package/lib/session-worktree-registry.ts +87 -9
- package/lib/shell-bar.ts +215 -30
- package/lib/shell-card.ts +287 -18
- package/lib/shell-changes-view.ts +42 -14
- package/lib/shell-changes.ts +86 -71
- package/lib/shell-prompt.ts +102 -6
- package/lib/shell-sidebar-layout.ts +83 -15
- package/lib/shell-sidebar.ts +15 -1
- package/lib/shell-todo.ts +84 -31
- package/lib/shell-usage-view.ts +9 -4
- package/lib/shell-usage.ts +70 -13
- package/lib/stats-collector.ts +381 -0
- package/lib/stats-view.ts +431 -0
- package/lib/status-timing-diagnostics.ts +201 -0
- package/lib/telemetry-trigger.ts +3 -1
- package/lib/theme-customization.ts +52 -0
- package/lib/vim-editor-adapter.ts +383 -0
- package/lib/vim-normal-engine.ts +154 -0
- package/lib/vim-operator-engine.ts +416 -0
- package/lib/vim-policy.ts +49 -0
- package/lib/vim-visual-engine.ts +107 -0
- package/lib/visual-customization-policy.ts +119 -0
- package/lib/visual-customize-view.ts +481 -0
- package/lib/visual-profiles.ts +228 -0
- package/lib/writer-surfaces.ts +162 -0
- package/lib/yolo-session-policy.ts +317 -0
- package/package.json +20 -8
- package/runtime/child-package-injection.mjs +63 -0
- package/runtime/gentle-ai-binary.mjs +109 -4
- package/runtime/gentle-shell-bundled.mjs +518 -0
- package/runtime/gentle-shell-launcher.mjs +87 -25
- package/runtime/gentle-shell-resume-hint.mjs +177 -0
- 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 +239 -177
- package/runtime/review-integration-v2.mjs +100 -22
- package/runtime/review-risk-assessment.mjs +225 -54
- 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 +8 -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 +83 -35
- 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/mirror-odd-routing.mjs +2 -2
- package/scripts/npm-pack-result.mjs +31 -0
- package/scripts/render-cache-performance-worker.mjs +205 -0
- package/scripts/render-cache-performance.mjs +115 -0
- package/scripts/run-test-suite.mjs +90 -0
- package/scripts/test-installer-acceptance.sh +161 -0
- package/scripts/test-packed-runner.mjs +54 -19
- package/scripts/types-baseline.json +2 -92
- package/scripts/verify-package-files.mjs +91 -38
- package/skills/branch-pr/SKILL.md +24 -52
- package/skills/chained-pr/SKILL.md +31 -15
- package/skills/chained-pr/references/chaining-details.md +31 -20
- package/skills/gentle-ai/SKILL.md +16 -21
- package/skills/issue-creation/SKILL.md +8 -2
- package/skills/issue-creation/references/delegated-workflow-actions.md +19 -0
- package/skills/work-unit-commits/SKILL.md +4 -3
- package/tests/agent-home.test.ts +56 -3
- package/tests/agent-model-resolution.test.ts +113 -0
- package/tests/agent-profiles.test.ts +30 -0
- package/tests/agent-risk-escalation.test.ts +123 -0
- package/tests/agents-completion-delivery.test.ts +17 -0
- package/tests/agents-fake-child.ts +2 -2
- package/tests/agents-message-delivery.test.ts +106 -0
- package/tests/agents-protocol.test.ts +97 -0
- package/tests/agents-queries.test.ts +9 -9
- package/tests/agents-runner-process.test.ts +31 -0
- package/tests/agents-runner.test.ts +467 -93
- package/tests/agents-view-thread-identity.test.ts +169 -0
- package/tests/agents-view.test.ts +8 -2
- package/tests/agents-widget.test.ts +154 -15
- package/tests/append-system-prompt-route.test.ts +160 -0
- package/tests/append-system-prompt.test.ts +46 -0
- package/tests/artifact-language.test.ts +19 -213
- package/tests/ask-user-question.test.ts +44 -1
- package/tests/asset-installation-runtime.test.ts +5 -16
- package/tests/auto-mode-invariants.test.ts +258 -0
- package/tests/auto-mode-sdk.test.ts +57 -0
- package/tests/auto-mode.test.ts +49 -0
- package/tests/auto-permission-classifier.test.ts +283 -0
- package/tests/auto-permission-runtime.test.ts +196 -0
- package/tests/autonomous-guard.test.ts +69 -1
- 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 +146 -0
- package/tests/branch-pr-skill.test.ts +43 -0
- 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 +87 -0
- package/tests/chained-pr-skill.test.ts +124 -0
- package/tests/child-codegraph-bootstrap.test.ts +74 -0
- package/tests/child-context-files.test.ts +255 -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 +94 -0
- package/tests/child-web-package.test.ts +224 -0
- package/tests/codegraph-tools.test.ts +35 -0
- package/tests/codemode-rendering.test.ts +596 -0
- package/tests/command-palette.test.ts +43 -7
- 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 -78
- package/tests/destructive-command-guard.test.ts +84 -0
- package/tests/devbinary/native-review-parity.devtest.ts +171 -3
- package/tests/devbinary/non-git-subagent-bootstrap.devtest.ts +193 -0
- package/tests/devbinary/pi-host-relay.devtest.ts +173 -3
- package/tests/engram-safety.test.ts +288 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +7 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +3 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T08-00-00-000Z_ddd.jsonl +2 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T09-00-00-000Z_eee.jsonl +3 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/run-1/session.jsonl +2 -0
- package/tests/fixtures/stats/sessions/--work-beta--/2026-09-01T12-00-00-000Z_ccc.jsonl +2 -0
- package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +2 -0
- package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +4 -0
- package/tests/fixtures/stats/user-pi/sessions/--work-gamma--/2026-09-20T09-00-00-000Z_fff.jsonl +3 -0
- package/tests/generic-agent-tools.test.ts +69 -0
- package/tests/gentle-agents.test.ts +2804 -346
- package/tests/gentle-ai-binary-isolation.test.ts +135 -0
- package/tests/gentle-ai-binary.test.ts +21 -4
- 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-elapsed-store.test.ts +68 -0
- package/tests/gentle-ai-installer.test.ts +566 -62
- package/tests/gentle-ai-prompt-model-routing.test.ts +123 -0
- package/tests/gentle-ai-renderer.test.ts +493 -14
- package/tests/gentle-ai.test.ts +1427 -236
- package/tests/gentle-card-text.ts +2 -1
- package/tests/gentle-herdr-activity.test.ts +100 -0
- package/tests/gentle-notifications.test.ts +427 -0
- package/tests/gentle-shell-bin.test.ts +896 -115
- package/tests/gentle-shell-bundled.test.ts +1002 -0
- package/tests/gentle-shell-launcher.test.ts +333 -65
- package/tests/gentle-shell-resume-hint.test.ts +270 -0
- 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 +4216 -119
- package/tests/gentle-stats.test.ts +152 -0
- package/tests/gentle-theme.test.ts +4 -1
- package/tests/gentle-todo.test.ts +333 -10
- package/tests/herdr-activity.test.ts +243 -0
- package/tests/history-agent-home.test.ts +81 -0
- package/tests/history-atomic-write.test.ts +57 -0
- package/tests/history-capture-policy.test.ts +102 -0
- package/tests/history-child-guard.test.ts +70 -0
- package/tests/history-command-registration.test.ts +164 -0
- package/tests/history-dedupe-entries.test.ts +123 -0
- package/tests/history-delete-backfill.test.ts +190 -0
- package/tests/history-delete-confirm.test.ts +460 -0
- package/tests/history-dispatch.test.ts +180 -0
- package/tests/history-drain-hidden.test.ts +110 -0
- package/tests/history-drain-order.test.ts +98 -0
- package/tests/history-expanded-globals.test.ts +62 -0
- package/tests/history-gc.test.ts +832 -0
- package/tests/history-header-layout.test.ts +265 -0
- package/tests/history-hide-prompts.test.ts +275 -0
- package/tests/history-lazy-windowing.test.ts +508 -0
- package/tests/history-legacy-migrate-v2.test.ts +297 -0
- package/tests/history-load-shared-history.test.ts +53 -0
- package/tests/history-max-results-cap.test.ts +76 -0
- package/tests/history-multi-reader.test.ts +203 -0
- package/tests/history-off-path.test.ts +170 -0
- package/tests/history-openflow-integration.test.ts +173 -0
- package/tests/history-overlay-margin.test.ts +326 -0
- package/tests/history-preview-layout.test.ts +93 -0
- package/tests/history-registry.test.ts +143 -0
- package/tests/history-scope-delete.test.ts +411 -0
- package/tests/history-search-caret-keys.test.ts +142 -0
- package/tests/history-seed-bootstrap.test.ts +170 -0
- package/tests/history-seed-regen.test.ts +129 -0
- package/tests/history-selector-windowing.test.ts +94 -0
- package/tests/history-session-scan-directory.test.ts +87 -0
- package/tests/history-session-scan-extract.test.ts +583 -0
- package/tests/history-session-writer.test.ts +351 -0
- package/tests/history-store-paths.test.ts +79 -0
- package/tests/history-tombstone-exact.test.ts +139 -0
- package/tests/history-wheel-mouse.test.ts +242 -0
- package/tests/inprocess-reviewer.test.ts +49 -19
- 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 +2158 -0
- package/tests/installer-server.test.ts +1567 -0
- package/tests/installer-windows-bootstrap.test.ts +2410 -0
- package/tests/issue-creation-skill.test.ts +61 -0
- package/tests/jobs-view.test.ts +160 -0
- package/tests/main-channel-upgrade.test.ts +389 -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-authority.test.ts +16 -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 +481 -0
- package/tests/native-binary-gate.test.ts +2 -0
- package/tests/native-review-capability-contract.test.ts +13 -1
- package/tests/native-review-cli.test.ts +239 -118
- package/tests/native-review-consent.test.ts +2 -2
- package/tests/native-review-parity-runtime.test.ts +101 -4
- 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 +76 -0
- package/tests/odd-phase-inference.test.ts +213 -0
- package/tests/odd-phase-loader.test.ts +253 -0
- package/tests/odd-phase.test.ts +307 -0
- package/tests/odd-routing-canonical-ratchet.test.ts +60 -14
- package/tests/odd-routing-contract.test.ts +158 -50
- package/tests/orchestrator-budget.test.ts +43 -45
- 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 +8 -7
- 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/overlay-repaint.test.ts +74 -0
- package/tests/package-manifest.test.ts +344 -123
- package/tests/packed-runner-owned-path.test.ts +80 -0
- 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 +10 -18
- package/tests/pi-pretty.test.ts +56 -0
- package/tests/provider-defect-handoff.test.ts +7 -14
- package/tests/quiet-bash-runtime.test.ts +76 -0
- package/tests/quiet-tool-rendering.test.ts +569 -187
- package/tests/rdd-aware-verification-contract.test.ts +103 -40
- package/tests/rdd-status-line.test.ts +9 -4
- package/tests/render-cache-performance.test.ts +48 -0
- package/tests/resume-hint-extension.test.ts +122 -0
- package/tests/review-agent-end-preflight.test.ts +237 -21
- package/tests/review-candidate-owner-retry.test.ts +22 -1
- package/tests/review-candidate-view.test.ts +399 -12
- package/tests/review-contract-prompt.test.ts +138 -44
- package/tests/review-controller-lock-status.test.ts +0 -1
- package/tests/review-controller-native-recovery.test.ts +327 -17
- package/tests/review-controller-native-routing.test.ts +866 -6
- package/tests/review-controller-workspace-root.test.ts +206 -6
- 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 +342 -4
- package/tests/review-host-relay.test.ts +49 -2
- package/tests/review-integration-v2-forward.test.ts +221 -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 +14 -37
- 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-reminder-receipt.test.ts +47 -1
- package/tests/review-risk-assessment.test.ts +594 -26
- 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-sidebar-state.test.ts +402 -0
- package/tests/review-test-fixtures.ts +7 -1
- package/tests/review-transaction.test.ts +18 -1
- package/tests/run-test-suite.test.ts +202 -0
- package/tests/runtime-harness.mjs +186 -800
- package/tests/runtime-metrics-children.test.ts +16 -23
- package/tests/runtime-metrics-extension.test.ts +1 -1
- package/tests/runtime-metrics-native.test.ts +31 -1
- package/tests/selection-engine.test.ts +451 -0
- 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-messaging-grants.test.ts +255 -0
- package/tests/session-profile-binding.test.ts +163 -0
- package/tests/session-profile-freeze.test.ts +381 -0
- package/tests/session-subject-routing-contract.test.ts +34 -0
- package/tests/session-worktree-registry.test.ts +134 -2
- package/tests/shell-bar.test.ts +422 -1
- package/tests/shell-card.test.ts +353 -1
- package/tests/shell-changes-view.test.ts +135 -32
- package/tests/shell-changes.test.ts +61 -62
- package/tests/shell-prompt.test.ts +94 -2
- 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 +351 -22
- package/tests/shell-sidebar-scroll-benchmark.test.ts +255 -0
- package/tests/shell-todo.test.ts +207 -3
- package/tests/shell-usage-view.test.ts +28 -1
- package/tests/shell-usage.test.ts +108 -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 +357 -2
- package/tests/startup-banner.test.ts +246 -4
- package/tests/startup-home-repo.test.ts +170 -0
- package/tests/stats-collector.test.ts +195 -0
- package/tests/stats-view.test.ts +202 -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 +107 -20
- package/tests/test-strength-contract.test.ts +124 -0
- package/tests/theme-customization.test.ts +72 -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-host-resolution.test.ts +37 -0
- package/tests/vim-editor-adapter.test.ts +804 -0
- package/tests/vim-normal-engine.test.ts +101 -0
- package/tests/vim-operator-engine.test.ts +215 -0
- package/tests/vim-policy.test.ts +19 -0
- package/tests/vim-visual-engine.test.ts +52 -0
- package/tests/visual-customization-policy.test.ts +139 -0
- package/tests/visual-customize-view.test.ts +696 -0
- package/tests/visual-profiles.test.ts +87 -0
- package/tests/writer-edit-surface-scope.test.ts +59 -2
- package/tests/writer-surfaces.test.ts +199 -0
- package/tests/yolo-customize.test.ts +271 -0
- package/tests/yolo-mode-runtime.test.ts +188 -0
- package/tests/yolo-mode.test.ts +289 -0
- package/tests/yolo-session-policy.test.ts +59 -0
- package/themes/Gentle.json +2 -1
- package/themes/Gentleman-Cute.json +2 -1
- package/themes/Gentleman-Sexy.json +2 -1
- package/assets/agents/sdd-apply.md +0 -159
- package/assets/agents/sdd-archive.md +0 -228
- package/assets/agents/sdd-design.md +0 -49
- package/assets/agents/sdd-explore.md +0 -48
- package/assets/agents/sdd-init.md +0 -56
- package/assets/agents/sdd-onboard.md +0 -52
- package/assets/agents/sdd-proposal.md +0 -64
- package/assets/agents/sdd-remediate.md +0 -37
- package/assets/agents/sdd-research.md +0 -49
- package/assets/agents/sdd-spec.md +0 -192
- package/assets/agents/sdd-status.md +0 -54
- package/assets/agents/sdd-tasks.md +0 -108
- package/assets/agents/sdd-verify.md +0 -124
- package/assets/chains/sdd-full.chain.md +0 -83
- package/assets/chains/sdd-plan.chain.md +0 -56
- package/assets/chains/sdd-verify.chain.md +0 -43
- package/assets/sdd-orchestrator-workflow.md +0 -319
- package/assets/support/sdd-status-contract.md +0 -77
- package/docs/assets/diagrams/sdd-cycle.svg +0 -14
- package/extensions/sdd-init.ts +0 -816
- package/lib/openspec-deltas.ts +0 -156
- package/lib/sdd-preflight.ts +0 -1066
- package/lib/sdd-research-capabilities.ts +0 -94
- package/lib/sdd-status.ts +0 -26
- package/tests/fixtures/legacy/sdd-research-v2.5.0.md +0 -54
- package/tests/fixtures/native-review-cli/v2.1.3/bind-sdd.json +0 -25
- package/tests/fixtures/v0.10.7/assets/agents/sdd-apply.md +0 -132
- package/tests/openspec-deltas.test.ts +0 -209
- package/tests/sdd-agent-tools.test.ts +0 -156
- package/tests/sdd-archive-replay.test.ts +0 -82
- package/tests/sdd-classical-continuation.test.ts +0 -74
- package/tests/sdd-execution-routing-contract.test.ts +0 -44
- package/tests/sdd-managed-runtime-settlement.test.ts +0 -155
- package/tests/sdd-native-managed-uptake.test.ts +0 -243
- package/tests/sdd-no-attempts-contract.test.ts +0 -15
- package/tests/sdd-odd-integration.test.ts +0 -33
- package/tests/sdd-optional-research.test.ts +0 -124
- package/tests/sdd-planning-routing-contract.test.ts +0 -45
- package/tests/sdd-preflight-rpc-input.test.ts +0 -125
- package/tests/sdd-preflight.test.ts +0 -541
- package/tests/sdd-research-capabilities.test.ts +0 -114
- package/tests/sdd-research-live.test.ts +0 -241
- package/tests/sdd-selection-transport.test.ts +0 -653
- package/tests/sdd-status.test.ts +0 -9
- package/tests/sdd-task-truth.test.ts +0 -43
- /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
|
@@ -1,22 +1,113 @@
|
|
|
1
1
|
import { CustomEditor, keyHint, type ExtensionAPI, type ExtensionContext, type KeybindingsManager } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import
|
|
2
|
+
import { isHomeOrBroadUserDirectory } from "../lib/bounded-writer-admission.ts";
|
|
3
|
+
import { Editor, decodeKittyPrintable, isKeyRelease, matchesKey, parseKey, truncateToWidth, visibleWidth, type EditorTheme, type TUI } from "@earendil-works/pi-tui";
|
|
3
4
|
import { execFile, spawnSync } from "node:child_process";
|
|
4
|
-
import { statSync } from "node:fs";
|
|
5
|
-
import {
|
|
5
|
+
import { existsSync, realpathSync, statSync } from "node:fs";
|
|
6
|
+
import { lstat, readFile } from "node:fs/promises";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { profilesFilePath, profileRoleEntries, readProfilesFileResult } from "../lib/agent-profiles.ts";
|
|
9
|
+
import { readSessionProfileBinding } from "../lib/session-profile-binding.ts";
|
|
10
|
+
import { resolveProfilePin } from "../lib/agent-profile-pin.ts";
|
|
6
11
|
import * as os from "node:os";
|
|
7
|
-
import { join } from "node:path";
|
|
8
|
-
import { buildShellHeaderModel, renderShellBar,
|
|
9
|
-
import { CHANGE_STATUS,
|
|
12
|
+
import { dirname, join, resolve } from "node:path";
|
|
13
|
+
import { buildShellHeaderModel, renderShellBar, renderShellBelowInputFloat, renderShellBottomOnlyBar, renderShellHeaderChrome, renderShellSidebarBar, keepNativeWorkingRow, shellEnabled, shellHeaderUsageHit, shellJobsCount, type ShellBarModel, type ShellBarTheme } from "../lib/shell-bar.ts";
|
|
14
|
+
import { CHANGE_STATUS, DIFF_MAX_BYTES, DIFF_MAX_LINES, DIFF_TOO_LARGE, WorktreeChangesTracker, renderChangesWidget, type ChangedFile, type ChangesModel, type FileRevision, type GitRunner, type LineCounter, type WorktreeChanges } from "../lib/shell-changes.ts";
|
|
10
15
|
import { WorktreeChangesView } from "../lib/shell-changes-view.ts";
|
|
11
|
-
import { SessionWorktreeRegistry, resolveSessionWorktree, worktreeGitEnvironment, type WorktreeResolver } from "../lib/session-worktree-registry.ts";
|
|
12
|
-
import { CARD_TONE, renderCard, type Card, type CardTheme } from "../lib/shell-card.ts";
|
|
16
|
+
import { SessionWorktreeRegistry, resolveSessionWorktree, worktreeGitEnvironment, type WorktreeResolver, type WorktreeIdentity } from "../lib/session-worktree-registry.ts";
|
|
17
|
+
import { CARD_STYLE, CARD_TONE, cardStyle, renderCard, setCardStyle, type Card, type CardTheme } from "../lib/shell-card.ts";
|
|
18
|
+
import { resolveCardStyle, writeCardStyle } from "../lib/card-style-policy.ts";
|
|
19
|
+
import { CARD_CONTENT, resolveCardContent, setCardContent, writeCardContent } from "../lib/card-content-policy.ts";
|
|
13
20
|
import { CommandPalette, commandsKey, type CommandPaletteResult } from "../lib/command-palette.ts";
|
|
14
21
|
import { buildCommandPaletteGroups } from "../lib/command-palette-catalog.ts";
|
|
22
|
+
import { discoverYoloUiAdapter, YOLO_DISPLAY, type YoloDisplay, type YoloUiAdapter } from "../lib/yolo-session-policy.ts";
|
|
23
|
+
import { VisualCustomizeView, type CustomizeCategory, type CustomizeRow, type ProfileActions } from "../lib/visual-customize-view.ts";
|
|
24
|
+
import { deleteVisualProfile, getVisualProfile, listVisualProfiles, resetVisualProfiles, saveVisualProfile } from "../lib/visual-profiles.ts";
|
|
25
|
+
import { sourcePalettePreview } from "../lib/theme-customization.ts";
|
|
26
|
+
import { DEFAULT_VISUAL_SETTINGS, DENSITY, HEADER_PLACEMENT, STATUS_PLACEMENT, VISUAL_SECTION_KEYS, resolveVisualSettings, writeVisualSettings } from "../lib/visual-customization-policy.ts";
|
|
27
|
+
import { BANNER_COLORS, DEFAULT_BANNER_CONFIG, readBannerConfig, readBannerConfigForEdit, writeBannerConfig } from "./startup-banner.ts";
|
|
15
28
|
import { agentsViewKey } from "../lib/agents-keys.ts";
|
|
16
29
|
import { GentleAiDevBinaryOverrideError, resolveGentleAiDevBinaryOverride } from "../lib/gentle-ai-binary.ts";
|
|
17
|
-
import { DOUBLE_ESC_CANCEL_HINT, framePromptLines, IDLE_ESC_CLEAR_HINT, PROMPT_HINT, PROMPT_STATE, SHELL_PULSE_MS, withPromptHint, type PromptState } from "../lib/shell-prompt.ts";
|
|
30
|
+
import { DOUBLE_ESC_CANCEL_HINT, floatPromptRow, framePromptLines, resolvePromptLayout, IDLE_ESC_CLEAR_HINT, PROMPT_HINT, PROMPT_STATE, SHELL_PULSE_MS, withPromptHint, type PromptState } from "../lib/shell-prompt.ts";
|
|
31
|
+
import { oddPhaseRegistry } from "../lib/odd-phase.ts";
|
|
32
|
+
import { inferOddPhase } from "../lib/odd-phase-inference.ts";
|
|
33
|
+
import { isInteractiveMode } from "../lib/rpc-host.ts";
|
|
18
34
|
import { gentlePiConfigHome } from "../lib/agent-home.ts";
|
|
35
|
+
import { bundledGentlePiConfigHome } from "../lib/gentle-shell-bundled.ts";
|
|
36
|
+
import { isNanAdaptiveReasoningModel } from "../lib/nan-provider.ts";
|
|
19
37
|
import { resolveAnimationPolicy, writeAnimationPolicy, type AnimationPolicy } from "../lib/animation-policy.ts";
|
|
38
|
+
import { resolveVimPolicy, writeVimPolicy, type VimPolicy } from "../lib/vim-policy.ts";
|
|
39
|
+
import { resolveHistoryCapture, writeHistoryCapturePolicy } from "../lib/history-capture-policy.ts";
|
|
40
|
+
import { createRequire } from "node:module";
|
|
41
|
+
import { createVimEditorAdapter, isAuditedPiEditorVersion } from "../lib/vim-editor-adapter.ts";
|
|
42
|
+
import { VimNormalEngine } from "../lib/vim-normal-engine.ts";
|
|
43
|
+
import { VimOperatorEngine, type OperatorResult } from "../lib/vim-operator-engine.ts";
|
|
44
|
+
import { VimVisualEngine } from "../lib/vim-visual-engine.ts";
|
|
45
|
+
|
|
46
|
+
// Canonical entrypoint and index patterns across POSIX and Windows separators.
|
|
47
|
+
// Exported for cross-platform unit testing of candidate path resolution.
|
|
48
|
+
export const VIM_CLI_ENTRY_PATTERN = /(?:^|[\\/])dist[\\/]bundle[\\/]cli\.js$/;
|
|
49
|
+
export const VIM_AGENT_INDEX_PATTERN = /(?:^|[\\/])dist[\\/]index\.js$/;
|
|
50
|
+
|
|
51
|
+
// Candidate paths provide only package roots, never version authority. Both
|
|
52
|
+
// constructors must come from that same canonical agent/TUI pair before its
|
|
53
|
+
// metadata can admit private editing (jiti may alias imports to host modules).
|
|
54
|
+
export function resolveVimRuntime(entry = process.argv[1], customClass: typeof CustomEditor = CustomEditor):
|
|
55
|
+
{ version: string; editorClass: typeof Editor } | undefined {
|
|
56
|
+
const editorPrototype = Object.getPrototypeOf(customClass.prototype) as typeof Editor.prototype | undefined;
|
|
57
|
+
const editorClass = editorPrototype?.constructor as typeof Editor | undefined;
|
|
58
|
+
const candidates: string[] = [];
|
|
59
|
+
// Audited CLIs (AUDITED_PI_EDITOR_VERSIONS) use the bundled virtual module graph.
|
|
60
|
+
// Its public index exports the very CustomEditor class supplied to extensions by that graph.
|
|
61
|
+
try {
|
|
62
|
+
if (entry) {
|
|
63
|
+
const cli = realpathSync(entry);
|
|
64
|
+
if (VIM_CLI_ENTRY_PATTERN.test(cli)) {
|
|
65
|
+
const root = resolve(dirname(cli), "../..");
|
|
66
|
+
const bundlePath = resolve(root, "dist", "bundle", "index.js");
|
|
67
|
+
if (realpathSync(bundlePath) === bundlePath) {
|
|
68
|
+
const requireFromBundle = createRequire(bundlePath);
|
|
69
|
+
const bundled = requireFromBundle(bundlePath) as { CustomEditor?: typeof CustomEditor; VERSION?: string };
|
|
70
|
+
const metadata = requireFromBundle(resolve(root, "package.json")) as { name?: string; version?: string };
|
|
71
|
+
if (metadata.name === "@earendil-works/pi-coding-agent" && isAuditedPiEditorVersion(metadata.version) &&
|
|
72
|
+
bundled.VERSION === metadata.version && bundled.CustomEditor === customClass &&
|
|
73
|
+
typeof editorClass === "function" && editorClass.name === "Editor" &&
|
|
74
|
+
editorPrototype === editorClass.prototype &&
|
|
75
|
+
Object.getPrototypeOf(customClass) === editorClass) {
|
|
76
|
+
return { version: metadata.version, editorClass };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
} catch { /* Unknown bundled host: do not authorize private editing. */ }
|
|
82
|
+
try {
|
|
83
|
+
if (entry) {
|
|
84
|
+
const cli = realpathSync(entry);
|
|
85
|
+
if (VIM_CLI_ENTRY_PATTERN.test(cli)) candidates.push(resolve(dirname(cli), "../.."));
|
|
86
|
+
}
|
|
87
|
+
} catch { /* The CLI is only a candidate, not proof. */ }
|
|
88
|
+
try {
|
|
89
|
+
const localIndex = realpathSync(fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent")));
|
|
90
|
+
if (VIM_AGENT_INDEX_PATTERN.test(localIndex)) candidates.push(resolve(dirname(localIndex), ".."));
|
|
91
|
+
} catch { /* No local candidate; do not trust extension-relative metadata. */ }
|
|
92
|
+
for (const root of new Set(candidates)) {
|
|
93
|
+
try {
|
|
94
|
+
const agentIndex = realpathSync(resolve(root, "dist", "index.js"));
|
|
95
|
+
if (agentIndex !== resolve(root, "dist", "index.js")) continue;
|
|
96
|
+
const requireFromRuntime = createRequire(agentIndex);
|
|
97
|
+
const agent = requireFromRuntime(agentIndex) as { CustomEditor?: typeof CustomEditor };
|
|
98
|
+
const agentMetadata = requireFromRuntime(resolve(root, "package.json")) as { version?: string; name?: string };
|
|
99
|
+
const tuiPath = realpathSync(requireFromRuntime.resolve("@earendil-works/pi-tui"));
|
|
100
|
+
const tui = requireFromRuntime(tuiPath) as { Editor?: typeof Editor };
|
|
101
|
+
const tuiRoot = resolve(dirname(tuiPath), "..");
|
|
102
|
+
const tuiMetadata = requireFromRuntime(resolve(tuiRoot, "package.json")) as { version?: string; name?: string };
|
|
103
|
+
if (agent.CustomEditor === customClass && tui.Editor === editorClass &&
|
|
104
|
+
agentMetadata.name === "@earendil-works/pi-coding-agent" && tuiMetadata.name === "@earendil-works/pi-tui" &&
|
|
105
|
+
isAuditedPiEditorVersion(agentMetadata.version) &&
|
|
106
|
+
agentMetadata.version === tuiMetadata.version) return { version: tuiMetadata.version, editorClass };
|
|
107
|
+
} catch { /* Unknown package or constructor: ordinary editing stays active. */ }
|
|
108
|
+
}
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
20
111
|
import {
|
|
21
112
|
DOUBLE_ESC_CANCEL_WINDOW_MS,
|
|
22
113
|
resolveDoubleEscCancelPolicy,
|
|
@@ -24,12 +115,17 @@ import {
|
|
|
24
115
|
type DoubleEscCancelPolicy,
|
|
25
116
|
type DoubleEscCancelResolution,
|
|
26
117
|
} from "../lib/double-esc-cancel-policy.ts";
|
|
27
|
-
import { accountIdFromToken, CODEX_PROVIDER, CODEX_USAGE_URL, NAN_PROVIDER, NAN_QUOTA_URL, parseCodexUsage, parseNanQuota, parseProviderUsage, parseUsageHeaders, parseUsageSource, UsageSourceRegistry, UsageStore, USAGE_SOURCE_EVENT, type ProviderUsage, type UsageSource } from "../lib/shell-usage.ts";
|
|
118
|
+
import { accountIdFromToken, CODEX_PROVIDER, CODEX_USAGE_URL, NAN_PROVIDER, NAN_QUOTA_URL, parseCodexUsage, parseNanQuota, parseProviderUsage, parseUsageHeaders, parseUsageSource, usageScopeProviders, UsageSourceRegistry, UsageStore, USAGE_SOURCE_EVENT, type ProviderUsage, type UsageSource } from "../lib/shell-usage.ts";
|
|
28
119
|
import { UsageView } from "../lib/shell-usage-view.ts";
|
|
29
|
-
import { sidebarHeader, sidebarPart } from "../lib/shell-sidebar.ts";
|
|
30
|
-
import { installSidebar, invalidateSidebar } from "../lib/shell-sidebar-layout.ts";
|
|
31
|
-
import {
|
|
120
|
+
import { sidebarHeader, sidebarPart, sidebarState, VISUAL_SETTINGS_CHANGED, type SidebarRail } from "../lib/shell-sidebar.ts";
|
|
121
|
+
import { installSidebar, invalidateSidebar, narrowStatusOwner, SIDEBAR_BREAKPOINT, STATUS_OWNER } from "../lib/shell-sidebar-layout.ts";
|
|
122
|
+
import { sessionChangeRoots } from "../lib/session-changes.ts";
|
|
123
|
+
import { REVIEW_SIDEBAR_EVENT, isReviewSidebarSnapshot, type ReviewSidebarSnapshot } from "../lib/review-sidebar-state.ts";
|
|
124
|
+
import { JOBS_SIDEBAR_EVENT, JOBS_STATUS_KEY, isJobsSidebarSnapshot, type RunningJobDisplay } from "../lib/jobs-sidebar-state.ts";
|
|
32
125
|
import { installSessionChangeCapture } from "../lib/session-change-capture.ts";
|
|
126
|
+
import { SelectionEngine } from "../lib/selection-engine.ts";
|
|
127
|
+
import { withOverlayRepaint } from "../lib/overlay-repaint.ts";
|
|
128
|
+
import { buildNotificationRows } from "../lib/notification-customize.ts";
|
|
33
129
|
|
|
34
130
|
// Gentle Shell: the visual layer gentle-pi puts on top of pi. It installs the
|
|
35
131
|
// status bar, the petal prompt, the working-tree changes widget and overlay,
|
|
@@ -54,6 +150,7 @@ interface ShellBarComponent {
|
|
|
54
150
|
}
|
|
55
151
|
|
|
56
152
|
interface BuildOptions {
|
|
153
|
+
jobs?: RunningJobDisplay[];
|
|
57
154
|
profile?: string;
|
|
58
155
|
home?: string;
|
|
59
156
|
dirty?: number;
|
|
@@ -68,17 +165,31 @@ export interface ShellDeps {
|
|
|
68
165
|
now(): number;
|
|
69
166
|
devBinary(): DevBinaryNotice | undefined;
|
|
70
167
|
resolveWorktree: WorktreeResolver;
|
|
71
|
-
|
|
168
|
+
/** maxBuffer bounds one diff; status and discovery stay unbounded. */
|
|
169
|
+
gitRunner(cwd: string, maxBuffer?: number): GitRunner;
|
|
170
|
+
/** A root Changes may scan: removed worktrees and leftover directories are skipped. */
|
|
171
|
+
rootExists(root: string): boolean;
|
|
172
|
+
vimRuntimeVersion?(): string | undefined;
|
|
72
173
|
}
|
|
73
174
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
175
|
+
export type ActiveProfileReader = (() => string | undefined) & {
|
|
176
|
+
bind(cwd: string, resolveWorktree: WorktreeResolver, sessionId?: string): boolean;
|
|
177
|
+
refresh(): boolean;
|
|
178
|
+
reset(): void;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
// Unbound reads retain the global file-identity cache, including atomic replacements.
|
|
182
|
+
// Bound reads are snapshots: no filesystem or Git work is done during a frame.
|
|
183
|
+
export function createActiveProfileReader(env: NodeJS.ProcessEnv = process.env): ActiveProfileReader {
|
|
184
|
+
const path = profilesFilePath(bundledGentlePiConfigHome(env) ?? env.GENTLE_PI_CONFIG_HOME ?? join(os.homedir(), ".pi", "gentle-ai"));
|
|
79
185
|
let fingerprint: string | undefined;
|
|
80
186
|
let name: string | undefined;
|
|
81
|
-
|
|
187
|
+
let bound = false;
|
|
188
|
+
let cwd: string | undefined;
|
|
189
|
+
let identity: WorktreeIdentity | undefined;
|
|
190
|
+
let sessionId: string | undefined;
|
|
191
|
+
let effective: string | undefined;
|
|
192
|
+
const global = () => {
|
|
82
193
|
try {
|
|
83
194
|
const stat = statSync(path, { bigint: true });
|
|
84
195
|
const next = `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
|
|
@@ -94,6 +205,40 @@ export function createActiveProfileReader(env: NodeJS.ProcessEnv = process.env):
|
|
|
94
205
|
return undefined;
|
|
95
206
|
}
|
|
96
207
|
};
|
|
208
|
+
const reader = (() => bound ? effective : global()) as ActiveProfileReader;
|
|
209
|
+
reader.refresh = () => {
|
|
210
|
+
if (!bound) return false;
|
|
211
|
+
// gentle-shell#1064 slice 1: a parent-session profile binding outranks
|
|
212
|
+
// both the pin layers and the global active profile, with the same
|
|
213
|
+
// "name (scope)" spelling the pin uses. The shared precedence rule lives
|
|
214
|
+
// in one place: session → p (local pin) → P (repo declaration) → global.
|
|
215
|
+
const session = sessionId === undefined ? undefined : readSessionProfileBinding(sessionId);
|
|
216
|
+
if (session !== undefined) {
|
|
217
|
+
const next = `${session.name} (session)`;
|
|
218
|
+
const changed = next !== effective;
|
|
219
|
+
effective = next;
|
|
220
|
+
return changed;
|
|
221
|
+
}
|
|
222
|
+
const pin = identity && cwd ? resolveProfilePin({
|
|
223
|
+
cwd,
|
|
224
|
+
configHome: bundledGentlePiConfigHome(env) ?? env.GENTLE_PI_CONFIG_HOME ?? join(os.homedir(), ".pi", "gentle-ai"),
|
|
225
|
+
resolveWorktree: () => identity!,
|
|
226
|
+
}) : undefined;
|
|
227
|
+
const next = pin ? `${pin.profile} (${pin.source})` : global();
|
|
228
|
+
const changed = next !== effective;
|
|
229
|
+
effective = next;
|
|
230
|
+
return changed;
|
|
231
|
+
};
|
|
232
|
+
reader.bind = (nextCwd, resolveWorktree, nextSessionId) => {
|
|
233
|
+
reader.reset();
|
|
234
|
+
cwd = nextCwd;
|
|
235
|
+
sessionId = nextSessionId;
|
|
236
|
+
try { identity = resolveWorktree(nextCwd, nextCwd); } catch { identity = undefined; }
|
|
237
|
+
bound = true;
|
|
238
|
+
return reader.refresh();
|
|
239
|
+
};
|
|
240
|
+
reader.reset = () => { bound = false; cwd = undefined; identity = undefined; sessionId = undefined; effective = undefined; };
|
|
241
|
+
return reader;
|
|
97
242
|
}
|
|
98
243
|
|
|
99
244
|
function ambientDevBinary(): DevBinaryNotice | undefined {
|
|
@@ -106,7 +251,7 @@ function ambientDevBinary(): DevBinaryNotice | undefined {
|
|
|
106
251
|
}
|
|
107
252
|
}
|
|
108
253
|
|
|
109
|
-
const defaultShellDeps: Omit<ShellDeps, "activeProfile"> = { fetch: (
|
|
254
|
+
const defaultShellDeps: Omit<ShellDeps, "activeProfile"> = { fetch: (input, init) => globalThis.fetch(input, init), now: () => Date.now(), devBinary: ambientDevBinary, resolveWorktree: resolveSessionWorktree, gitRunner: (cwd, maxBuffer) => shellGitRunner(cwd, process.env, execFile, maxBuffer), rootExists: (root) => existsSync(join(root, ".git")) };
|
|
110
255
|
|
|
111
256
|
interface AssistantUsageEntry {
|
|
112
257
|
type: string;
|
|
@@ -132,6 +277,37 @@ function sessionCost(ctx: ExtensionContext): number {
|
|
|
132
277
|
return total;
|
|
133
278
|
}
|
|
134
279
|
|
|
280
|
+
type ShellSessionStats = { costTotal: number; usage: ReturnType<ExtensionContext["getContextUsage"]> };
|
|
281
|
+
const shellSessionStats = new WeakMap<object, {
|
|
282
|
+
sessionId: string | undefined;
|
|
283
|
+
leafId: string | null;
|
|
284
|
+
entryCount: number | undefined;
|
|
285
|
+
model: ExtensionContext["model"];
|
|
286
|
+
contextWindow: number | undefined;
|
|
287
|
+
stats: ShellSessionStats;
|
|
288
|
+
}>();
|
|
289
|
+
|
|
290
|
+
function sessionStats(ctx: ExtensionContext): ShellSessionStats {
|
|
291
|
+
const manager = ctx.sessionManager as ExtensionContext["sessionManager"] & { getEntryCount?(): number };
|
|
292
|
+
const compute = (): ShellSessionStats => ({ costTotal: sessionCost(ctx), usage: ctx.getContextUsage() });
|
|
293
|
+
// Append-only session entries move the leaf, including compaction and branch
|
|
294
|
+
// switches. A virtual model's routed physical model comes from the latest
|
|
295
|
+
// response, which is appended as an entry, so the leaf covers it as well.
|
|
296
|
+
// Older/test hosts without that identity must stay uncached.
|
|
297
|
+
if (typeof manager.getLeafId !== "function") return compute();
|
|
298
|
+
const sessionId = manager.getSessionId();
|
|
299
|
+
const leafId = manager.getLeafId();
|
|
300
|
+
const entryCount = manager.getEntryCount?.();
|
|
301
|
+
const model = ctx.model;
|
|
302
|
+
const contextWindow = model?.contextWindow;
|
|
303
|
+
const cached = shellSessionStats.get(manager);
|
|
304
|
+
if (cached && cached.sessionId === sessionId && cached.leafId === leafId &&
|
|
305
|
+
cached.entryCount === entryCount && cached.model === model && cached.contextWindow === contextWindow) return cached.stats;
|
|
306
|
+
const stats = compute();
|
|
307
|
+
shellSessionStats.set(manager, { sessionId, leafId, entryCount, model, contextWindow, stats });
|
|
308
|
+
return stats;
|
|
309
|
+
}
|
|
310
|
+
|
|
135
311
|
export function buildShellBarModel(
|
|
136
312
|
pi: ExtensionAPI,
|
|
137
313
|
ctx: ExtensionContext,
|
|
@@ -139,9 +315,11 @@ export function buildShellBarModel(
|
|
|
139
315
|
options: BuildOptions = {},
|
|
140
316
|
): ShellBarModel {
|
|
141
317
|
const home = options.home ?? os.homedir();
|
|
142
|
-
const usage = ctx
|
|
318
|
+
const { usage, costTotal } = sessionStats(ctx);
|
|
143
319
|
const model = ctx.model;
|
|
320
|
+
const adaptiveReasoning = isNanAdaptiveReasoningModel(model);
|
|
144
321
|
const statuses = Array.from(footerData.getExtensionStatuses().entries())
|
|
322
|
+
.filter(([key]) => options.jobs === undefined || key !== JOBS_STATUS_KEY)
|
|
145
323
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
146
324
|
.map(([, text]) => text);
|
|
147
325
|
return {
|
|
@@ -151,12 +329,13 @@ export function buildShellBarModel(
|
|
|
151
329
|
dirty: options.dirty,
|
|
152
330
|
sessionName: ctx.sessionManager.getSessionName(),
|
|
153
331
|
modelId: model?.id ?? "no-model",
|
|
154
|
-
effort: model?.reasoning ? pi.getThinkingLevel() : undefined,
|
|
332
|
+
effort: model?.reasoning ? (adaptiveReasoning ? "auto" : pi.getThinkingLevel()) : undefined,
|
|
155
333
|
contextPercent: usage?.percent ?? null,
|
|
156
334
|
contextWindow: usage?.contextWindow ?? model?.contextWindow ?? 0,
|
|
157
|
-
costTotal
|
|
335
|
+
costTotal,
|
|
158
336
|
subscription: model ? ctx.modelRegistry.isUsingOAuth(model) : false,
|
|
159
337
|
usage: options.usage,
|
|
338
|
+
...(options.jobs !== undefined ? { jobs: options.jobs } : {}),
|
|
160
339
|
statuses,
|
|
161
340
|
};
|
|
162
341
|
}
|
|
@@ -169,6 +348,7 @@ export function createShellBarComponent(
|
|
|
169
348
|
footerData: ShellFooterData,
|
|
170
349
|
dirty: () => number | undefined = () => undefined,
|
|
171
350
|
usage: () => ProviderUsage | undefined = () => undefined,
|
|
351
|
+
presentation?: () => ReturnType<typeof resolveVisualSettings>["settings"],
|
|
172
352
|
): ShellBarComponent {
|
|
173
353
|
const unsubscribe = footerData.onBranchChange(() => {
|
|
174
354
|
host.invalidateSidebar?.();
|
|
@@ -176,7 +356,7 @@ export function createShellBarComponent(
|
|
|
176
356
|
});
|
|
177
357
|
return {
|
|
178
358
|
render(width: number) {
|
|
179
|
-
return renderShellBar(buildShellBarModel(pi, ctx, footerData, { dirty: dirty(), usage: usage() }), theme, width);
|
|
359
|
+
return renderShellBar(buildShellBarModel(pi, ctx, footerData, { dirty: dirty(), usage: usage() }), theme, width, presentation?.());
|
|
180
360
|
},
|
|
181
361
|
invalidate() {},
|
|
182
362
|
dispose() {
|
|
@@ -187,6 +367,7 @@ export function createShellBarComponent(
|
|
|
187
367
|
|
|
188
368
|
interface PromptEditorDeps {
|
|
189
369
|
fg: (color: string, text: string) => string;
|
|
370
|
+
bg?: (color: string, text: string) => string;
|
|
190
371
|
bold: (text: string) => string;
|
|
191
372
|
requestRender(): void;
|
|
192
373
|
pending(): boolean;
|
|
@@ -195,12 +376,26 @@ interface PromptEditorDeps {
|
|
|
195
376
|
doubleEscCancelEnabled(): boolean;
|
|
196
377
|
/** Hand off text reconstructed from Pi's Esc-abort restore so it is sent as the next turn instead of sitting in the editor. */
|
|
197
378
|
dispatchQueuedText(text: string): void;
|
|
379
|
+
/** Override only for isolated adapter compatibility tests; production reads installed metadata. */
|
|
380
|
+
tuiVersion?: () => string | undefined;
|
|
381
|
+
/** Report a single warning if this editor cannot safely provide modal editing. */
|
|
382
|
+
notifyCompatibility?: (message: string) => void;
|
|
383
|
+
/**
|
|
384
|
+
* Read fresh on every render while WORKING: the explicit, orchestrator-reported
|
|
385
|
+
* ODD phase label for the current session, or undefined to fall back to the
|
|
386
|
+
* generic "working…" label. Never applied to IDLE or QUEUED.
|
|
387
|
+
*/
|
|
388
|
+
workingLabel?: () => string | undefined;
|
|
198
389
|
}
|
|
199
390
|
|
|
200
391
|
const PROMPT_FRAME_ROLE = "border";
|
|
201
392
|
// Matches Pi's own idle double-Esc window (empty editor -> /tree or /fork);
|
|
202
393
|
// this is the same muscle memory applied to clearing a non-empty draft.
|
|
203
394
|
const IDLE_ESC_CLEAR_WINDOW_MS = 500;
|
|
395
|
+
const BRACKETED_PASTE_START = "\x1b[200~";
|
|
396
|
+
const BRACKETED_PASTE_END = "\x1b[201~";
|
|
397
|
+
// Bound our framing buffer even when a terminal never sends the closing marker.
|
|
398
|
+
const MAX_BRACKETED_PASTE_CHARS = 1024 * 1024;
|
|
204
399
|
|
|
205
400
|
/**
|
|
206
401
|
* Pi's own Esc-abort handler (`restoreQueuedMessagesToEditor({ abort: true
|
|
@@ -231,8 +426,28 @@ export class GentlePromptEditor extends CustomEditor {
|
|
|
231
426
|
private promptState: PromptState = PROMPT_STATE.IDLE;
|
|
232
427
|
private tick = 0;
|
|
233
428
|
private animationPolicy: AnimationPolicy = "quality";
|
|
429
|
+
private vimPolicy: VimPolicy = "off";
|
|
430
|
+
private vimRejected = false;
|
|
431
|
+
private vimNormal = false;
|
|
432
|
+
private compatibilityNotified = false;
|
|
433
|
+
private readonly vimEngine = new VimNormalEngine();
|
|
434
|
+
private readonly vimOperator = new VimOperatorEngine();
|
|
435
|
+
private vimInsertIntent?: { operator?: OperatorResult; command?: string };
|
|
436
|
+
private readonly vimVisual = new VimVisualEngine();
|
|
437
|
+
private pasteFrame: string | undefined;
|
|
438
|
+
private pasteOverflow = false;
|
|
439
|
+
private pasteTail = "";
|
|
440
|
+
private visualReplacePending = false;
|
|
441
|
+
private visualObjectPending: "i" | "a" | undefined;
|
|
442
|
+
private visualAnchor: { line: number; col: number } | undefined;
|
|
234
443
|
private pulse: NodeJS.Timeout | undefined;
|
|
235
444
|
private readonly deps: PromptEditorDeps;
|
|
445
|
+
private floatMouseLayout?: ReturnType<typeof resolvePromptLayout>;
|
|
446
|
+
// Native selection engine (shift+home/end, alt+a, replace-on-key): ported
|
|
447
|
+
// from pi-select-del so the petal prompt owns the feature without factory
|
|
448
|
+
// composition. Constructed with `this`; the internals probe degrades to
|
|
449
|
+
// passthrough on pi drift, costing only the selection features.
|
|
450
|
+
private readonly selectionEngine: SelectionEngine;
|
|
236
451
|
// CustomEditor keeps its own `keybindings` private, so this class holds
|
|
237
452
|
// its own reference to run the same app.interrupt match before deciding
|
|
238
453
|
// whether to swallow the keystroke.
|
|
@@ -246,6 +461,7 @@ export class GentlePromptEditor extends CustomEditor {
|
|
|
246
461
|
|
|
247
462
|
constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager, deps: PromptEditorDeps) {
|
|
248
463
|
super(tui, theme, keybindings);
|
|
464
|
+
this.selectionEngine = new SelectionEngine(this);
|
|
249
465
|
this.deps = deps;
|
|
250
466
|
this.keybindingsManager = keybindings;
|
|
251
467
|
}
|
|
@@ -258,6 +474,87 @@ export class GentlePromptEditor extends CustomEditor {
|
|
|
258
474
|
this.deps.requestRender();
|
|
259
475
|
}
|
|
260
476
|
|
|
477
|
+
get effectiveVimPolicy(): VimPolicy { return this.vimPolicy; }
|
|
478
|
+
|
|
479
|
+
setVimPolicy(policy: VimPolicy): void {
|
|
480
|
+
this.vimRejected = false;
|
|
481
|
+
if (policy === "on") {
|
|
482
|
+
try { this.vimAdapter(); }
|
|
483
|
+
catch {
|
|
484
|
+
this.vimRejected = true;
|
|
485
|
+
if (!this.compatibilityNotified) {
|
|
486
|
+
this.compatibilityNotified = true;
|
|
487
|
+
this.deps.notifyCompatibility?.("Gentle Vim unsupported by this Pi editor layout/version; ordinary editing remains active.");
|
|
488
|
+
}
|
|
489
|
+
policy = "off";
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
if (this.vimPolicy === policy) return;
|
|
493
|
+
this.pasteFrame = undefined;
|
|
494
|
+
this.pasteOverflow = false;
|
|
495
|
+
this.pasteTail = "";
|
|
496
|
+
if (this.vimPolicy === "on" && !this.disablingUnsupportedVim) {
|
|
497
|
+
try { this.vimAdapter().endInsertSession(); } catch { /* Unsupported editor cannot group inserts. */ }
|
|
498
|
+
}
|
|
499
|
+
this.vimInsertIntent = undefined;
|
|
500
|
+
this.vimPolicy = policy;
|
|
501
|
+
this.vimNormal = false;
|
|
502
|
+
this.vimOperator.forgetRepeat();
|
|
503
|
+
this.vimOperator.cancel();
|
|
504
|
+
this.visualAnchor = undefined;
|
|
505
|
+
this.vimVisual.cancel();
|
|
506
|
+
this.visualReplacePending = false;
|
|
507
|
+
this.visualObjectPending = undefined;
|
|
508
|
+
this.deps.requestRender();
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
private notifyUnsafeVisual(stage: "range" | "readRange" | "beginInsertSession" | "replace" | "move" | "operation", error?: unknown): void {
|
|
512
|
+
// Only fixed reason codes reach UI; never include a draft, path, or raw host error.
|
|
513
|
+
const message = error instanceof Error ? error.message : "";
|
|
514
|
+
const reason = /duplicate registered paste marker/i.test(message) ? "duplicate-marker" :
|
|
515
|
+
/paste marker/i.test(message) ? "paste-marker" :
|
|
516
|
+
/unsupported pi editor layout/i.test(message) ? "unsupported-layout" :
|
|
517
|
+
/valid grapheme or paste boundary|invalid cursor boundary/i.test(message) ? "invalid-boundary" :
|
|
518
|
+
/invalid selection range|invalid operator range/i.test(message) ? "invalid-range" :
|
|
519
|
+
/insert session already active/i.test(message) ? "session-active" :
|
|
520
|
+
error === undefined ? "no-range" : "unexpected";
|
|
521
|
+
this.deps.notifyCompatibility?.((reason === "paste-marker"
|
|
522
|
+
? "Vim selection contains a registered paste marker; edit it in INSERT mode instead."
|
|
523
|
+
: "Vim selection cannot be edited safely; try a different selection.") + ` [${stage}:${reason}]`);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
private disablingUnsupportedVim = false;
|
|
527
|
+
private vimAdapter(): ReturnType<typeof createVimEditorAdapter> {
|
|
528
|
+
const runtime = resolveVimRuntime();
|
|
529
|
+
try {
|
|
530
|
+
return createVimEditorAdapter(this, this.deps.tuiVersion?.() ?? runtime?.version ?? "unknown", runtime?.editorClass ?? Editor, runtime?.version);
|
|
531
|
+
} catch (error) {
|
|
532
|
+
if (this.vimPolicy === "on" && !this.disablingUnsupportedVim) {
|
|
533
|
+
this.disablingUnsupportedVim = true;
|
|
534
|
+
try { this.setVimPolicy("off"); this.vimRejected = true; } finally { this.disablingUnsupportedVim = false; }
|
|
535
|
+
if (!this.compatibilityNotified) {
|
|
536
|
+
this.compatibilityNotified = true;
|
|
537
|
+
this.deps.notifyCompatibility?.("Gentle Vim unsupported by this Pi editor layout/version; ordinary editing remains active.");
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
throw error;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
private enterVimInsert(resetEngine = true, cancelOperator = true, beginSession = true): void {
|
|
545
|
+
if (this.vimNormal && beginSession) {
|
|
546
|
+
try { this.vimAdapter().beginInsertSession(); } catch { /* Unknown layout: normal edits remain disabled. */ }
|
|
547
|
+
}
|
|
548
|
+
this.vimNormal = false;
|
|
549
|
+
if (cancelOperator) this.vimOperator.cancel();
|
|
550
|
+
if (resetEngine) this.vimEngine.reset();
|
|
551
|
+
this.visualAnchor = undefined;
|
|
552
|
+
this.vimVisual.cancel();
|
|
553
|
+
this.visualReplacePending = false;
|
|
554
|
+
this.visualObjectPending = undefined;
|
|
555
|
+
this.deps.requestRender();
|
|
556
|
+
}
|
|
557
|
+
|
|
261
558
|
private startPulse(): void {
|
|
262
559
|
if (this.animationPolicy === "potato") return;
|
|
263
560
|
this.pulse = setInterval(() => {
|
|
@@ -294,12 +591,337 @@ export class GentlePromptEditor extends CustomEditor {
|
|
|
294
591
|
* and never reach this branch.
|
|
295
592
|
*/
|
|
296
593
|
override handleInput(data: string): void {
|
|
297
|
-
//
|
|
298
|
-
//
|
|
594
|
+
// Selection and Vim handlers can consume input before the native chain;
|
|
595
|
+
// any intervening key invalidates an idle Esc confirmation.
|
|
299
596
|
if (this.pendingIdleClearDeadline !== undefined && !this.keybindingsManager.matches(data, "app.interrupt")) {
|
|
300
597
|
this.pendingIdleClearDeadline = undefined;
|
|
301
598
|
this.pendingIdleClearText = undefined;
|
|
302
599
|
}
|
|
600
|
+
// Vim owns its modal keys and paste frames. Ordinary editing retains
|
|
601
|
+
// native selection before the prompt's existing input chain, but a
|
|
602
|
+
// registered extension shortcut still owns its chord (gentle-shell#1565).
|
|
603
|
+
if (this.vimPolicy === "on") this.handleInputNative(data);
|
|
604
|
+
else this.selectionEngine.handleInput(data, (d) => this.handleInputNative(d), (d) => this.onExtensionShortcut?.(d) === true);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
private handleInputNative(data: string): void {
|
|
608
|
+
if ((this.vimPolicy === "on" || this.vimRejected) && this.handleVimPasteFrame(data)) return;
|
|
609
|
+
if (this.vimPolicy === "on" && isKeyRelease(data)) return;
|
|
610
|
+
if (this.vimPolicy === "on" && this.vimNormal && matchesKey(data, "escape") && this.vimVisual.active) {
|
|
611
|
+
if (this.isShowingAutocomplete()) {
|
|
612
|
+
try { this.vimAdapter().dismissAutocomplete(); } catch { return; }
|
|
613
|
+
}
|
|
614
|
+
this.vimVisual.cancel(); this.visualAnchor = undefined;
|
|
615
|
+
this.visualReplacePending = false; this.visualObjectPending = undefined;
|
|
616
|
+
this.vimOperator.cancel(); this.vimEngine.cancelPendingFind();
|
|
617
|
+
this.deps.requestRender();
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
if (this.vimPolicy === "on" && this.vimNormal && matchesKey(data, "escape") &&
|
|
621
|
+
(this.vimOperator.cancel() || this.vimEngine.cancelPendingFind())) {
|
|
622
|
+
this.deps.requestRender();
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
// Pi owns autocomplete and shell input except while a VISUAL command
|
|
626
|
+
// owns the key. Defer that handoff until after shortcut priority.
|
|
627
|
+
const bashMode = this.getText().trimStart().startsWith("!");
|
|
628
|
+
if (this.vimPolicy === "on" && this.vimNormal && ((this.isShowingAutocomplete() && !this.vimVisual.active) ||
|
|
629
|
+
(bashMode && !this.keybindingsManager.matches(data, "app.interrupt") && !matchesKey(data, "escape")))) {
|
|
630
|
+
this.enterVimInsert();
|
|
631
|
+
}
|
|
632
|
+
if (this.vimPolicy === "on" && !this.vimNormal && !this.isShowingAutocomplete() &&
|
|
633
|
+
(this.keybindingsManager.matches(data, "app.interrupt") || matchesKey(data, "escape"))) {
|
|
634
|
+
try {
|
|
635
|
+
const inserted = this.vimAdapter().endInsertSession();
|
|
636
|
+
if (matchesKey(data, "escape") && inserted && this.vimInsertIntent?.operator)
|
|
637
|
+
this.vimOperator.rememberInsert(this.vimInsertIntent.operator, inserted);
|
|
638
|
+
else if (matchesKey(data, "escape") && inserted && this.vimInsertIntent?.command)
|
|
639
|
+
this.vimOperator.rememberLiteralInsert(this.vimInsertIntent.command, inserted);
|
|
640
|
+
} catch { /* Unknown layout: no grouping or repeat. */ }
|
|
641
|
+
this.vimInsertIntent = undefined;
|
|
642
|
+
this.vimNormal = true;
|
|
643
|
+
this.visualAnchor = undefined;
|
|
644
|
+
this.vimVisual.cancel();
|
|
645
|
+
this.pendingEscapeCancelDeadline = undefined;
|
|
646
|
+
this.pendingIdleClearDeadline = undefined;
|
|
647
|
+
this.pendingIdleClearText = undefined;
|
|
648
|
+
this.deps.requestRender();
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
if (this.vimPolicy === "on" && (!this.isShowingAutocomplete() || this.vimVisual.active) && !bashMode) {
|
|
652
|
+
// CustomEditor gives extension, clipboard and app shortcuts priority
|
|
653
|
+
// over editor input. Preserve that order even when a shortcut shares
|
|
654
|
+
// a modal command key, and transfer ownership before its callback.
|
|
655
|
+
if (this.vimNormal && !this.keybindingsManager.matches(data, "app.interrupt") && !matchesKey(data, "escape")) {
|
|
656
|
+
if (this.onExtensionShortcut) {
|
|
657
|
+
const draft = this.getText();
|
|
658
|
+
const anchor = this.visualAnchor;
|
|
659
|
+
const visualMode = this.vimVisual.kind;
|
|
660
|
+
const replacePending = this.visualReplacePending;
|
|
661
|
+
const objectPending = this.visualObjectPending;
|
|
662
|
+
const restoreMode = () => {
|
|
663
|
+
this.vimNormal = true;
|
|
664
|
+
this.visualAnchor = anchor;
|
|
665
|
+
this.visualReplacePending = replacePending;
|
|
666
|
+
this.visualObjectPending = objectPending;
|
|
667
|
+
if (anchor) this.vimVisual.start(visualMode, anchor);
|
|
668
|
+
this.deps.requestRender();
|
|
669
|
+
};
|
|
670
|
+
this.enterVimInsert(false, false);
|
|
671
|
+
let consumed: boolean;
|
|
672
|
+
try { consumed = this.onExtensionShortcut(data); }
|
|
673
|
+
catch (error) {
|
|
674
|
+
if (this.getText() === draft) {
|
|
675
|
+
try { this.vimAdapter().endInsertSession(); } catch { /* Preserve the callback failure. */ }
|
|
676
|
+
restoreMode();
|
|
677
|
+
} else {
|
|
678
|
+
this.vimOperator.cancel();
|
|
679
|
+
this.vimEngine.reset();
|
|
680
|
+
}
|
|
681
|
+
throw error;
|
|
682
|
+
}
|
|
683
|
+
if (consumed || this.getText() !== draft) {
|
|
684
|
+
this.vimOperator.cancel();
|
|
685
|
+
this.vimEngine.reset();
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
// A non-owning shortcut probe temporarily entered INSERT; close
|
|
689
|
+
// its session before restoring NORMAL and dispatching this key.
|
|
690
|
+
try { this.vimAdapter().endInsertSession(); } catch { return; }
|
|
691
|
+
restoreMode();
|
|
692
|
+
}
|
|
693
|
+
if (this.keybindingsManager.matches(data, "app.clipboard.pasteImage")) {
|
|
694
|
+
this.enterVimInsert();
|
|
695
|
+
this.onPasteImage?.();
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
if (this.keybindingsManager.matches(data, "tui.editor.historyPrevious") ||
|
|
699
|
+
this.keybindingsManager.matches(data, "tui.editor.historyNext")) {
|
|
700
|
+
this.enterVimInsert();
|
|
701
|
+
super.handleInput(data);
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
for (const [action, handler] of this.actionHandlers) {
|
|
705
|
+
if (action !== "app.interrupt" && this.keybindingsManager.matches(data, action)) {
|
|
706
|
+
if (action === "app.exit" && this.getText().length !== 0) continue;
|
|
707
|
+
this.enterVimInsert();
|
|
708
|
+
handler();
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
const parsed = parseKey(data);
|
|
714
|
+
const singleGrapheme = (value: string): boolean =>
|
|
715
|
+
[...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(value)].length === 1;
|
|
716
|
+
const printable = decodeKittyPrintable(data) ??
|
|
717
|
+
(parsed && singleGrapheme(parsed) ? parsed : undefined) ??
|
|
718
|
+
(singleGrapheme(data) ? data : undefined);
|
|
719
|
+
// Only unmodified presses become modal commands; other shortcuts
|
|
720
|
+
// remain available to Pi's application keybindings.
|
|
721
|
+
const command = printable && (matchesKey(data, printable as Parameters<typeof matchesKey>[1]) || data === printable)
|
|
722
|
+
? printable : matchesKey(data, "space") ? "Space" : undefined;
|
|
723
|
+
// A stale Pi menu must never take a VISUAL motion, count or
|
|
724
|
+
// operator. Dismiss it only after app/extension shortcut priority;
|
|
725
|
+
// outside VISUAL Pi retains its ordinary completion ownership.
|
|
726
|
+
if (this.vimNormal && this.vimVisual.active && this.isShowingAutocomplete()) {
|
|
727
|
+
try { this.vimAdapter().dismissAutocomplete(); } catch { return; }
|
|
728
|
+
}
|
|
729
|
+
if (this.vimNormal && !this.vimVisual.active && command === "/") {
|
|
730
|
+
// Pi 0.85.1 opens slash completion only at the start of the
|
|
731
|
+
// first line. Let its editor insert the slash and decide whether
|
|
732
|
+
// to open the menu; never relocate or replace an existing draft.
|
|
733
|
+
this.enterVimInsert();
|
|
734
|
+
super.handleInput(data);
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
if (this.vimNormal && command) {
|
|
738
|
+
if (this.visualObjectPending && this.vimVisual.active) {
|
|
739
|
+
const around = this.visualObjectPending;
|
|
740
|
+
this.visualObjectPending = undefined;
|
|
741
|
+
try {
|
|
742
|
+
const adapter = this.vimAdapter();
|
|
743
|
+
const stops = adapter.motionBoundaries();
|
|
744
|
+
const range = this.vimVisual.object(this.getText(), this.getCursor(), stops, around, command);
|
|
745
|
+
if (range) {
|
|
746
|
+
adapter.readRange(range.start, range.end);
|
|
747
|
+
const endStops = stops[range.end.line]!;
|
|
748
|
+
const endIndex = endStops.indexOf(range.end.col);
|
|
749
|
+
const last = endIndex > 0 ? { line: range.end.line, col: endStops[endIndex - 1]! } :
|
|
750
|
+
range.end.line > 0 ? { line: range.end.line - 1, col: stops[range.end.line - 1]!.at(-1)! } : undefined;
|
|
751
|
+
if (last) {
|
|
752
|
+
this.visualAnchor = range.start;
|
|
753
|
+
this.vimVisual.start("char", range.start);
|
|
754
|
+
adapter.move(last);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
} catch { /* Registered paste markers and unknown layouts leave the visual range intact. */ }
|
|
758
|
+
this.deps.requestRender(); return;
|
|
759
|
+
}
|
|
760
|
+
if (this.visualReplacePending && this.vimVisual.active) {
|
|
761
|
+
this.visualReplacePending = false;
|
|
762
|
+
if (singleGrapheme(command) && !command.includes("\n")) {
|
|
763
|
+
try {
|
|
764
|
+
const adapter = this.vimAdapter();
|
|
765
|
+
const range = this.vimVisual.range(this.getText(), this.getCursor(), adapter.motionBoundaries());
|
|
766
|
+
if (range) {
|
|
767
|
+
adapter.readRange(range.start, range.end);
|
|
768
|
+
const edit = this.vimOperator.visualEdit("r" + command, this.getText(), range).edit;
|
|
769
|
+
if (edit) { adapter.replace(edit.start, edit.end, edit.text); adapter.move(edit.cursor); }
|
|
770
|
+
this.vimVisual.cancel(); this.visualAnchor = undefined;
|
|
771
|
+
this.vimOperator.cancel(); this.vimEngine.reset();
|
|
772
|
+
}
|
|
773
|
+
} catch { /* Preserve selection when a paste marker or layout is unsafe. */ }
|
|
774
|
+
}
|
|
775
|
+
this.deps.requestRender(); return;
|
|
776
|
+
}
|
|
777
|
+
if (command === "v" || command === "V") {
|
|
778
|
+
if (this.vimVisual.active && this.vimVisual.kind === (command === "v" ? "char" : "line")) {
|
|
779
|
+
this.vimVisual.cancel(); this.visualAnchor = undefined;
|
|
780
|
+
} else {
|
|
781
|
+
let anchor = this.visualAnchor ?? this.getCursor();
|
|
782
|
+
if (command === "v" && !this.vimVisual.active) {
|
|
783
|
+
try {
|
|
784
|
+
const adapter = this.vimAdapter();
|
|
785
|
+
const stops = adapter.motionBoundaries()[anchor.line];
|
|
786
|
+
if (!stops) return;
|
|
787
|
+
if (stops.length > 1 && anchor.col === stops[stops.length - 1]) {
|
|
788
|
+
anchor = { line: anchor.line, col: stops[stops.length - 2]! };
|
|
789
|
+
adapter.move(anchor);
|
|
790
|
+
}
|
|
791
|
+
} catch { return; }
|
|
792
|
+
}
|
|
793
|
+
this.visualAnchor = anchor;
|
|
794
|
+
this.vimVisual.start(command === "v" ? "char" : "line", anchor);
|
|
795
|
+
}
|
|
796
|
+
this.vimOperator.cancel();
|
|
797
|
+
this.vimEngine.reset();
|
|
798
|
+
this.deps.requestRender();
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
let adapter;
|
|
802
|
+
try { adapter = this.vimAdapter(); } catch { return; }
|
|
803
|
+
if (this.vimVisual.active) {
|
|
804
|
+
if (command === "i" || command === "a") { this.visualObjectPending = command; return; }
|
|
805
|
+
if (command === "r") { this.visualReplacePending = true; return; }
|
|
806
|
+
if (command === "o") {
|
|
807
|
+
const target = this.vimVisual.swap(this.getCursor());
|
|
808
|
+
if (target) { this.visualAnchor = this.getCursor(); adapter.move(target); }
|
|
809
|
+
this.deps.requestRender(); return;
|
|
810
|
+
}
|
|
811
|
+
if (["d", "x", "c", "s", "y", "p", "~", "u", "U", ">", "<", "J"].includes(command)) {
|
|
812
|
+
let range;
|
|
813
|
+
try { range = this.vimVisual.range(this.getText(), this.getCursor(), adapter.motionBoundaries()); }
|
|
814
|
+
catch (error) { this.notifyUnsafeVisual("range", error); return; }
|
|
815
|
+
if (!range) { this.notifyUnsafeVisual("range"); return; }
|
|
816
|
+
let stage: "readRange" | "operation" | "beginInsertSession" | "replace" | "move" = "readRange";
|
|
817
|
+
let beganInsert = false;
|
|
818
|
+
try {
|
|
819
|
+
const selected = adapter.readRange(range.start, range.end);
|
|
820
|
+
if (!selected && (!range.linewise || !this.getText())) {
|
|
821
|
+
this.vimVisual.cancel(); this.visualAnchor = undefined;
|
|
822
|
+
this.vimEngine.reset(); this.deps.requestRender();
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
stage = "operation";
|
|
826
|
+
const operation = this.vimOperator.visualEdit(command === "x" ? "d" : command === "s" ? "c" : command, this.getText(), range);
|
|
827
|
+
if (command === "p" && !operation.edit) {
|
|
828
|
+
this.deps.notifyCompatibility?.("Vim register is empty; yank text before visual paste.");
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
if (operation.edit) {
|
|
832
|
+
this.vimOperator.forgetRepeat();
|
|
833
|
+
if (operation.edit.insert) { stage = "beginInsertSession"; adapter.beginInsertSession(); beganInsert = true; }
|
|
834
|
+
if (operation.edit.start.line !== operation.edit.end.line || operation.edit.start.col !== operation.edit.end.col || operation.edit.text) {
|
|
835
|
+
stage = "replace"; adapter.replace(operation.edit.start, operation.edit.end, operation.edit.text);
|
|
836
|
+
}
|
|
837
|
+
stage = "move"; adapter.move(operation.edit.cursor);
|
|
838
|
+
}
|
|
839
|
+
if (["d", "x", "c", "s", "y", "p"].includes(command)) this.vimOperator.visualRegister(range.linewise ? selected + "\n" : selected, range.linewise);
|
|
840
|
+
this.vimVisual.cancel(); this.visualAnchor = undefined;
|
|
841
|
+
this.vimOperator.cancel(); this.vimEngine.reset();
|
|
842
|
+
if (operation.edit?.insert) this.enterVimInsert(true, true, false);
|
|
843
|
+
} catch (error) {
|
|
844
|
+
if (beganInsert) { try { adapter.endInsertSession(); } catch { /* Retain the original failure. */ } }
|
|
845
|
+
this.notifyUnsafeVisual(stage, error);
|
|
846
|
+
}
|
|
847
|
+
this.deps.requestRender(); return;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
if (command === "." && !this.vimVisual.active && !this.vimOperator.isPending) {
|
|
851
|
+
const count = this.vimOperator.takeRepeatCount();
|
|
852
|
+
this.vimEngine.reset();
|
|
853
|
+
for (let i = 0; i < count; i++) {
|
|
854
|
+
const literal = this.vimOperator.repeatInsertCommand;
|
|
855
|
+
const inserted = this.vimOperator.repeatInsertion;
|
|
856
|
+
const destination = literal ? new VimNormalEngine().input(literal, this.getText(), this.getCursor(), adapter.motionBoundaries()) : undefined;
|
|
857
|
+
const change = literal ? undefined : this.vimOperator.repeatAt(this.getText(), this.getCursor(), adapter.motionBoundaries());
|
|
858
|
+
if (!change?.edit && !(destination?.handled && inserted)) break;
|
|
859
|
+
try {
|
|
860
|
+
if (inserted && /[\r\t]/.test(inserted)) break;
|
|
861
|
+
if (change?.edit?.insert) {
|
|
862
|
+
adapter.readRange(change.edit.start, change.edit.end);
|
|
863
|
+
adapter.beginInsertSession();
|
|
864
|
+
}
|
|
865
|
+
if (change?.edit) {
|
|
866
|
+
if (change.edit.text || change.edit.start.line !== change.edit.end.line || change.edit.start.col !== change.edit.end.col)
|
|
867
|
+
adapter.replace(change.edit.start, change.edit.end, change.edit.text);
|
|
868
|
+
adapter.move(change.edit.cursor);
|
|
869
|
+
}
|
|
870
|
+
if (inserted && (change?.edit?.insert || destination)) {
|
|
871
|
+
const at = destination?.cursor ?? change!.edit!.cursor;
|
|
872
|
+
adapter.replace(at, at, inserted);
|
|
873
|
+
}
|
|
874
|
+
if (change?.edit?.insert) adapter.endInsertSession();
|
|
875
|
+
else if (change?.edit) adapter.move(change.edit.cursor);
|
|
876
|
+
} catch { if (change?.edit?.insert) adapter.endInsertSession(); break; /* Unsafe paste marker: stop replay. */ }
|
|
877
|
+
}
|
|
878
|
+
this.deps.requestRender(); return;
|
|
879
|
+
}
|
|
880
|
+
const wasPending = this.vimOperator.isPending;
|
|
881
|
+
const operator = this.vimVisual.active ? null : this.vimOperator.input(command, this.getText(), this.getCursor(), adapter.motionBoundaries());
|
|
882
|
+
if (operator && (wasPending || !/^[0-9]$/.test(command))) {
|
|
883
|
+
if (!wasPending) this.vimEngine.reset();
|
|
884
|
+
if (operator.edit) {
|
|
885
|
+
try {
|
|
886
|
+
if (operator.edit.insert) adapter.beginInsertSession();
|
|
887
|
+
if (operator.edit.text || operator.edit.start.line !== operator.edit.end.line ||
|
|
888
|
+
operator.edit.start.col !== operator.edit.end.col) {
|
|
889
|
+
adapter.replace(operator.edit.start, operator.edit.end, operator.edit.text);
|
|
890
|
+
}
|
|
891
|
+
adapter.move(operator.edit.cursor);
|
|
892
|
+
if (operator.edit.insert) {
|
|
893
|
+
adapter.markInsertAnchor();
|
|
894
|
+
this.vimInsertIntent = { operator };
|
|
895
|
+
} else this.vimOperator.remember(operator);
|
|
896
|
+
if (operator.edit.insert) this.enterVimInsert(true, true, false);
|
|
897
|
+
} catch { if (operator.edit.insert) adapter.endInsertSession(); this.vimOperator.cancel(); }
|
|
898
|
+
}
|
|
899
|
+
this.deps.requestRender();
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
if (command === "u" && !this.vimVisual.active) { adapter.undo(); this.vimEngine.reset(); this.deps.requestRender(); return; }
|
|
903
|
+
const motion = this.vimEngine.input(command, this.getText(), this.getCursor(), adapter.motionBoundaries());
|
|
904
|
+
if (motion.handled) {
|
|
905
|
+
if (this.vimVisual.active && motion.insert !== undefined) return;
|
|
906
|
+
try {
|
|
907
|
+
adapter.move(motion.cursor);
|
|
908
|
+
if (motion.insert !== undefined) {
|
|
909
|
+
this.enterVimInsert();
|
|
910
|
+
this.vimInsertIntent = { command };
|
|
911
|
+
}
|
|
912
|
+
if (motion.insert === "\n") {
|
|
913
|
+
this.insertTextAtCursor("\n");
|
|
914
|
+
if (command === "O") this.vimAdapter().move({ line: motion.cursor.line, col: 0 });
|
|
915
|
+
}
|
|
916
|
+
this.deps.requestRender();
|
|
917
|
+
} catch { this.vimEngine.reset(); /* Unsupported private layout: no modal mutation. */ }
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
// Encoded Return, Tab, IME and paste are editor input, not
|
|
922
|
+
// app actions. Never forward an unowned NORMAL byte to insertion.
|
|
923
|
+
if (this.vimNormal && !this.keybindingsManager.matches(data, "app.interrupt") && !matchesKey(data, "escape")) return;
|
|
924
|
+
}
|
|
303
925
|
if (
|
|
304
926
|
this.promptState === PROMPT_STATE.WORKING &&
|
|
305
927
|
!this.isShowingAutocomplete() &&
|
|
@@ -355,6 +977,47 @@ export class GentlePromptEditor extends CustomEditor {
|
|
|
355
977
|
super.handleInput(data);
|
|
356
978
|
}
|
|
357
979
|
|
|
980
|
+
/** Buffer a whole paste before any bytes reach Vim's command router. Pi's
|
|
981
|
+
* editor receives the complete frame in INSERT so it retains native paste
|
|
982
|
+
* normalization, marker registration and undo behavior. In NORMAL, discard
|
|
983
|
+
* the entire frame rather than interpreting its contents as keystrokes.
|
|
984
|
+
*/
|
|
985
|
+
private handleVimPasteFrame(data: string): boolean {
|
|
986
|
+
if (this.pasteFrame === undefined) {
|
|
987
|
+
const start = data.indexOf(BRACKETED_PASTE_START);
|
|
988
|
+
if (start < 0) return false;
|
|
989
|
+
// A frame is an atomic event: bytes before its marker must not be
|
|
990
|
+
// accidentally interpreted as a Vim command either.
|
|
991
|
+
this.pasteFrame = "";
|
|
992
|
+
this.pasteOverflow = false;
|
|
993
|
+
data = data.slice(start + BRACKETED_PASTE_START.length);
|
|
994
|
+
}
|
|
995
|
+
// Retain only the terminator's possible prefix between events. This
|
|
996
|
+
// avoids rescanning the entire paste for each single-character chunk.
|
|
997
|
+
const chunk = this.pasteTail + data;
|
|
998
|
+
const end = chunk.indexOf(BRACKETED_PASTE_END);
|
|
999
|
+
const content = end < 0 ? chunk.slice(0, -(BRACKETED_PASTE_END.length - 1)) : chunk.slice(0, end);
|
|
1000
|
+
if (!this.pasteOverflow) {
|
|
1001
|
+
if (this.pasteFrame.length + content.length > MAX_BRACKETED_PASTE_CHARS) {
|
|
1002
|
+
this.pasteFrame = "";
|
|
1003
|
+
this.pasteOverflow = true;
|
|
1004
|
+
} else this.pasteFrame += content;
|
|
1005
|
+
}
|
|
1006
|
+
if (end < 0) {
|
|
1007
|
+
this.pasteTail = chunk.slice(-(BRACKETED_PASTE_END.length - 1));
|
|
1008
|
+
return true;
|
|
1009
|
+
}
|
|
1010
|
+
const paste = this.pasteFrame;
|
|
1011
|
+
const overflow = this.pasteOverflow;
|
|
1012
|
+
this.pasteFrame = undefined;
|
|
1013
|
+
this.pasteOverflow = false;
|
|
1014
|
+
this.pasteTail = "";
|
|
1015
|
+
if (!overflow && !this.vimNormal) super.handleInput(BRACKETED_PASTE_START + paste + BRACKETED_PASTE_END);
|
|
1016
|
+
const remaining = chunk.slice(end + BRACKETED_PASTE_END.length);
|
|
1017
|
+
if (remaining) this.handleInput(remaining);
|
|
1018
|
+
return true;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
358
1021
|
/**
|
|
359
1022
|
* Runs the Esc that actually aborts the turn. Pi's own onEscape (invoked
|
|
360
1023
|
* synchronously by `super.handleInput`) restores `queuedText + draft`
|
|
@@ -386,24 +1049,76 @@ export class GentlePromptEditor extends CustomEditor {
|
|
|
386
1049
|
this.deps.dispatchQueuedText(queued);
|
|
387
1050
|
}
|
|
388
1051
|
|
|
1052
|
+
handleMouse(event: Parameters<CustomEditor["handleMouse"]>[0]): ReturnType<CustomEditor["handleMouse"]> {
|
|
1053
|
+
const layout = this.floatMouseLayout;
|
|
1054
|
+
if (!layout) return super.handleMouse(event);
|
|
1055
|
+
// Row coordinates stay native: padding=0, content=1…N, hint=N+1.
|
|
1056
|
+
// Only float translates x; the existing neon/fallback path is unchanged.
|
|
1057
|
+
if (event.x < layout.prefixWidth || event.x >= layout.width - 1) return undefined;
|
|
1058
|
+
return super.handleMouse({ ...event, x: event.x - layout.prefixWidth, width: layout.nativeWidth });
|
|
1059
|
+
}
|
|
1060
|
+
|
|
389
1061
|
render(width: number): string[] {
|
|
390
|
-
const
|
|
1062
|
+
const layout = resolvePromptLayout(width, cardStyle(), this.deps.bg);
|
|
1063
|
+
this.floatMouseLayout = layout.background ? layout : undefined;
|
|
1064
|
+
const inner = layout.nativeWidth;
|
|
1065
|
+
const lines = super.render(inner);
|
|
391
1066
|
if (this.getText() === "" && lines.length === 3) lines[1] = withPromptHint(lines[1], PROMPT_HINT, this.deps.fg);
|
|
392
1067
|
const state = this.promptState === PROMPT_STATE.WORKING && this.deps.pending() ? PROMPT_STATE.QUEUED : this.promptState;
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
|
|
1068
|
+
// Neon keeps the shell's border role; float follows Pi's live public
|
|
1069
|
+
// borderColor callback so thinking/bash mode accents survive.
|
|
1070
|
+
// Selection is applied only to the owned Pi editor rows, before Gentle
|
|
1071
|
+
// frames them. No second editor factory or private layout mutation.
|
|
1072
|
+
let editorLines = lines;
|
|
1073
|
+
if (this.vimPolicy === "on" && this.vimVisual.active) {
|
|
1074
|
+
try {
|
|
1075
|
+
const adapter = this.vimAdapter();
|
|
1076
|
+
const range = this.vimVisual.range(this.getText(), this.getCursor(), adapter.motionBoundaries());
|
|
1077
|
+
if (range) editorLines = adapter.renderSelection(inner, range.start, range.end, lines);
|
|
1078
|
+
} catch { /* Unknown layout: keep the original rendered prompt. */ }
|
|
1079
|
+
}
|
|
1080
|
+
if (this.vimPolicy !== "on") editorLines = this.selectionEngine.decorateRows(editorLines, inner, 0);
|
|
1081
|
+
// SAFETY: this optional host hint is accepted only as an integer within the
|
|
1082
|
+
// rendered row bounds below; absent/invalid hints fall back to all rows.
|
|
1083
|
+
const visibleCount = (this as unknown as { renderedVisibleLineCount?: number }).renderedVisibleLineCount;
|
|
1084
|
+
const borderEnd = Number.isInteger(visibleCount) && visibleCount! >= 1 && visibleCount! + 2 <= editorLines.length
|
|
1085
|
+
? visibleCount! + 2 : editorLines.length;
|
|
1086
|
+
const framed = framePromptLines(editorLines.slice(0, borderEnd), width, {
|
|
396
1087
|
state,
|
|
397
1088
|
tick: this.tick,
|
|
398
|
-
borderColor: (text) => this.deps.fg(PROMPT_FRAME_ROLE, text),
|
|
1089
|
+
borderColor: (text) => layout.background ? this.borderColor(text) : this.deps.fg(PROMPT_FRAME_ROLE, text),
|
|
399
1090
|
fg: this.deps.fg,
|
|
400
1091
|
bold: this.deps.bold,
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
1092
|
+
workingLabel: this.promptState === PROMPT_STATE.WORKING ? this.deps.workingLabel?.() : undefined,
|
|
1093
|
+
escHint: [
|
|
1094
|
+
this.vimPolicy === "on" ? (this.vimVisual.active ? (this.vimVisual.kind === "line" ? "VISUAL LINE" : "VISUAL") : this.vimNormal ? "NORMAL" : "INSERT") : undefined,
|
|
1095
|
+
this.promptState === PROMPT_STATE.WORKING && this.isPendingEscapeCancel()
|
|
1096
|
+
? DOUBLE_ESC_CANCEL_HINT
|
|
1097
|
+
: this.promptState === PROMPT_STATE.IDLE && this.isPendingIdleClear()
|
|
1098
|
+
? IDLE_ESC_CLEAR_HINT
|
|
1099
|
+
: undefined,
|
|
1100
|
+
].filter(Boolean).join(" · ") || undefined,
|
|
1101
|
+
}, layout);
|
|
1102
|
+
if (this.vimPolicy !== "on") {
|
|
1103
|
+
framed[framed.length - 1] = layout.background
|
|
1104
|
+
? floatPromptRow(this.selectionEngine.decorateBottomRule("", layout.nativeWidth, ""), layout, (text) => this.borderColor(text))
|
|
1105
|
+
: this.selectionEngine.decorateBottomRule(framed[framed.length - 1] ?? "", width, "╯");
|
|
1106
|
+
}
|
|
1107
|
+
// Pi places autocomplete after its bottom border. Keep those rows below
|
|
1108
|
+
// Gentle's frame and preserve their terminal width and row coordinates.
|
|
1109
|
+
for (const line of editorLines.slice(borderEnd)) {
|
|
1110
|
+
if (layout.background) {
|
|
1111
|
+
framed.push(floatPromptRow(line, layout, (text) => this.borderColor(text)));
|
|
1112
|
+
continue;
|
|
1113
|
+
}
|
|
1114
|
+
const available = Math.max(0, width);
|
|
1115
|
+
const border = available >= 2 ? "│" : "";
|
|
1116
|
+
const inner = available - border.length * 2;
|
|
1117
|
+
const clipped = truncateToWidth(line, inner, "");
|
|
1118
|
+
framed.push(this.deps.fg(PROMPT_FRAME_ROLE, border) + clipped +
|
|
1119
|
+
" ".repeat(Math.max(0, inner - visibleWidth(clipped))) + this.deps.fg(PROMPT_FRAME_ROLE, border));
|
|
1120
|
+
}
|
|
1121
|
+
return framed;
|
|
407
1122
|
}
|
|
408
1123
|
|
|
409
1124
|
dispose(): void {
|
|
@@ -436,20 +1151,30 @@ type PromptFactory = NonNullable<ReturnType<ExtensionContext["ui"]["getEditorCom
|
|
|
436
1151
|
function installPrompt(
|
|
437
1152
|
ctx: ExtensionContext,
|
|
438
1153
|
onCreated: (prompt: GentlePromptEditor) => void,
|
|
439
|
-
promptDeps: { now: () => number; doubleEscCancelEnabled: () => boolean; dispatchQueuedText: (text: string) => void },
|
|
1154
|
+
promptDeps: { now: () => number; doubleEscCancelEnabled: () => boolean; dispatchQueuedText: (text: string) => void; vimRuntimeVersion?: () => string | undefined },
|
|
440
1155
|
): boolean {
|
|
441
1156
|
const previous = ctx.ui.getEditorComponent() as PromptFactory | undefined;
|
|
442
1157
|
if (previous && !previous[PROMPT_OWNER]) return false;
|
|
443
1158
|
const factory: PromptFactory = (tui, theme, keybindings) => {
|
|
444
1159
|
const prompt = new GentlePromptEditor(tui, theme, keybindings, {
|
|
445
1160
|
fg: (color, text) => ctx.ui.theme.fg(color as Parameters<typeof ctx.ui.theme.fg>[0], text),
|
|
1161
|
+
bg: (color, text) => ctx.ui.theme.bg(color as Parameters<typeof ctx.ui.theme.bg>[0], text),
|
|
446
1162
|
bold: (text) => ctx.ui.theme.bold(text),
|
|
447
1163
|
requestRender: () => tui.requestRender(),
|
|
448
1164
|
pending: () => ctx.hasPendingMessages(),
|
|
449
1165
|
now: promptDeps.now,
|
|
450
1166
|
doubleEscCancelEnabled: promptDeps.doubleEscCancelEnabled,
|
|
451
1167
|
dispatchQueuedText: promptDeps.dispatchQueuedText,
|
|
1168
|
+
notifyCompatibility: (message) => ctx.ui.notify(message, "warning"),
|
|
1169
|
+
tuiVersion: promptDeps.vimRuntimeVersion,
|
|
1170
|
+
workingLabel: () => oddPhaseRegistry.label(ctx.sessionManager.getSessionId()),
|
|
452
1171
|
});
|
|
1172
|
+
// Pi's own docs require an explicit requestRender() after a state change
|
|
1173
|
+
// (docs/tui.md: "Call tui.requestRender() after state changes"); no host
|
|
1174
|
+
// re-render is implicitly guaranteed, and the WORKING pulse loop does not
|
|
1175
|
+
// run at all under the "potato" animation policy. Register this session's
|
|
1176
|
+
// redraw so a phase reported by the tool is visible immediately.
|
|
1177
|
+
oddPhaseRegistry.setRenderRequest(ctx.sessionManager.getSessionId(), () => tui.requestRender());
|
|
453
1178
|
onCreated(prompt);
|
|
454
1179
|
return prompt;
|
|
455
1180
|
};
|
|
@@ -498,6 +1223,7 @@ function renderDoubleEscCancelReport(
|
|
|
498
1223
|
}
|
|
499
1224
|
|
|
500
1225
|
const CHANGES_WIDGET_KEY = "gentle-shell-changes";
|
|
1226
|
+
const HEADER_WIDGET_KEY = "gentle-shell-below-input-header";
|
|
501
1227
|
const CHANGES_COMMAND_NAME = "gentle:changes";
|
|
502
1228
|
const CHANGES_SHORTCUT_DEFAULT = "alt+g";
|
|
503
1229
|
const CHANGES_POLL_DEFAULT_MS = 2000;
|
|
@@ -506,7 +1232,7 @@ const COMMANDS_COMMAND_NAME = "gentle:commands";
|
|
|
506
1232
|
const OVERLAY_HEIGHT_RATIO = 0.8;
|
|
507
1233
|
const OVERLAY_MIN_ROWS = 8;
|
|
508
1234
|
|
|
509
|
-
export function shellGitRunner(cwd: string, env: NodeJS.ProcessEnv = process.env, run: typeof execFile = execFile): GitRunner {
|
|
1235
|
+
export function shellGitRunner(cwd: string, env: NodeJS.ProcessEnv = process.env, run: typeof execFile = execFile, maxBuffer = Infinity): GitRunner {
|
|
510
1236
|
// Pi exec cannot replace the inherited environment. Use argv directly and
|
|
511
1237
|
// a complete sanitized environment for discovery, status, and lazy diffs.
|
|
512
1238
|
const childEnv = worktreeGitEnvironment(env);
|
|
@@ -519,8 +1245,10 @@ export function shellGitRunner(cwd: string, env: NodeJS.ProcessEnv = process.env
|
|
|
519
1245
|
timeout: GIT_TIMEOUT_MS,
|
|
520
1246
|
// Pi exec accumulates output without a maxBuffer cap. In particular,
|
|
521
1247
|
// large porcelain inventories must not become partial successful scans.
|
|
522
|
-
|
|
1248
|
+
// Only a single diff passes a bound, and is told when it hit it.
|
|
1249
|
+
maxBuffer,
|
|
523
1250
|
}, (error, stdout) => {
|
|
1251
|
+
if ((error as { code?: unknown } | null)?.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") return resolve({ stdout, code: 1, truncated: true });
|
|
524
1252
|
resolve({ stdout, code: error ? typeof error.code === "number" ? error.code : 1 : 0 });
|
|
525
1253
|
});
|
|
526
1254
|
});
|
|
@@ -529,9 +1257,24 @@ export function shellGitRunner(cwd: string, env: NodeJS.ProcessEnv = process.env
|
|
|
529
1257
|
export async function loadFileDiff(git: GitRunner, file: ChangedFile): Promise<string> {
|
|
530
1258
|
const args = file.status === CHANGE_STATUS.UNTRACKED ? ["diff", "--no-index", "--", "/dev/null", file.path] : ["diff", "HEAD", "--", file.path];
|
|
531
1259
|
const result = await git(args);
|
|
1260
|
+
if (result.truncated || result.stdout.length > DIFF_MAX_BYTES || result.stdout.split("\n", DIFF_MAX_LINES + 2).length > DIFF_MAX_LINES + 1) return DIFF_TOO_LARGE;
|
|
532
1261
|
return result.code === 0 || result.code === 1 ? result.stdout : "";
|
|
533
1262
|
}
|
|
534
1263
|
|
|
1264
|
+
// Untracked files have no numstat; count their lines without reading past the diff bound.
|
|
1265
|
+
const untrackedLines = (root: string): LineCounter => async (path) => {
|
|
1266
|
+
const target = join(root, path);
|
|
1267
|
+
const info = await lstat(target);
|
|
1268
|
+
if (!info.isFile() || info.size > DIFF_MAX_BYTES) return 0;
|
|
1269
|
+
const text = await readFile(target, "utf8");
|
|
1270
|
+
return text === "" ? 0 : text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
|
|
1271
|
+
};
|
|
1272
|
+
// Same-count edits still change size or mtime, so an open diff reloads.
|
|
1273
|
+
const fileRevision = (root: string): FileRevision => async (path) => {
|
|
1274
|
+
const info = await lstat(join(root, path));
|
|
1275
|
+
return `${info.size}:${info.mtimeMs}`;
|
|
1276
|
+
};
|
|
1277
|
+
|
|
535
1278
|
export interface ExternalEditorHost {
|
|
536
1279
|
stop(): void;
|
|
537
1280
|
start(): void;
|
|
@@ -573,49 +1316,47 @@ function changesPollMs(env: NodeJS.ProcessEnv): number {
|
|
|
573
1316
|
return positiveMs(env.GENTLE_PI_SHELL_CHANGES_POLL_MS, CHANGES_POLL_DEFAULT_MS);
|
|
574
1317
|
}
|
|
575
1318
|
|
|
1319
|
+
// One bounded window per provider refresh: a credential lookup or fetch that
|
|
1320
|
+
// never answers must not hold the other providers — or the panel opening on
|
|
1321
|
+
// them — past it. Tunable (tests, slow networks); invalid values fall back.
|
|
1322
|
+
const USAGE_FETCH_TIMEOUT_DEFAULT_MS = 10_000;
|
|
1323
|
+
const usageFetchTimeoutMs = (env: NodeJS.ProcessEnv): number => positiveMs(env.GENTLE_PI_SHELL_USAGE_TIMEOUT_MS, USAGE_FETCH_TIMEOUT_DEFAULT_MS);
|
|
1324
|
+
|
|
576
1325
|
function changesFingerprint(model: ChangesModel): string {
|
|
577
|
-
return
|
|
1326
|
+
return model.files.map((file) => `${file.path}:${file.status}:${file.added}:${file.deleted}:${file.diffRevision ?? ""}:${file.countsUnavailable ?? ""}`).join("|");
|
|
578
1327
|
}
|
|
579
1328
|
|
|
580
1329
|
interface OverlayDeps {
|
|
581
|
-
loadDiff(root: string, file: ChangedFile): string
|
|
1330
|
+
loadDiff(root: string, file: ChangedFile): Promise<string>;
|
|
582
1331
|
worktrees(): WorktreeChanges[];
|
|
583
1332
|
refresh(): Promise<ChangesModel>;
|
|
584
1333
|
apply(ctx: ExtensionContext, model: ChangesModel): void;
|
|
585
1334
|
pollMs: number;
|
|
586
|
-
gitForRoot(root: string): GitRunner;
|
|
587
1335
|
}
|
|
588
1336
|
|
|
589
|
-
//
|
|
590
|
-
// only Git the overlay touches is each root's HEAD, to label its tree.
|
|
1337
|
+
// The overlay polls each root's Git state while it is open; diffs load lazily.
|
|
591
1338
|
async function showChangesOverlay(ctx: ExtensionContext, deps: OverlayDeps): Promise<void> {
|
|
592
1339
|
let host: ExternalEditorHost | undefined;
|
|
593
1340
|
let view: WorktreeChangesView | undefined;
|
|
594
|
-
// Session evidence knows roots, not branches; label them while the overlay
|
|
595
|
-
// is open and repaint when Git answers.
|
|
596
|
-
const labels = new RootBranchLabels(deps.gitForRoot, () => {
|
|
597
|
-
view?.update(labels.decorate(deps.worktrees()));
|
|
598
|
-
host?.requestRender();
|
|
599
|
-
});
|
|
600
|
-
const worktrees = () => labels.decorate(deps.worktrees());
|
|
601
1341
|
const refresh = async () => {
|
|
602
1342
|
const latest = await deps.refresh();
|
|
603
|
-
view?.update(worktrees());
|
|
1343
|
+
view?.update(deps.worktrees());
|
|
604
1344
|
deps.apply(ctx, latest);
|
|
605
1345
|
};
|
|
606
|
-
const poll = setInterval(() => void refresh(), deps.pollMs);
|
|
1346
|
+
const poll = setInterval(() => void refresh().catch(() => {}), deps.pollMs);
|
|
607
1347
|
poll.unref();
|
|
608
1348
|
try {
|
|
609
1349
|
const chosen = await ctx.ui.custom<{ root: string; file: ChangedFile } | null>(
|
|
610
1350
|
(tui, theme, _keybindings, done) => {
|
|
1351
|
+
const close = withOverlayRepaint(tui, done);
|
|
611
1352
|
host = tui;
|
|
612
|
-
view = new WorktreeChangesView(worktrees(), {
|
|
1353
|
+
view = new WorktreeChangesView(deps.worktrees(), {
|
|
613
1354
|
theme,
|
|
614
1355
|
rows: () => Math.max(OVERLAY_MIN_ROWS, Math.floor(tui.terminal.rows * OVERLAY_HEIGHT_RATIO)),
|
|
615
|
-
loadDiff:
|
|
616
|
-
onOpen: (root, file) =>
|
|
617
|
-
onRefresh: () => void refresh(),
|
|
618
|
-
onClose: () =>
|
|
1356
|
+
loadDiff: deps.loadDiff,
|
|
1357
|
+
onOpen: (root, file) => close({ root, file }),
|
|
1358
|
+
onRefresh: () => void refresh().catch(() => {}),
|
|
1359
|
+
onClose: () => close(null),
|
|
619
1360
|
requestRender: () => tui.requestRender(),
|
|
620
1361
|
});
|
|
621
1362
|
return view;
|
|
@@ -648,14 +1389,14 @@ async function showCommandPalette(pi: ExtensionAPI, ctx: ExtensionContext, env:
|
|
|
648
1389
|
return;
|
|
649
1390
|
}
|
|
650
1391
|
const result = await ctx.ui.custom<CommandPaletteResult>(
|
|
651
|
-
(tui, theme, _keybindings, done) => new CommandPalette(groups, done, theme, () => Math.max(0, tui.terminal.rows)),
|
|
1392
|
+
(tui, theme, _keybindings, done) => new CommandPalette(groups, withOverlayRepaint(tui, done), theme, () => Math.max(0, tui.terminal.rows)),
|
|
652
1393
|
{ overlay: true, overlayOptions: { anchor: "center", width: "70%", minWidth: 60, maxHeight: "85%" } },
|
|
653
1394
|
);
|
|
654
1395
|
if (result?.type === "run") pi.sendUserMessage(`/${result.name}`, { expandPromptTemplates: true });
|
|
655
1396
|
}
|
|
656
1397
|
|
|
657
|
-
function showChanges(ctx: ExtensionContext, model: ChangesModel): void {
|
|
658
|
-
if (model.files.length === 0) {
|
|
1398
|
+
function showChanges(ctx: ExtensionContext, model: ChangesModel, visible = true, grouped: (width: number) => boolean = () => false): void {
|
|
1399
|
+
if (!visible || model.files.length === 0) {
|
|
659
1400
|
ctx.ui.setWidget(CHANGES_WIDGET_KEY, undefined);
|
|
660
1401
|
return;
|
|
661
1402
|
}
|
|
@@ -663,7 +1404,7 @@ function showChanges(ctx: ExtensionContext, model: ChangesModel): void {
|
|
|
663
1404
|
CHANGES_WIDGET_KEY,
|
|
664
1405
|
(tui, theme) => sidebarPart(tui, "changes", {
|
|
665
1406
|
render(width: number) {
|
|
666
|
-
return renderChangesWidget(model, theme, width);
|
|
1407
|
+
return grouped(width) ? [] : renderChangesWidget(model, theme, width);
|
|
667
1408
|
},
|
|
668
1409
|
invalidate() {},
|
|
669
1410
|
}),
|
|
@@ -684,6 +1425,7 @@ function messageText(content: string | Array<{ type: string; text?: string }>):
|
|
|
684
1425
|
|
|
685
1426
|
interface CardComponentOptions {
|
|
686
1427
|
expanded: boolean;
|
|
1428
|
+
previewRows?: number;
|
|
687
1429
|
hint?: string;
|
|
688
1430
|
}
|
|
689
1431
|
|
|
@@ -707,15 +1449,19 @@ function spaced(component: { render(width: number): string[]; invalidate(): void
|
|
|
707
1449
|
};
|
|
708
1450
|
}
|
|
709
1451
|
|
|
1452
|
+
// Same rose identity as the Gentle AI tool cards (lib/gentle-ai-renderer.ts).
|
|
1453
|
+
const GENTLE_AI_GLYPH = "\u{1F339}";
|
|
1454
|
+
|
|
710
1455
|
export function devBinaryCard(notice: DevBinaryNotice): Card {
|
|
711
1456
|
if (notice.state === "invalid") {
|
|
712
|
-
return { title: "
|
|
1457
|
+
return { title: "gentle-ai", subtitle: "dev binary override invalid", body: [notice.reason], tone: CARD_TONE.ERROR, glyph: GENTLE_AI_GLYPH };
|
|
713
1458
|
}
|
|
714
1459
|
return {
|
|
715
|
-
title: "
|
|
1460
|
+
title: "gentle-ai",
|
|
716
1461
|
subtitle: "dev binary override · field-test only",
|
|
717
1462
|
body: [`${notice.path} · sha256:${notice.sha256.slice(0, SHA_PREFIX_LENGTH)}`],
|
|
718
1463
|
tone: CARD_TONE.WARNING,
|
|
1464
|
+
glyph: GENTLE_AI_GLYPH,
|
|
719
1465
|
};
|
|
720
1466
|
}
|
|
721
1467
|
|
|
@@ -770,10 +1516,35 @@ async function fetchFromSource(source: UsageSource, apiKey: string | undefined,
|
|
|
770
1516
|
}
|
|
771
1517
|
}
|
|
772
1518
|
|
|
1519
|
+
// Changes refreshes started without awaiting (turn end, worktree registration)
|
|
1520
|
+
// are tracked so tests can wait for them instead of guessing a delay.
|
|
1521
|
+
const backgroundChangeRefreshes = new Set<Promise<void>>();
|
|
1522
|
+
function trackChangeRefresh(refresh: Promise<void>): void {
|
|
1523
|
+
const tracked = refresh.catch(() => {}).finally(() => { backgroundChangeRefreshes.delete(tracked); });
|
|
1524
|
+
backgroundChangeRefreshes.add(tracked);
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
/** Resolves once every background Changes refresh has finished, or after `timeoutMs` for one that never settles. */
|
|
1528
|
+
export async function backgroundChangeRefreshesSettled(timeoutMs = 2000): Promise<void> {
|
|
1529
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1530
|
+
const deadline = new Promise<void>((resolve) => { timer = setTimeout(resolve, timeoutMs); });
|
|
1531
|
+
const settled = (async () => { while (backgroundChangeRefreshes.size > 0) await Promise.all([...backgroundChangeRefreshes]); })();
|
|
1532
|
+
await Promise.race([settled, deadline]);
|
|
1533
|
+
clearTimeout(timer);
|
|
1534
|
+
}
|
|
1535
|
+
|
|
773
1536
|
export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = process.env, overrides: Partial<ShellDeps> = {}): void {
|
|
774
1537
|
installSessionChangeCapture(pi, env, overrides.resolveWorktree ?? resolveSessionWorktree);
|
|
775
1538
|
if (!shellEnabled(env)) return;
|
|
776
|
-
const
|
|
1539
|
+
const profileReader = createActiveProfileReader(env);
|
|
1540
|
+
const deps: ShellDeps = { ...defaultShellDeps, activeProfile: profileReader, ...overrides };
|
|
1541
|
+
let closeCustomize: (() => void) | undefined;
|
|
1542
|
+
let profilePoll: ReturnType<typeof setInterval> | undefined;
|
|
1543
|
+
const stopProfilePoll = () => {
|
|
1544
|
+
if (profilePoll) clearInterval(profilePoll);
|
|
1545
|
+
profilePoll = undefined;
|
|
1546
|
+
profileReader.reset();
|
|
1547
|
+
};
|
|
777
1548
|
const usage = new UsageStore();
|
|
778
1549
|
// Providers gentle-shell has never heard of get a usage source too, when
|
|
779
1550
|
// the extension that owns them registers one on pi.events; see the
|
|
@@ -783,30 +1554,151 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
783
1554
|
// The 5-minute rule is per provider: one provider's fetch cannot leave the
|
|
784
1555
|
// next one waiting for an interval it never used.
|
|
785
1556
|
const usageFetchedAt = new Map<string, number>();
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
if (!
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
1557
|
+
// Actual failures, per provider: a refresh that settled without a snapshot.
|
|
1558
|
+
// In-flight fetches are never in here, so they cannot read as failed; a
|
|
1559
|
+
// successful refresh (or a snapshot otherwise recorded) clears the provider.
|
|
1560
|
+
const usageFailures = new Set<string>();
|
|
1561
|
+
// Monotonic per-provider generation: each dispatched refresh takes the next
|
|
1562
|
+
// number, and only the newest one for a provider may mutate its state. Two
|
|
1563
|
+
// overlapping refreshes of the SAME source (or of builtins) can otherwise
|
|
1564
|
+
// let an older failure settle after a newer success and poison it.
|
|
1565
|
+
const usageGenerations = new Map<string, number>();
|
|
1566
|
+
// NaN accepts a thinking level for DeepSeek V4 Flash, Qwen 3.8 Flash, and MiMo
|
|
1567
|
+
// but manages their depth itself, so the header reports `auto` and the shell says
|
|
1568
|
+
// why once per model instead of repeating it on every level change.
|
|
1569
|
+
const adaptiveReasoningNotices = new Set<string>();
|
|
1570
|
+
const announceAdaptiveReasoning = (ctx: ExtensionContext, model: { provider: string; id: string; name?: string } | undefined) => {
|
|
1571
|
+
if (!ctx.hasUI || !isNanAdaptiveReasoningModel(model)) return;
|
|
1572
|
+
const key = `${model.provider}/${model.id}`;
|
|
1573
|
+
if (adaptiveReasoningNotices.has(key)) return;
|
|
1574
|
+
adaptiveReasoningNotices.add(key);
|
|
1575
|
+
ctx.ui.notify(`${model.name || model.id} chooses its reasoning depth automatically; the selected thinking level is accepted but does not change it.`, "info");
|
|
1576
|
+
};
|
|
1577
|
+
// One spelling of the config home, so the pin resolver, the global profiles
|
|
1578
|
+
// store and the profile reader cannot drift onto two different stores.
|
|
1579
|
+
const usageConfigHome = gentlePiConfigHome(env);
|
|
1580
|
+
const usageFetchTimeout = usageFetchTimeoutMs(env);
|
|
1581
|
+
// The subagent routing in force for this session: a session binding first
|
|
1582
|
+
// (gentle-shell#1064 slice 1), then a repository pin, then the global active
|
|
1583
|
+
// profile. Only the profile's own role entries count; the reserved
|
|
1584
|
+
// orchestrator key is not a route.
|
|
1585
|
+
const activeRoutingModels = (ctx: ExtensionContext): Array<string | undefined> => {
|
|
1586
|
+
const session = readSessionProfileBinding(ctx.sessionManager?.getSessionId?.())?.modelProfiles;
|
|
1587
|
+
if (session) return [...profileRoleEntries(session).map(([, entry]) => entry.model)];
|
|
1588
|
+
const pin = resolveProfilePin({ cwd: ctx.cwd, configHome: usageConfigHome, resolveWorktree: deps.resolveWorktree });
|
|
1589
|
+
const config = pin ? pin.modelProfiles : undefined;
|
|
1590
|
+
if (config) return [...profileRoleEntries(config).map(([, entry]) => entry.model)];
|
|
1591
|
+
const store = readProfilesFileResult(profilesFilePath(usageConfigHome));
|
|
1592
|
+
const active = store.status === "valid" && store.file.active !== undefined ? store.file.profiles[store.file.active] : undefined;
|
|
1593
|
+
return active ? profileRoleEntries(active).map(([, entry]) => entry.model) : [];
|
|
1594
|
+
};
|
|
1595
|
+
// A bare model id names a provider only when exactly one provider in the
|
|
1596
|
+
// registry carries that id; anything else stays untargeted rather than guessed.
|
|
1597
|
+
const bareModelProvider = (ctx: ExtensionContext, modelId: string): string | undefined => {
|
|
1598
|
+
try {
|
|
1599
|
+
const providers = new Set(ctx.modelRegistry.getAll().filter((model) => model.id === modelId).map((model) => model.provider));
|
|
1600
|
+
return providers.size === 1 ? [...providers][0] : undefined;
|
|
1601
|
+
} catch {
|
|
1602
|
+
return undefined;
|
|
1603
|
+
}
|
|
1604
|
+
};
|
|
1605
|
+
const usageScopeFor = (ctx: ExtensionContext): string[] =>
|
|
1606
|
+
usageScopeProviders(ctx.model?.provider, activeRoutingModels(ctx), (modelId) => bareModelProvider(ctx, modelId));
|
|
1607
|
+
let usageScope: string[] = [];
|
|
1608
|
+
// The scope is Git/filesystem work, so it is resolved once per refresh and
|
|
1609
|
+
// per registration — never per panel render, which only reads the cache.
|
|
1610
|
+
const resolveUsageScope = (ctx: ExtensionContext): string[] => {
|
|
1611
|
+
usageScope = usageScopeFor(ctx);
|
|
1612
|
+
return usageScope;
|
|
1613
|
+
};
|
|
1614
|
+
// One notification per settled provider — snapshot or failure — so the
|
|
1615
|
+
// shell and the open overlay repaint immediately instead of waiting for
|
|
1616
|
+
// the whole refresh, whose slowest member is the bounded window itself.
|
|
1617
|
+
const notifyUsageSettled = () => {
|
|
807
1618
|
renderHost?.invalidateSidebar?.();
|
|
808
1619
|
renderHost?.requestRender();
|
|
809
1620
|
};
|
|
1621
|
+
// One bounded, self-contained refresh per provider. Providers run
|
|
1622
|
+
// concurrently: a stalled credential lookup or fetch for one can never hold
|
|
1623
|
+
// the others — or the overlay opening on them — past its own window.
|
|
1624
|
+
const refreshProvider = async (ctx: ExtensionContext, provider: string, source: UsageSource | undefined, force: boolean, now: number): Promise<boolean> => {
|
|
1625
|
+
if (!source && provider !== CODEX_PROVIDER && provider !== NAN_PROVIDER) return false;
|
|
1626
|
+
if (!force && now - (usageFetchedAt.get(provider) ?? 0) < USAGE_REFRESH_MS) return false;
|
|
1627
|
+
usageFetchedAt.set(provider, now);
|
|
1628
|
+
const generation = (usageGenerations.get(provider) ?? 0) + 1;
|
|
1629
|
+
usageGenerations.set(provider, generation);
|
|
1630
|
+
// The window covers credential resolution and the fetch together. The
|
|
1631
|
+
// abort signal reaches the underlying fetch through the wrapped fetchFn —
|
|
1632
|
+
// built-ins and registered sources alike — so an expired provider is
|
|
1633
|
+
// actually cancelled when its caller honors the signal. The race is the
|
|
1634
|
+
// only path to state mutation: whatever the work resolves after the
|
|
1635
|
+
// window expired is a value nobody reads, so a late answer — success or
|
|
1636
|
+
// failure — cannot mutate anything.
|
|
1637
|
+
const controller = new AbortController();
|
|
1638
|
+
let expire: (() => void) | undefined;
|
|
1639
|
+
const expired = new Promise<"timeout">((resolve) => { expire = () => resolve("timeout"); });
|
|
1640
|
+
const timer = setTimeout(() => {
|
|
1641
|
+
controller.abort();
|
|
1642
|
+
expire?.();
|
|
1643
|
+
}, usageFetchTimeout);
|
|
1644
|
+
// The window's signal composes with whatever the caller already carries —
|
|
1645
|
+
// init.signal, or a Request input's own signal — instead of replacing it:
|
|
1646
|
+
// either side aborting still aborts, exactly like a plain fetch. With no
|
|
1647
|
+
// caller signal the window's signal passes through unchanged.
|
|
1648
|
+
const boundedFetch: typeof fetch = (input, init) => {
|
|
1649
|
+
const callerSignal = init?.signal ?? (typeof Request !== "undefined" && input instanceof Request ? input.signal : undefined);
|
|
1650
|
+
return deps.fetch(input, {
|
|
1651
|
+
...init,
|
|
1652
|
+
signal: callerSignal ? AbortSignal.any([callerSignal, controller.signal]) : controller.signal,
|
|
1653
|
+
});
|
|
1654
|
+
};
|
|
1655
|
+
const work = (async () => {
|
|
1656
|
+
const apiKey = await ctx.modelRegistry.getApiKeyForProvider(provider).catch(() => undefined);
|
|
1657
|
+
// The window may have expired while the credential was resolving: an
|
|
1658
|
+
// aborted provider must not start its fetch at all.
|
|
1659
|
+
if (controller.signal.aborted) return undefined;
|
|
1660
|
+
return source
|
|
1661
|
+
? fetchFromSource(source, apiKey, boundedFetch, deps.now())
|
|
1662
|
+
: provider === NAN_PROVIDER
|
|
1663
|
+
? fetchNanUsage(apiKey, boundedFetch, deps.now())
|
|
1664
|
+
: fetchCodexUsage(apiKey, boundedFetch, deps.now());
|
|
1665
|
+
})();
|
|
1666
|
+
// The race's loser still runs to completion in the background: guard it so
|
|
1667
|
+
// its rejection (a foreign source escaping its own catch) can never
|
|
1668
|
+
// surface as unhandled, and clear the timer so nothing dangles.
|
|
1669
|
+
const guarded = work.catch(() => undefined);
|
|
1670
|
+
const settled = await Promise.race([guarded, expired]);
|
|
1671
|
+
clearTimeout(timer);
|
|
1672
|
+
// Discard before any state mutation: a generation mismatch means a newer
|
|
1673
|
+
// refresh for this provider was dispatched and owns the state, and a
|
|
1674
|
+
// replaced source's answer belongs to neither. A late success must not
|
|
1675
|
+
// overwrite the newer snapshot, and a late failure must not mark the
|
|
1676
|
+
// provider failed after its replacement succeeded.
|
|
1677
|
+
if (usageGenerations.get(provider) !== generation) return false;
|
|
1678
|
+
if (source && usageSources.get(provider) !== source) return false;
|
|
1679
|
+
if (settled === "timeout" || !settled) {
|
|
1680
|
+
usageFailures.add(provider);
|
|
1681
|
+
notifyUsageSettled();
|
|
1682
|
+
return false;
|
|
1683
|
+
}
|
|
1684
|
+
usage.record(settled);
|
|
1685
|
+
usageFailures.delete(provider);
|
|
1686
|
+
notifyUsageSettled();
|
|
1687
|
+
return true;
|
|
1688
|
+
};
|
|
1689
|
+
const refreshUsage = async (ctx: ExtensionContext, force: boolean, only?: string) => {
|
|
1690
|
+
const scope = resolveUsageScope(ctx);
|
|
1691
|
+
const now = deps.now();
|
|
1692
|
+
// Concurrent per provider; each settle notifies the shell (and repaints
|
|
1693
|
+
// the open overlay) on its own, so a fast provider never waits for the
|
|
1694
|
+
// slowest one's window. A discarded late answer notifies nothing: the
|
|
1695
|
+
// refresh that owns the state notifies at its own settle.
|
|
1696
|
+
await Promise.all(
|
|
1697
|
+
scope
|
|
1698
|
+
.filter((provider) => only === undefined || provider === only)
|
|
1699
|
+
.map((provider) => refreshProvider(ctx, provider, usageSources.get(provider), force, now)),
|
|
1700
|
+
);
|
|
1701
|
+
};
|
|
810
1702
|
// Subscribed once, for the life of the extension: a registration can
|
|
811
1703
|
// arrive before the first session_start (the owning extension's factory
|
|
812
1704
|
// runs first) or after it (its own session_start fires later, or it
|
|
@@ -817,36 +1709,49 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
817
1709
|
const source = parseUsageSource(payload);
|
|
818
1710
|
if (!source) return;
|
|
819
1711
|
usageSources.register(source);
|
|
820
|
-
if (currentContext
|
|
1712
|
+
if (!currentContext) return;
|
|
1713
|
+
// A registration is interesting when its provider is targeted at all —
|
|
1714
|
+
// the session's own or a subagent route of the active profile — and then
|
|
1715
|
+
// only that provider is forced, never the whole scope.
|
|
1716
|
+
if (resolveUsageScope(currentContext).includes(source.provider)) void refreshUsage(currentContext, true, source.provider);
|
|
821
1717
|
});
|
|
822
1718
|
pi.on("after_provider_response", (event) => {
|
|
823
1719
|
const parsed = parseUsageHeaders(event.headers, deps.now());
|
|
824
1720
|
if (!parsed) return;
|
|
825
1721
|
usage.record(parsed);
|
|
1722
|
+
// A snapshot that arrived with a response is evidence the provider is
|
|
1723
|
+
// answering: a failure some earlier refresh recorded no longer stands.
|
|
1724
|
+
usageFailures.delete(parsed.provider);
|
|
826
1725
|
renderHost?.invalidateSidebar?.();
|
|
827
1726
|
renderHost?.requestRender();
|
|
828
1727
|
});
|
|
829
1728
|
pi.registerMessageRenderer(REVIEW_PREFLIGHT_TYPE, (message, options, theme) => {
|
|
830
|
-
const
|
|
1729
|
+
const lines = messageText(message.content as string | Array<{ type: string; text?: string }>).split("\n");
|
|
1730
|
+
const body = options.expanded ? lines : lines.filter((line) => line.trim() !== "");
|
|
831
1731
|
const hint = keyHint("app.tools.expand", options.expanded ? "collapse" : "expand");
|
|
832
|
-
return cardComponent({ title: "Gentle AI", subtitle: "review preflight", body, tone: CARD_TONE.INFO }, theme, { expanded: options.expanded, hint });
|
|
1732
|
+
return cardComponent({ title: "Gentle AI", subtitle: "review preflight", body, tone: CARD_TONE.INFO }, theme, { expanded: options.expanded, previewRows: 3, hint });
|
|
833
1733
|
});
|
|
834
|
-
const openUsage =
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
new UsageView(usage, {
|
|
1734
|
+
const openUsage = (ctx: ExtensionContext) =>
|
|
1735
|
+
ctx.ui.custom<null>(
|
|
1736
|
+
(tui, theme, _keybindings, done) => {
|
|
1737
|
+
const view = new UsageView(usage, {
|
|
839
1738
|
theme,
|
|
840
1739
|
now: () => deps.now(),
|
|
841
1740
|
active: () => (ctx.model ? { provider: ctx.model.provider } : undefined),
|
|
842
1741
|
registry: () => usageSources,
|
|
843
1742
|
onRefresh: () => refreshUsage(ctx, true),
|
|
844
|
-
onClose: () => done(null),
|
|
1743
|
+
onClose: () => withOverlayRepaint(tui, done)(null),
|
|
845
1744
|
requestRender: () => tui.requestRender(),
|
|
846
|
-
|
|
1745
|
+
scope: () => ({ providers: usageScope, failed: new Set(usageFailures) }),
|
|
1746
|
+
});
|
|
1747
|
+
// Open promptly: the panel draws whatever the store already holds
|
|
1748
|
+
// while the forced refresh runs underneath it, and repaints when
|
|
1749
|
+
// the refresh settles — answers, failures, or the bounded timeout.
|
|
1750
|
+
view.refresh();
|
|
1751
|
+
return view;
|
|
1752
|
+
},
|
|
847
1753
|
{ overlay: true, overlayOptions: { width: "70%", minWidth: 60, anchor: "center" } },
|
|
848
1754
|
);
|
|
849
|
-
};
|
|
850
1755
|
pi.registerCommand(USAGE_COMMAND_NAME, {
|
|
851
1756
|
description: "Show subscription usage windows for the connected providers. Press r to refetch.",
|
|
852
1757
|
handler: async (_args, ctx) => openUsage(ctx),
|
|
@@ -875,90 +1780,224 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
875
1780
|
const doubleEscCancelConfigHome = gentlePiConfigHome(env);
|
|
876
1781
|
const animationOptions = { gentlePiConfigHome: doubleEscCancelConfigHome };
|
|
877
1782
|
let animationPolicy = resolveAnimationPolicy(animationOptions).policy;
|
|
1783
|
+
let vimPolicy = resolveVimPolicy(animationOptions).policy;
|
|
1784
|
+
// Conversation cards read the style from a process-wide slot; the saved
|
|
1785
|
+
// preference fills it at startup and again on every session start.
|
|
1786
|
+
const applyCardStyle = () => setCardStyle(resolveCardStyle(animationOptions).style);
|
|
1787
|
+
applyCardStyle();
|
|
1788
|
+
// The Card content preference rides the same kind of slot: the quiet tool
|
|
1789
|
+
// renderers read it live, so the saved level applies from the first render.
|
|
1790
|
+
const applyCardContent = () => setCardContent(resolveCardContent(animationOptions).content);
|
|
1791
|
+
applyCardContent();
|
|
1792
|
+
const reportVim = (ctx: ExtensionContext, result: ReturnType<typeof resolveVimPolicy>) => {
|
|
1793
|
+
const source = result.source === "default" ? "built-in default" : `global file ${result.globalFile}`;
|
|
1794
|
+
const effective = prompt?.effectiveVimPolicy;
|
|
1795
|
+
const state = effective === undefined ? "No active Gentle prompt; preference applies when one starts" :
|
|
1796
|
+
effective !== result.policy ? `Effective prompt: ${effective}; saved preference and prompt differ; ordinary editing remains active` :
|
|
1797
|
+
result.policy === "off" ? "Prompt applies now; ordinary editing active" : "Prompt applies now";
|
|
1798
|
+
ctx.ui.notify(`vim: ${result.policy} (decided by ${source})${result.malformed ? "; malformed or unreadable file, falling back to off" : ""}. ${state}.`, result.malformed || (effective !== undefined && effective !== result.policy) ? "warning" : "info");
|
|
1799
|
+
};
|
|
1800
|
+
let visualSettings = resolveVisualSettings(animationOptions).settings;
|
|
1801
|
+
let sidebarTui: TUI | undefined;
|
|
1802
|
+
let groupedChangesOwner: (width: number) => boolean = () => false;
|
|
1803
|
+
const refreshVisual = () => {
|
|
1804
|
+
visualSettings = resolveVisualSettings(animationOptions).settings;
|
|
1805
|
+
if (currentContext && changes) showChanges(currentContext, changes.model, visualSettings.visibility.changes, (width) => groupedChangesOwner(width));
|
|
1806
|
+
if (sidebarTui?.terminal) sidebarState(sidebarTui).visibility = { todo: visualSettings.visibility.todo };
|
|
1807
|
+
renderHost?.invalidateSidebar?.();
|
|
1808
|
+
renderHost?.requestRender();
|
|
1809
|
+
};
|
|
878
1810
|
let doubleEscCancelPolicy: DoubleEscCancelPolicy = resolveDoubleEscCancelPolicy({
|
|
879
1811
|
env,
|
|
880
1812
|
gentlePiConfigHome: doubleEscCancelConfigHome,
|
|
881
1813
|
}).policy;
|
|
882
|
-
let changes:
|
|
1814
|
+
let changes: WorktreeChangesTracker | undefined;
|
|
883
1815
|
let registry: SessionWorktreeRegistry | undefined;
|
|
884
1816
|
let currentContext: ExtensionContext | undefined;
|
|
1817
|
+
let review: ReviewSidebarSnapshot | undefined;
|
|
1818
|
+
let runningJobs: RunningJobDisplay[] | undefined;
|
|
1819
|
+
const redrawReview = () => {
|
|
1820
|
+
renderHost?.invalidateSidebar?.();
|
|
1821
|
+
renderHost?.requestRender();
|
|
1822
|
+
};
|
|
1823
|
+
const unsubscribeReview = pi.events.on(REVIEW_SIDEBAR_EVENT, (value) => {
|
|
1824
|
+
const event = value as { sessionId?: unknown; snapshot?: unknown } | undefined;
|
|
1825
|
+
if (!currentContext || event?.sessionId !== currentContext.sessionManager.getSessionId()) return;
|
|
1826
|
+
if (!isReviewSidebarSnapshot(event.snapshot)) return;
|
|
1827
|
+
review = { state: event.snapshot.state, scope: event.snapshot.scope };
|
|
1828
|
+
redrawReview();
|
|
1829
|
+
});
|
|
1830
|
+
const unsubscribeJobs = pi.events.on(JOBS_SIDEBAR_EVENT, (value) => {
|
|
1831
|
+
const event = value as { sessionId?: unknown; snapshot?: unknown } | undefined;
|
|
1832
|
+
if (!currentContext || event?.sessionId !== currentContext.sessionManager.getSessionId()) return;
|
|
1833
|
+
if (!isJobsSidebarSnapshot(event.snapshot)) return;
|
|
1834
|
+
runningJobs = event.snapshot.jobs.map(({ id, label, startedAt }) => ({ id, label, startedAt }));
|
|
1835
|
+
redrawReview();
|
|
1836
|
+
});
|
|
1837
|
+
pi.on("session_tree", () => {
|
|
1838
|
+
review = undefined;
|
|
1839
|
+
redrawReview();
|
|
1840
|
+
});
|
|
885
1841
|
let shown = "";
|
|
886
1842
|
const applyChanges = (ctx: ExtensionContext, model: ChangesModel) => {
|
|
887
1843
|
const fingerprint = changesFingerprint(model);
|
|
888
1844
|
if (fingerprint === shown) return;
|
|
889
1845
|
shown = fingerprint;
|
|
890
|
-
showChanges(ctx, model);
|
|
1846
|
+
showChanges(ctx, model, visualSettings.visibility.changes, (width) => groupedChangesOwner(width));
|
|
1847
|
+
};
|
|
1848
|
+
const gitForRoot = (root: string) => deps.gitRunner(root);
|
|
1849
|
+
// Changes is the Git state of the session's roots that still exist: read
|
|
1850
|
+
// at turn end, on panel open and by the panel's poll, never at startup.
|
|
1851
|
+
const changesTracker = (ctx: ExtensionContext) => {
|
|
1852
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
1853
|
+
const roots = () => {
|
|
1854
|
+
try { return sessionChangeRoots(sessionId, ctx.sessionManager.getEntries()).filter((root) => deps.rootExists(root) && !isHomeOrBroadUserDirectory(root)); }
|
|
1855
|
+
catch { return []; }
|
|
1856
|
+
};
|
|
1857
|
+
return new WorktreeChangesTracker(gitForRoot(ctx.cwd), gitForRoot, untrackedLines, roots, fileRevision);
|
|
891
1858
|
};
|
|
892
1859
|
const refreshChanges = async (ctx: ExtensionContext) => {
|
|
893
1860
|
const tracker = changes;
|
|
894
1861
|
if (!tracker || !ctx.hasUI || registry?.sessionId !== ctx.sessionManager.getSessionId()) return;
|
|
895
|
-
tracker.restore(ctx.sessionManager.getEntries());
|
|
896
1862
|
const model = await tracker.refresh();
|
|
897
1863
|
if (changes === tracker) applyChanges(ctx, model);
|
|
898
1864
|
};
|
|
899
|
-
const unsubscribeWorktrees = pi.events.on(SESSION_CHANGE_EVENT, (data) => {
|
|
900
|
-
if (!currentContext || !registry || (data as { sessionId?: string } | undefined)?.sessionId !== registry.sessionId) return;
|
|
901
|
-
const notice = (data as { notice?: string } | undefined)?.notice;
|
|
902
|
-
if (notice) { if (changes) changes.notice = notice; currentContext.ui.notify(notice, "warning"); }
|
|
903
|
-
void refreshChanges(currentContext);
|
|
904
|
-
});
|
|
905
1865
|
pi.registerTool({
|
|
906
1866
|
name: "session_worktree_register",
|
|
907
1867
|
renderShell: "self",
|
|
908
1868
|
label: "Register session worktree",
|
|
909
|
-
description: "Register a worktree in the same Git clone for session coordination.
|
|
1869
|
+
description: "Register a worktree in the same Git clone for session coordination. /gentle:changes then also lists that worktree's uncommitted Git changes.",
|
|
910
1870
|
parameters: { type: "object", required: ["path"], additionalProperties: false, properties: { path: { type: "string", description: "Worktree path to include in this session." } } } as never,
|
|
911
1871
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
912
1872
|
if (!registry || registry.sessionId !== ctx.sessionManager.getSessionId()) throw new Error("No active session worktree registry.");
|
|
913
1873
|
const root = registry.register((params as { path: string }).path, "explicit");
|
|
914
|
-
|
|
1874
|
+
trackChangeRefresh(refreshChanges(ctx));
|
|
915
1875
|
return { content: [{ type: "text", text: `Registered session worktree: ${root}` }], details: { root, sessionId: registry.sessionId } };
|
|
916
1876
|
},
|
|
917
1877
|
});
|
|
918
1878
|
pi.on("session_start", async (_event, ctx) => {
|
|
1879
|
+
closeCustomize?.();
|
|
1880
|
+
runningJobs = undefined;
|
|
1881
|
+
adaptiveReasoningNotices.clear();
|
|
1882
|
+
// A restored or default session never emits model_select, so the startup model
|
|
1883
|
+
// announces itself here when it manages its own depth.
|
|
1884
|
+
announceAdaptiveReasoning(ctx, ctx.model);
|
|
1885
|
+
if (review) {
|
|
1886
|
+
review = undefined;
|
|
1887
|
+
redrawReview();
|
|
1888
|
+
}
|
|
1889
|
+
stopProfilePoll();
|
|
919
1890
|
registry?.close();
|
|
920
1891
|
currentContext = ctx;
|
|
921
1892
|
changes = undefined;
|
|
922
1893
|
registry = new SessionWorktreeRegistry(pi, ctx.sessionManager, ctx.cwd, deps.resolveWorktree);
|
|
923
1894
|
registry.start();
|
|
1895
|
+
applyCardStyle();
|
|
1896
|
+
applyCardContent();
|
|
924
1897
|
if (!ctx.hasUI) return;
|
|
925
|
-
|
|
1898
|
+
visualSettings = resolveVisualSettings(animationOptions).settings;
|
|
1899
|
+
if (!overrides.activeProfile) {
|
|
1900
|
+
profileReader.bind(ctx.cwd, deps.resolveWorktree, ctx.sessionManager.getSessionId());
|
|
1901
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
1902
|
+
profilePoll = setInterval(() => {
|
|
1903
|
+
if (currentContext !== ctx || ctx.sessionManager.getSessionId() !== sessionId) return;
|
|
1904
|
+
if (!profileReader.refresh()) return;
|
|
1905
|
+
renderHost?.invalidateSidebar?.();
|
|
1906
|
+
renderHost?.requestRender();
|
|
1907
|
+
}, 2_000);
|
|
1908
|
+
profilePoll.unref?.();
|
|
1909
|
+
}
|
|
1910
|
+
changes = changesTracker(ctx);
|
|
926
1911
|
const tracker = changes;
|
|
927
1912
|
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
1913
|
+
sidebarTui = tui;
|
|
1914
|
+
if (tui.terminal) sidebarState(tui).visibility = { todo: visualSettings.visibility.todo };
|
|
928
1915
|
renderHost = { requestRender: () => tui.requestRender(), invalidateSidebar: () => invalidateSidebar(tui) };
|
|
929
|
-
const bottom = createShellBarComponent(pi, ctx, renderHost, theme, footerData, () => tracker.model.files.length, () => usage.get(ctx.model?.provider ?? ""));
|
|
1916
|
+
const bottom = createShellBarComponent(pi, ctx, renderHost, theme, footerData, () => tracker.model.files.length, () => usage.get(ctx.model?.provider ?? ""), () => visualSettings);
|
|
930
1917
|
// The Status card paints live session state that no event re-registers a
|
|
931
1918
|
// part for: model, effort, context, cost, session name and extension
|
|
932
1919
|
// statuses. The digest is what keeps the fullscreen memo honest, and it
|
|
933
1920
|
// rebuilds the model exactly as the narrow bottom bar does every frame.
|
|
934
1921
|
const footerModel = (): ShellBarModel => ({
|
|
935
|
-
...buildShellBarModel(pi, ctx, footerData, { dirty: tracker.model.files.length, usage: usage.get(ctx.model?.provider ?? ""), profile: deps.activeProfile() }),
|
|
936
|
-
changes: { files: tracker.model.files.length, added: tracker.model.added, deleted: tracker.model.deleted
|
|
1922
|
+
...buildShellBarModel(pi, ctx, footerData, { dirty: tracker.model.files.length, usage: usage.get(ctx.model?.provider ?? ""), profile: deps.activeProfile(), jobs: runningJobs }),
|
|
1923
|
+
changes: { files: tracker.model.files.length, added: tracker.model.added, deleted: tracker.model.deleted },
|
|
1924
|
+
review,
|
|
937
1925
|
});
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
1926
|
+
// At narrow fullscreen widths only one status row paints: a top header
|
|
1927
|
+
// suppresses the bottom bar in the layout, and otherwise the bottom bar
|
|
1928
|
+
// takes over the header's data while the below-input header steps aside.
|
|
1929
|
+
const statusOwner = () => narrowStatusOwner({ mode: (tui as TUI & { mode?: string }).mode, columns: tui.terminal?.columns ?? 0, statusPlacement: visualSettings.statusPlacement, headerPlacement: visualSettings.headerPlacement });
|
|
1930
|
+
const statusCardVisible = () => Boolean(tui.terminal && tui.terminal.columns >= SIDEBAR_BREAKPOINT && sidebarState(tui).ownsHost?.() && visualSettings.statusPlacement !== "hidden");
|
|
1931
|
+
const belowFloat = (width: number, statuses: boolean) => (tui as TUI & { mode?: string }).mode === "fullscreen"
|
|
1932
|
+
? renderShellBelowInputFloat({ ...footerModel(), ...(statuses ? {} : { statuses: [] }), ...(statusCardVisible() ? { jobs: undefined } : {}) }, theme, width, usageShortcutKey, visualSettings, tracker.model)
|
|
1933
|
+
: undefined;
|
|
1934
|
+
// Match sidebarPart's live paint ownership, including an unavailable rail.
|
|
1935
|
+
const footerSuppressed = () => {
|
|
1936
|
+
if (!tui.terminal) return false;
|
|
1937
|
+
const state = sidebarState(tui);
|
|
1938
|
+
return Boolean(state.statusHidden?.() || state.headerOwnsStatus?.() || (state.active && state.ownsHost?.()));
|
|
1939
|
+
};
|
|
1940
|
+
groupedChangesOwner = (width) => belowFloat(width, !footerSuppressed()) !== undefined;
|
|
1941
|
+
const bottomBar = {
|
|
1942
|
+
...bottom,
|
|
1943
|
+
render: (width: number) => belowFloat(width, true)?.rows ?? (statusOwner() === STATUS_OWNER.BOTTOM ? renderShellBottomOnlyBar(footerModel(), theme, width, usageShortcutKey, visualSettings) : bottom.render(width)),
|
|
1944
|
+
handleMouse(event: Parameters<NonNullable<SidebarRail["handleMouse"]>>[0]) {
|
|
1945
|
+
if (footerSuppressed() || event.type !== "click" || event.button !== "left") return undefined;
|
|
1946
|
+
const chrome = belowFloat(event.width, true);
|
|
1947
|
+
if (!chrome || !shellHeaderUsageHit(chrome, event.x, event.y)) return undefined;
|
|
1948
|
+
void openUsage(ctx);
|
|
1949
|
+
return { handled: true, render: true };
|
|
1950
|
+
},
|
|
1951
|
+
};
|
|
1952
|
+
const part = sidebarPart(tui, "footer", bottomBar, {
|
|
1953
|
+
digest: () => JSON.stringify([footerModel(), tracker.model, visualSettings, cardStyle()]),
|
|
1954
|
+
render: (width) => renderShellSidebarBar(footerModel(), theme, width, visualSettings),
|
|
941
1955
|
invalidate() {},
|
|
942
1956
|
});
|
|
943
1957
|
// The header row carries everything that ticks every frame (model,
|
|
944
|
-
// effort, context, cost, usage) plus session identity
|
|
945
|
-
//
|
|
946
|
-
const headerBar = (width: number) =>
|
|
1958
|
+
// effort, context, cost, usage) plus session identity and the jobs count
|
|
1959
|
+
// when Status is absent. Opaque statuses and working state stay excluded.
|
|
1960
|
+
const headerBar = (width: number) => renderShellHeaderChrome(buildShellHeaderModel(footerModel(), !statusCardVisible() && (statusOwner() === STATUS_OWNER.HEADER || visualSettings.statusPlacement === "hidden")), theme, width, usageShortcutKey, visualSettings);
|
|
947
1961
|
const disposeHeader = sidebarHeader(tui, {
|
|
948
|
-
digest: () => JSON.stringify(
|
|
949
|
-
render: (width) =>
|
|
1962
|
+
digest: () => JSON.stringify([footerModel(), tracker.model, visualSettings, cardStyle()]),
|
|
1963
|
+
render: (width) => headerBar(width).rows,
|
|
950
1964
|
invalidate() {},
|
|
951
1965
|
handleMouse(event) {
|
|
952
1966
|
if (event.type !== "click" || event.button !== "left") return undefined;
|
|
953
|
-
if (event.
|
|
954
|
-
const { usageSpan } = headerBar(event.width);
|
|
955
|
-
if (!usageSpan || event.x < usageSpan.start || event.x >= usageSpan.end) return undefined;
|
|
1967
|
+
if (!shellHeaderUsageHit(headerBar(event.width), event.x, event.y)) return undefined;
|
|
956
1968
|
void openUsage(ctx);
|
|
957
1969
|
return { handled: true, render: true };
|
|
958
1970
|
},
|
|
959
1971
|
});
|
|
960
|
-
const uninstall = installSidebar(tui, theme);
|
|
961
|
-
|
|
1972
|
+
const uninstall = installSidebar(tui, theme, () => visualSettings.statusPlacement, () => visualSettings.headerPlacement, () => visualSettings.density);
|
|
1973
|
+
// The public widget slot follows the editor even when the rail is absent.
|
|
1974
|
+
const belowHeader = () => visualSettings.headerPlacement === "below-input" && (tui as TUI & { mode?: string }).mode === "fullscreen" && statusOwner() !== STATUS_OWNER.BOTTOM;
|
|
1975
|
+
const widgetChrome = (width: number) => belowFloat(width, false) ?? headerBar(width);
|
|
1976
|
+
ctx.ui.setWidget(HEADER_WIDGET_KEY, () => ({
|
|
1977
|
+
render(width: number) {
|
|
1978
|
+
if (belowFloat(width, false)) return footerSuppressed() ? widgetChrome(width).rows : [];
|
|
1979
|
+
return belowHeader() ? headerBar(width).rows : [];
|
|
1980
|
+
},
|
|
1981
|
+
invalidate() {},
|
|
1982
|
+
handleMouse(event) {
|
|
1983
|
+
const grouped = belowFloat(event.width, false);
|
|
1984
|
+
if ((grouped ? !footerSuppressed() : !belowHeader()) || event.type !== "click" || event.button !== "left") return undefined;
|
|
1985
|
+
if (!shellHeaderUsageHit(widgetChrome(event.width), event.x, event.y)) return undefined;
|
|
1986
|
+
void openUsage(ctx);
|
|
1987
|
+
return { handled: true, render: true };
|
|
1988
|
+
},
|
|
1989
|
+
}), { placement: "belowEditor" });
|
|
1990
|
+
return { ...part,
|
|
1991
|
+
render(width: number) {
|
|
1992
|
+
if (visualSettings.statusPlacement === "hidden" && (tui as TUI & { mode?: string }).mode !== "fullscreen" && runningJobs?.length) {
|
|
1993
|
+
return [truncateToWidth(theme.fg("muted", shellJobsCount(runningJobs.length)), width, "…")];
|
|
1994
|
+
}
|
|
1995
|
+
const rows = part.render(width);
|
|
1996
|
+
// Preserve a shrinkable exterior dock row, outside prompt/completion geometry.
|
|
1997
|
+
return rows.length === 0 && (tui as TUI & { mode?: string }).mode === "fullscreen"
|
|
1998
|
+
&& resolvePromptLayout(width, cardStyle(), theme.bg?.bind(theme)).background ? [""] : rows;
|
|
1999
|
+
},
|
|
2000
|
+
dispose() { ctx.ui.setWidget(HEADER_WIDGET_KEY, undefined); disposeHeader(); uninstall(); part.dispose(); if (sidebarTui === tui) { sidebarTui = undefined; groupedChangesOwner = () => false; } } };
|
|
962
2001
|
});
|
|
963
2002
|
void refreshUsage(ctx, true);
|
|
964
2003
|
const ownsPrompt = installPrompt(
|
|
@@ -967,17 +2006,19 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
967
2006
|
prompt?.dispose();
|
|
968
2007
|
prompt = created;
|
|
969
2008
|
prompt.setAnimationPolicy(animationPolicy);
|
|
2009
|
+
prompt.setVimPolicy(vimPolicy);
|
|
970
2010
|
},
|
|
971
|
-
{ now: () => deps.now(), doubleEscCancelEnabled: () => doubleEscCancelPolicy === "on", dispatchQueuedText: (text) => { pendingQueuedText = pendingQueuedText === undefined ? text : `${pendingQueuedText}\n\n${text}`; } },
|
|
2011
|
+
{ now: () => deps.now(), doubleEscCancelEnabled: () => doubleEscCancelPolicy === "on", dispatchQueuedText: (text) => { pendingQueuedText = pendingQueuedText === undefined ? text : `${pendingQueuedText}\n\n${text}`; }, vimRuntimeVersion: deps.vimRuntimeVersion },
|
|
972
2012
|
);
|
|
973
2013
|
// Hide native feedback only when our petal replaces it. Native transcript
|
|
974
2014
|
// thinking blocks remain Pi-owned; this changes only the supported loader UI.
|
|
975
|
-
|
|
2015
|
+
// Inside Herdr the row stays: its native Pi detection reads it.
|
|
2016
|
+
if (ownsPrompt && !keepNativeWorkingRow(env)) ctx.ui.setWorkingVisible(false);
|
|
976
2017
|
const notice = deps.devBinary();
|
|
977
2018
|
ctx.ui.setWidget(
|
|
978
2019
|
DEV_BINARY_WIDGET_KEY,
|
|
979
2020
|
notice
|
|
980
|
-
? (_tui, theme) => spaced(cardComponent(devBinaryCard(notice), theme, { expanded: true }))
|
|
2021
|
+
? (_tui, theme) => spaced(cardComponent(devBinaryCard(notice), theme, { expanded: true, previewRows: 3 }))
|
|
981
2022
|
: undefined,
|
|
982
2023
|
);
|
|
983
2024
|
if (changes !== tracker) return;
|
|
@@ -985,6 +2026,19 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
985
2026
|
applyChanges(ctx, tracker.model);
|
|
986
2027
|
});
|
|
987
2028
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
2029
|
+
closeCustomize?.();
|
|
2030
|
+
if (review) {
|
|
2031
|
+
review = undefined;
|
|
2032
|
+
redrawReview();
|
|
2033
|
+
}
|
|
2034
|
+
// Pi rebuilds the extension runtime after every shutdown (reload, replacement,
|
|
2035
|
+
// fork, quit), so the factory-level subscription never needs to be restored.
|
|
2036
|
+
unsubscribeReview();
|
|
2037
|
+
unsubscribeJobs();
|
|
2038
|
+
runningJobs = undefined;
|
|
2039
|
+
stopProfilePoll();
|
|
2040
|
+
oddPhaseRegistry.clear(ctx.sessionManager.getSessionId());
|
|
2041
|
+
oddPhaseRegistry.clearRenderRequest(ctx.sessionManager.getSessionId());
|
|
988
2042
|
pendingQueuedText = undefined;
|
|
989
2043
|
prompt?.dispose();
|
|
990
2044
|
prompt = undefined;
|
|
@@ -996,26 +2050,33 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
996
2050
|
registry = undefined;
|
|
997
2051
|
changes = undefined;
|
|
998
2052
|
currentContext = undefined;
|
|
999
|
-
unsubscribeWorktrees();
|
|
1000
2053
|
});
|
|
1001
2054
|
const openChanges = async (ctx: ExtensionContext) => {
|
|
1002
2055
|
if (!changes) return;
|
|
1003
2056
|
const tracker = changes;
|
|
1004
2057
|
const model = await tracker.refresh();
|
|
2058
|
+
if (changes !== tracker) return;
|
|
2059
|
+
applyChanges(ctx, model);
|
|
2060
|
+
// A failed root scan is unknown, never clean: say so whenever one failed.
|
|
2061
|
+
const failed = tracker.failedRoots.length;
|
|
2062
|
+
const roots = tracker.scannedRootCount;
|
|
1005
2063
|
if (model.files.length === 0) {
|
|
1006
|
-
ctx.ui.notify(
|
|
2064
|
+
if (failed > 0 && failed === roots) ctx.ui.notify(`Could not read Git status for ${failed} ${failed === 1 ? "root" : "roots"}; uncommitted changes are unknown.`, "error");
|
|
2065
|
+
else if (failed > 0) ctx.ui.notify(`No uncommitted changes in the roots Git could read; Git status failed for ${failed} of ${roots} roots.`, "warning");
|
|
2066
|
+
else ctx.ui.notify("No uncommitted changes in this session's repository or its subagents' worktrees.", "info");
|
|
1007
2067
|
return;
|
|
1008
2068
|
}
|
|
1009
|
-
|
|
2069
|
+
if (failed > 0) ctx.ui.notify(`Git status failed for ${failed} ${failed === 1 ? "root" : "roots"}; ${failed === 1 ? "its" : "their"} changes are not listed.`, "warning");
|
|
2070
|
+
await showChangesOverlay(ctx, { loadDiff: (root, file) => loadFileDiff(deps.gitRunner(root, DIFF_MAX_BYTES), file), worktrees: () => tracker.worktrees, refresh: () => tracker.refresh(), apply: applyChanges, pollMs: changesPollMs(env) });
|
|
1010
2071
|
};
|
|
1011
2072
|
pi.registerCommand(CHANGES_COMMAND_NAME, {
|
|
1012
|
-
description: "Browse
|
|
2073
|
+
description: "Browse uncommitted Git changes (working tree vs HEAD, untracked included) in this session's repository and its subagents' worktrees. Press o to open $EDITOR.",
|
|
1013
2074
|
handler: async (_args, ctx) => openChanges(ctx),
|
|
1014
2075
|
});
|
|
1015
2076
|
const shortcut = changesShortcut(env);
|
|
1016
2077
|
if (shortcut) {
|
|
1017
2078
|
pi.registerShortcut(shortcut as Parameters<ExtensionAPI["registerShortcut"]>[0], {
|
|
1018
|
-
description: "Open
|
|
2079
|
+
description: "Open the session's uncommitted Git changes",
|
|
1019
2080
|
handler: async (ctx) => openChanges(ctx),
|
|
1020
2081
|
});
|
|
1021
2082
|
}
|
|
@@ -1030,6 +2091,397 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
1030
2091
|
handler: async (ctx) => showCommandPalette(pi, ctx, env),
|
|
1031
2092
|
});
|
|
1032
2093
|
}
|
|
2094
|
+
pi.registerCommand("gentle:customize", {
|
|
2095
|
+
description: "Configure appearance, audio notifications, Vim editing, session permissions and global Auto/YOLO defaults, and prompt history capture.",
|
|
2096
|
+
handler: async (_args, ctx) => {
|
|
2097
|
+
if (ctx.mode !== "tui" || !ctx.hasUI) {
|
|
2098
|
+
if (ctx.hasUI) ctx.ui.notify("Customization requires an interactive terminal.", "warning");
|
|
2099
|
+
return;
|
|
2100
|
+
}
|
|
2101
|
+
closeCustomize?.();
|
|
2102
|
+
let closed = false;
|
|
2103
|
+
let adapter: YoloUiAdapter | undefined;
|
|
2104
|
+
let unobserve: (() => void) | undefined;
|
|
2105
|
+
let finish: (() => void) | undefined;
|
|
2106
|
+
let customizeView: VisualCustomizeView | undefined;
|
|
2107
|
+
const close = () => {
|
|
2108
|
+
if (closed) return;
|
|
2109
|
+
closed = true;
|
|
2110
|
+
customizeView?.dispose();
|
|
2111
|
+
unobserve?.(); adapter?.dispose(); finish?.();
|
|
2112
|
+
if (closeCustomize === close) closeCustomize = undefined;
|
|
2113
|
+
};
|
|
2114
|
+
closeCustomize = close;
|
|
2115
|
+
const home = { gentlePiConfigHome: doubleEscCancelConfigHome };
|
|
2116
|
+
const rows: CustomizeRow[] = [];
|
|
2117
|
+
const bannerHome = doubleEscCancelConfigHome;
|
|
2118
|
+
let banner = await readBannerConfig(bannerHome).catch(error => { close(); throw error; });
|
|
2119
|
+
if (closed) return;
|
|
2120
|
+
let activeTheme = ctx.ui.theme.name;
|
|
2121
|
+
let requestCustomizeRender: (() => void) | undefined;
|
|
2122
|
+
let category: CustomizeCategory = "Animations";
|
|
2123
|
+
const add = (label: CustomizeRow["label"], notice: string, action: () => void | false | Promise<void | false>, preview?: CustomizeRow["preview"]) => rows.push({ category, label, preview, action: async () => {
|
|
2124
|
+
if (await action() === false) return;
|
|
2125
|
+
ctx.ui.notify(notice, "info");
|
|
2126
|
+
} });
|
|
2127
|
+
for (const policy of ["quality", "performance", "potato"] as const) add(
|
|
2128
|
+
() => `Animations: ${policy}${resolveAnimationPolicy(animationOptions).policy === policy ? " (current)" : ""}`,
|
|
2129
|
+
"Animation saved. Prompt applies now; startup banner applies at next startup.",
|
|
2130
|
+
() => {
|
|
2131
|
+
writeAnimationPolicy(policy, animationOptions);
|
|
2132
|
+
animationPolicy = resolveAnimationPolicy(animationOptions).policy;
|
|
2133
|
+
prompt?.setAnimationPolicy(animationPolicy);
|
|
2134
|
+
},
|
|
2135
|
+
() => ({ title: `${policy} · static animation sample`, sample: policy === "potato" ? "✿ idle → working (no pulse)" : policy === "performance" ? "✿ short pulse → settle (static)" : "✿ gentle wave → settle (static)" }),
|
|
2136
|
+
);
|
|
2137
|
+
category = "Banner";
|
|
2138
|
+
const bannerColors = { pink: [255, 118, 195], cyan: [95, 210, 255], yellow: [255, 210, 95], green: [110, 220, 145] } as const;
|
|
2139
|
+
const bannerPreview = (next: typeof banner) => {
|
|
2140
|
+
const [r, g, b] = bannerColors[next.color];
|
|
2141
|
+
return { title: `Banner · ${next.color} (static)`, sample: `${next.showRose ? `\x1b[38;2;${r};${g};${b}m🌹\x1b[0m` : "·"} ${next.showTextLogo ? "GENTLE SHELL" : "(logo hidden)"}` };
|
|
2142
|
+
};
|
|
2143
|
+
add(() => `Banner rose: ${banner.showRose ? "on" : "off"}`, "Banner saved; applies at next startup.", async () => {
|
|
2144
|
+
const next = { ...await readBannerConfigForEdit(bannerHome) };
|
|
2145
|
+
next.showRose = !next.showRose;
|
|
2146
|
+
await writeBannerConfig(next, bannerHome);
|
|
2147
|
+
banner = next;
|
|
2148
|
+
}, () => bannerPreview({ ...banner, showRose: !banner.showRose }));
|
|
2149
|
+
add(() => `Banner text logo: ${banner.showTextLogo ? "on" : "off"}`, "Banner saved; applies at next startup.", async () => {
|
|
2150
|
+
const next = { ...await readBannerConfigForEdit(bannerHome) };
|
|
2151
|
+
next.showTextLogo = !next.showTextLogo;
|
|
2152
|
+
await writeBannerConfig(next, bannerHome);
|
|
2153
|
+
banner = next;
|
|
2154
|
+
}, () => bannerPreview({ ...banner, showTextLogo: !banner.showTextLogo }));
|
|
2155
|
+
for (const color of BANNER_COLORS) add(
|
|
2156
|
+
() => `Banner color: ${color}${banner.color === color ? " (current)" : ""}`,
|
|
2157
|
+
"Banner saved; applies at next startup.",
|
|
2158
|
+
async () => { const next = { ...await readBannerConfigForEdit(bannerHome), color }; await writeBannerConfig(next, bannerHome); banner = next; },
|
|
2159
|
+
() => bannerPreview({ ...banner, color }),
|
|
2160
|
+
);
|
|
2161
|
+
category = "Themes";
|
|
2162
|
+
try {
|
|
2163
|
+
if (typeof ctx.ui.getAllThemes !== "function" || typeof ctx.ui.getTheme !== "function" || typeof ctx.ui.setTheme !== "function") throw new Error("theme API unavailable");
|
|
2164
|
+
const themes = ctx.ui.getAllThemes();
|
|
2165
|
+
if (!Array.isArray(themes)) throw new Error("invalid theme list");
|
|
2166
|
+
const names = [...new Set(themes.map((item) => item?.name).filter((name): name is string => typeof name === "string" && !!name && !name.includes("/")))];
|
|
2167
|
+
if (names.length === 0) throw new Error("no installed themes");
|
|
2168
|
+
for (const name of names) rows.push({
|
|
2169
|
+
category,
|
|
2170
|
+
label: () => `Theme: ${name}${activeTheme === name ? " (current)" : ""}`,
|
|
2171
|
+
preview: () => {
|
|
2172
|
+
const selected = ctx.ui.getTheme(name);
|
|
2173
|
+
if (selected?.name !== name) throw new Error("Selected theme is unavailable.");
|
|
2174
|
+
const source = selected.sourcePath ?? ctx.ui.getAllThemes().find((item) => item.name === name)?.path;
|
|
2175
|
+
return sourcePalettePreview(name, source);
|
|
2176
|
+
},
|
|
2177
|
+
action: () => {
|
|
2178
|
+
if (!ctx.ui.getTheme(name)) throw new Error(`Theme ${name} is unavailable or invalid.`);
|
|
2179
|
+
const result = ctx.ui.setTheme(name);
|
|
2180
|
+
if (!result.success) throw new Error(result.error ?? `Could not activate theme ${name}.`);
|
|
2181
|
+
activeTheme = name;
|
|
2182
|
+
ctx.ui.notify("Theme applies now and is saved by Pi.", "info");
|
|
2183
|
+
},
|
|
2184
|
+
});
|
|
2185
|
+
} catch {
|
|
2186
|
+
rows.push({ category, label: "Themes unavailable; use Pi /settings", preview: () => ({ title: "Themes unavailable", sample: "Use Pi /settings to select a theme" }), action: () => ctx.ui.notify("Installed themes are unavailable in this session.", "warning") });
|
|
2187
|
+
}
|
|
2188
|
+
const updateVisual = (change: (settings: ReturnType<typeof resolveVisualSettings>["settings"]) => ReturnType<typeof resolveVisualSettings>["settings"]) => {
|
|
2189
|
+
const current = resolveVisualSettings(home);
|
|
2190
|
+
if (current.malformed || current.readError) throw new Error(`Cannot update unreadable or malformed visual settings: ${current.globalFile}`);
|
|
2191
|
+
writeVisualSettings(change(current.settings), home);
|
|
2192
|
+
refreshVisual();
|
|
2193
|
+
pi.events.emit(VISUAL_SETTINGS_CHANGED, { configHome: doubleEscCancelConfigHome });
|
|
2194
|
+
};
|
|
2195
|
+
const visual = () => resolveVisualSettings(home).settings;
|
|
2196
|
+
const pending = "Preference saved and applied.";
|
|
2197
|
+
const layoutPreview = (settings: ReturnType<typeof visual>) => ({
|
|
2198
|
+
title: `Layout · ${settings.density} (schematic)`,
|
|
2199
|
+
sample: `${settings.headerPlacement === "top" ? "[Header] → [Input]" : "[Input] → [Header]"} ${settings.statusPlacement === "auto" ? "[responsive status]" : settings.statusPlacement === "right" ? "[Right rail, wide]" : settings.statusPlacement === "hidden" ? "[No status bar or rail]" : "[Bottom status]"}`,
|
|
2200
|
+
});
|
|
2201
|
+
category = "Editor";
|
|
2202
|
+
for (const [label, policy] of [["enable", "on"], ["disable", "off"]] as const) rows.push({
|
|
2203
|
+
category,
|
|
2204
|
+
label: () => `Vim: ${label}${resolveVimPolicy(animationOptions).policy === policy ? " (current preference)" : ""}`,
|
|
2205
|
+
preview: () => {
|
|
2206
|
+
const result = resolveVimPolicy(animationOptions);
|
|
2207
|
+
const effective = prompt?.effectiveVimPolicy ?? "no active prompt";
|
|
2208
|
+
return { title: "Vim · global prompt editing", sample: `preference: ${result.policy} · effective: ${effective}${result.malformed ? " · malformed or unreadable file" : result.policy !== effective && effective !== "no active prompt" ? " · preference and prompt differ" : ""}` };
|
|
2209
|
+
},
|
|
2210
|
+
action: () => {
|
|
2211
|
+
const current = resolveVimPolicy(animationOptions);
|
|
2212
|
+
if (current.malformed) throw new Error(`Cannot update malformed or unreadable Vim policy: ${current.globalFile}`);
|
|
2213
|
+
writeVimPolicy(policy, animationOptions);
|
|
2214
|
+
const result = resolveVimPolicy(animationOptions);
|
|
2215
|
+
vimPolicy = result.policy;
|
|
2216
|
+
prompt?.setVimPolicy(vimPolicy);
|
|
2217
|
+
reportVim(ctx, result);
|
|
2218
|
+
},
|
|
2219
|
+
});
|
|
2220
|
+
let yoloDisplay: YoloDisplay = YOLO_DISPLAY.unavailable;
|
|
2221
|
+
let permissionDisplay = "normal", permissionDefault = "normal";
|
|
2222
|
+
let requestYoloRender = () => {};
|
|
2223
|
+
let refreshing: Promise<void> | undefined;
|
|
2224
|
+
let refreshAgain = false;
|
|
2225
|
+
// One in-flight read and one dirty bit coalesce slash/action/revocation
|
|
2226
|
+
// changes. Labels and previews only read the authority-free snapshot.
|
|
2227
|
+
const refreshYolo = (): Promise<void> => {
|
|
2228
|
+
refreshAgain = true;
|
|
2229
|
+
if (refreshing) return refreshing;
|
|
2230
|
+
refreshing = (async () => {
|
|
2231
|
+
while (refreshAgain && !closed) {
|
|
2232
|
+
refreshAgain = false;
|
|
2233
|
+
const snapshot = await adapter?.readSnapshot?.().catch(() => undefined);
|
|
2234
|
+
const next = snapshot?.yolo ?? await adapter?.read().catch(() => YOLO_DISPLAY.unavailable) ?? YOLO_DISPLAY.unavailable;
|
|
2235
|
+
if (closed) return;
|
|
2236
|
+
const nextMode = snapshot?.mode ?? "normal";
|
|
2237
|
+
const nextDefault = snapshot?.defaultMode ?? "normal";
|
|
2238
|
+
if (closed) return;
|
|
2239
|
+
if (refreshAgain) continue;
|
|
2240
|
+
yoloDisplay = next;
|
|
2241
|
+
permissionDisplay = nextMode;
|
|
2242
|
+
permissionDefault = nextDefault;
|
|
2243
|
+
requestYoloRender();
|
|
2244
|
+
}
|
|
2245
|
+
})().finally(() => { refreshing = undefined; });
|
|
2246
|
+
return refreshing;
|
|
2247
|
+
};
|
|
2248
|
+
rows.push({
|
|
2249
|
+
category,
|
|
2250
|
+
label: () => `YOLO: ${yoloDisplay} · session only`,
|
|
2251
|
+
preview: () => ({ title: "YOLO · session permission", sample: "ordinary scoped commits/push/PR · destructive confirmations remain · review consent unchanged · reset on reload" }),
|
|
2252
|
+
action: async () => {
|
|
2253
|
+
if (closed) return;
|
|
2254
|
+
if (!adapter || yoloDisplay === YOLO_DISPLAY.unavailable) {
|
|
2255
|
+
ctx.ui.notify("YOLO is unavailable in this live primary session.", "warning");
|
|
2256
|
+
return;
|
|
2257
|
+
}
|
|
2258
|
+
await adapter.toggle();
|
|
2259
|
+
await refreshYolo();
|
|
2260
|
+
},
|
|
2261
|
+
});
|
|
2262
|
+
for (const mode of ["normal", "auto", "yolo"] as const) rows.push({
|
|
2263
|
+
category,
|
|
2264
|
+
label: () => `Session permissions: ${mode.toUpperCase()}${permissionDisplay === mode ? " (current)" : ""}`,
|
|
2265
|
+
preview: () => ({ title: "Permissions · current session", sample: mode === "auto"
|
|
2266
|
+
? "independent session-model action checks · extra inference cost · hard safety and native consent remain"
|
|
2267
|
+
: mode === "yolo" ? "ordinary scoped delivery permission · destructive confirmations remain" : "normal checks · overrides an elevated global default" }),
|
|
2268
|
+
action: async () => {
|
|
2269
|
+
if (closed) return;
|
|
2270
|
+
if (!adapter?.setMode || !await adapter.setMode(mode)) ctx.ui.notify("Session mode unchanged — live primary UI unavailable or changed.", "warning");
|
|
2271
|
+
await refreshYolo();
|
|
2272
|
+
},
|
|
2273
|
+
});
|
|
2274
|
+
for (const mode of ["normal", "auto", "yolo"] as const) rows.push({
|
|
2275
|
+
category,
|
|
2276
|
+
label: () => `Default permissions: ${mode.toUpperCase()}${permissionDefault === mode ? " (saved)" : ""}`,
|
|
2277
|
+
preview: () => ({ title: "Permissions · user-global default", sample: `New primary sessions start in ${mode.toUpperCase()}; current session stays unchanged. ${mode === "auto" ? "Additional classifier inference uses the session model; usage is separate." : "Independent safety and consent remain."}` }),
|
|
2278
|
+
action: async () => {
|
|
2279
|
+
if (closed) return;
|
|
2280
|
+
if (!adapter?.saveDefault || !await adapter.saveDefault(mode)) ctx.ui.notify("Default unchanged — trusted UI unavailable or preference could not be saved.", "warning");
|
|
2281
|
+
await refreshYolo();
|
|
2282
|
+
},
|
|
2283
|
+
});
|
|
2284
|
+
category = "History";
|
|
2285
|
+
// The prompt-history extension re-reads this preference per prompt, so a
|
|
2286
|
+
// change applies without restart. An explicit GENTLE_PI_HISTORY_CAPTURE
|
|
2287
|
+
// value wins; the rows say so instead of silently ignoring the choice.
|
|
2288
|
+
const historyCapture = () => resolveHistoryCapture({ env, gentlePiConfigHome: doubleEscCancelConfigHome });
|
|
2289
|
+
for (const [label, policy] of [["enable", "on"], ["disable", "off"]] as const) rows.push({
|
|
2290
|
+
category,
|
|
2291
|
+
label: () => {
|
|
2292
|
+
const result = historyCapture();
|
|
2293
|
+
return `Prompt history capture: ${label}${result.preference === policy && !result.malformed ? " (current)" : ""}${result.envOverride ? " · env override" : ""}`;
|
|
2294
|
+
},
|
|
2295
|
+
preview: () => {
|
|
2296
|
+
const result = historyCapture();
|
|
2297
|
+
const effective = result.enabled ? "on" : "off";
|
|
2298
|
+
return { title: "Prompt history capture · Customize preference", sample: `preference: ${result.preference} · effective: ${effective}${result.malformed ? " · malformed or unreadable file" : ""}${result.envOverride ? " · GENTLE_PI_HISTORY_CAPTURE overrides this preference" : ""}` };
|
|
2299
|
+
},
|
|
2300
|
+
action: () => {
|
|
2301
|
+
const current = historyCapture();
|
|
2302
|
+
if (current.malformed) throw new Error(`Cannot update malformed or unreadable history capture preference: ${current.globalFile}`);
|
|
2303
|
+
writeHistoryCapturePolicy(policy, { gentlePiConfigHome: doubleEscCancelConfigHome });
|
|
2304
|
+
const result = historyCapture();
|
|
2305
|
+
if (result.envOverride) ctx.ui.notify(`Prompt history capture preference saved: ${result.preference}. GENTLE_PI_HISTORY_CAPTURE=${result.envOverride} overrides it; capture stays ${result.envOverride}.`, "warning");
|
|
2306
|
+
else ctx.ui.notify(result.enabled ? "Prompt history capture: on. Applies from the next prompt; stored history is kept." : "Prompt history capture: off. New prompts are not recorded; stored history is kept.", "info");
|
|
2307
|
+
},
|
|
2308
|
+
});
|
|
2309
|
+
rows.push(...buildNotificationRows(ctx));
|
|
2310
|
+
category = "Layout";
|
|
2311
|
+
for (const value of Object.values(STATUS_PLACEMENT)) add(() => `Status placement: ${value}${visual().statusPlacement === value ? " (current)" : ""}`, pending, () => updateVisual((settings) => ({ ...settings, statusPlacement: value })), () => layoutPreview({ ...visual(), statusPlacement: value }));
|
|
2312
|
+
for (const value of Object.values(HEADER_PLACEMENT)) add(() => `Header placement: ${value}${visual().headerPlacement === value ? " (current)" : ""}`, pending, () => updateVisual((settings) => ({ ...settings, headerPlacement: value })), () => layoutPreview({ ...visual(), headerPlacement: value }));
|
|
2313
|
+
for (const value of Object.values(DENSITY)) add(() => `Density: ${value}${visual().density === value ? " (current)" : ""}`, pending, () => updateVisual((settings) => ({ ...settings, density: value })), () => layoutPreview({ ...visual(), density: value }));
|
|
2314
|
+
category = "Cards";
|
|
2315
|
+
// One live style for conversation cards, panels, header/footer and prompt.
|
|
2316
|
+
const cardStylePreview = { [CARD_STYLE.NEON]: "╭─ ✿ read ─╮ outlined chrome + prompt", [CARD_STYLE.FLOAT]: "▎ ✿ read painted chrome + prompt" };
|
|
2317
|
+
for (const style of Object.values(CARD_STYLE)) add(
|
|
2318
|
+
() => {
|
|
2319
|
+
const current = resolveCardStyle(home);
|
|
2320
|
+
return `Card style: ${style}${current.style === style && !current.malformed ? " (current)" : ""}`;
|
|
2321
|
+
},
|
|
2322
|
+
`Card style: ${style}. Conversation cards and shell chrome redraw now.`,
|
|
2323
|
+
() => {
|
|
2324
|
+
writeCardStyle(style, home);
|
|
2325
|
+
setCardStyle(style);
|
|
2326
|
+
renderHost?.invalidateSidebar?.();
|
|
2327
|
+
renderHost?.requestRender();
|
|
2328
|
+
requestCustomizeRender?.();
|
|
2329
|
+
},
|
|
2330
|
+
() => ({ title: `Cards · ${style}`, sample: `${cardStylePreview[style]}${resolveCardStyle(home).malformed ? " · malformed or unreadable file" : ""}` }),
|
|
2331
|
+
);
|
|
2332
|
+
// The content level picks between the quiet tools' result previews
|
|
2333
|
+
// (default) and command-only cards (minimal).
|
|
2334
|
+
const cardContentPreview = {
|
|
2335
|
+
[CARD_CONTENT.DEFAULT]: "≡ read a.ts · result previews + counts · bash rows keep pi's native card",
|
|
2336
|
+
[CARD_CONTENT.MINIMAL]: "≡ read a.ts · command only · ctrl+o expands · bash draws as a Gentle card",
|
|
2337
|
+
};
|
|
2338
|
+
for (const content of Object.values(CARD_CONTENT)) add(
|
|
2339
|
+
() => {
|
|
2340
|
+
const current = resolveCardContent(home);
|
|
2341
|
+
return `Card content: ${content}${current.content === content && !current.malformed ? " (current)" : ""}`;
|
|
2342
|
+
},
|
|
2343
|
+
`Card content: ${content}. Quiet tool cards redraw now; bash rows follow on new calls.`,
|
|
2344
|
+
() => {
|
|
2345
|
+
writeCardContent(content, home);
|
|
2346
|
+
setCardContent(content);
|
|
2347
|
+
renderHost?.requestRender();
|
|
2348
|
+
requestCustomizeRender?.();
|
|
2349
|
+
},
|
|
2350
|
+
() => ({ title: `Cards · content ${content}`, sample: `${cardContentPreview[content]}${resolveCardContent(home).malformed ? " · malformed or unreadable file" : ""}` }),
|
|
2351
|
+
);
|
|
2352
|
+
category = "Sections";
|
|
2353
|
+
for (const key of VISUAL_SECTION_KEYS) add(
|
|
2354
|
+
() => `Section ${key}: ${visual().visibility[key] ? "shown" : "hidden"}`,
|
|
2355
|
+
pending,
|
|
2356
|
+
() => updateVisual((settings) => ({ ...settings, visibility: { ...settings.visibility, [key]: !settings.visibility[key] } })),
|
|
2357
|
+
() => {
|
|
2358
|
+
const visibility = { ...visual().visibility, [key]: !visual().visibility[key] };
|
|
2359
|
+
return { title: `Sections · ${key} ${visibility[key] ? "shown" : "hidden"}`, sample: `[${Object.entries(visibility).filter(([, shown]) => shown).map(([section]) => section).join("] [") || "no optional sections"}]` };
|
|
2360
|
+
},
|
|
2361
|
+
);
|
|
2362
|
+
const catalog = () => listVisualProfiles(home).map(name => getVisualProfile(name, home)!);
|
|
2363
|
+
category = "Profiles";
|
|
2364
|
+
rows.push({ category, label: "Visual profiles (preview, save, apply, delete)", preview: () => {
|
|
2365
|
+
const saved = catalog()[0];
|
|
2366
|
+
return saved ? { title: `${saved.name} · saved profile`, sample: `${saved.visual.headerPlacement === "top" ? "[Header] → [Input]" : "[Input] → [Header]"} [${saved.visual.statusPlacement} status]` } : { title: "No saved profiles", sample: "Enter to save the current appearance" };
|
|
2367
|
+
}, action: () => {
|
|
2368
|
+
catalog();
|
|
2369
|
+
customizeView?.openProfiles();
|
|
2370
|
+
ctx.ui.notify("Profile preview opened; selections do not change preferences.", "info");
|
|
2371
|
+
} });
|
|
2372
|
+
category = "Reset";
|
|
2373
|
+
add("Reset visual, banner and animation defaults", "Defaults saved. Layout and prompt apply now; banner at next startup.", async (): Promise<void | false> => {
|
|
2374
|
+
// Validate the banner before touching any store. The writes below are
|
|
2375
|
+
// independent, not a transaction: report precisely what succeeded.
|
|
2376
|
+
await readBannerConfigForEdit(bannerHome);
|
|
2377
|
+
const changed: string[] = [];
|
|
2378
|
+
try {
|
|
2379
|
+
writeVisualSettings(structuredClone(DEFAULT_VISUAL_SETTINGS), home);
|
|
2380
|
+
refreshVisual();
|
|
2381
|
+
pi.events.emit(VISUAL_SETTINGS_CHANGED, { configHome: doubleEscCancelConfigHome });
|
|
2382
|
+
changed.push("visual");
|
|
2383
|
+
await writeBannerConfig({ ...DEFAULT_BANNER_CONFIG }, bannerHome);
|
|
2384
|
+
banner = { ...DEFAULT_BANNER_CONFIG };
|
|
2385
|
+
changed.push("banner");
|
|
2386
|
+
writeAnimationPolicy("quality", animationOptions);
|
|
2387
|
+
animationPolicy = resolveAnimationPolicy(animationOptions).policy;
|
|
2388
|
+
prompt?.setAnimationPolicy(animationPolicy);
|
|
2389
|
+
changed.push("animation");
|
|
2390
|
+
} catch (error) {
|
|
2391
|
+
const state = ["visual", "banner", "animation"].map((store) => `${store} ${changed.includes(store) ? "changed" : "unchanged"}`).join(", ");
|
|
2392
|
+
ctx.ui.notify(`Partial reset: ${state}. ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
2393
|
+
return false;
|
|
2394
|
+
}
|
|
2395
|
+
}, () => ({ title: "Restore visual defaults", sample: "[Header] → [Input] [responsive status] · quality" }));
|
|
2396
|
+
const profiles: ProfileActions = {
|
|
2397
|
+
list: catalog,
|
|
2398
|
+
save: async (name, replace) => {
|
|
2399
|
+
const visual = resolveVisualSettings(home);
|
|
2400
|
+
if (visual.malformed || visual.readError) throw new Error("Visual settings unavailable for snapshot.");
|
|
2401
|
+
const currentBanner = await readBannerConfigForEdit(bannerHome);
|
|
2402
|
+
const currentAnimation = resolveAnimationPolicy(animationOptions);
|
|
2403
|
+
if (currentAnimation.malformed) throw new Error(`Cannot read animation policy: ${currentAnimation.globalFile}`);
|
|
2404
|
+
const themeName = ctx.ui.theme.name;
|
|
2405
|
+
if (!themeName || !ctx.ui.getAllThemes?.().some((item) => item.name === themeName) || !ctx.ui.getTheme?.(themeName)) throw new Error("Active theme is not installed or valid; profile not saved.");
|
|
2406
|
+
saveVisualProfile(name, { themeName, animationPolicy: currentAnimation.policy, banner: currentBanner, visual: visual.settings }, { ...home, replace });
|
|
2407
|
+
ctx.ui.notify(`Visual profile ${name} saved.`, "info");
|
|
2408
|
+
},
|
|
2409
|
+
apply: async (name) => {
|
|
2410
|
+
const profile = getVisualProfile(name, home);
|
|
2411
|
+
if (!profile) throw new Error(`Visual profile ${name} not found.`);
|
|
2412
|
+
const changed: string[] = [];
|
|
2413
|
+
const failures: string[] = [];
|
|
2414
|
+
const step = async (label: string, action: () => void | Promise<void>) => {
|
|
2415
|
+
try { await action(); changed.push(label); } catch (error) { failures.push(`${label}: ${error instanceof Error ? error.message : String(error)}`); }
|
|
2416
|
+
};
|
|
2417
|
+
await step("theme", () => {
|
|
2418
|
+
if (!ctx.ui.getTheme?.(profile.themeName)) throw new Error("Installed theme unavailable.");
|
|
2419
|
+
const result = ctx.ui.setTheme(profile.themeName);
|
|
2420
|
+
if (!result.success) throw new Error(result.error ?? "Theme activation failed.");
|
|
2421
|
+
activeTheme = profile.themeName;
|
|
2422
|
+
});
|
|
2423
|
+
await step("visual", () => {
|
|
2424
|
+
const current = resolveVisualSettings(home);
|
|
2425
|
+
if (current.malformed || current.readError) throw new Error("Visual settings unreadable.");
|
|
2426
|
+
writeVisualSettings(profile.visual, home);
|
|
2427
|
+
refreshVisual();
|
|
2428
|
+
pi.events.emit(VISUAL_SETTINGS_CHANGED, { configHome: doubleEscCancelConfigHome });
|
|
2429
|
+
});
|
|
2430
|
+
await step("banner", async () => { await readBannerConfigForEdit(bannerHome); await writeBannerConfig(profile.banner, bannerHome); banner = profile.banner; });
|
|
2431
|
+
await step("animation", () => { writeAnimationPolicy(profile.animationPolicy, animationOptions); animationPolicy = profile.animationPolicy; prompt?.setAnimationPolicy(animationPolicy); });
|
|
2432
|
+
ctx.ui.notify(failures.length ? `PARTIAL profile apply: ${changed.join(", ") || "none"} changed; ${failures.join("; ")}` : `Visual profile ${name} applied; banner applies at next startup.`, failures.length ? "warning" : "info");
|
|
2433
|
+
},
|
|
2434
|
+
delete: (name) => { deleteVisualProfile(name, home); ctx.ui.notify(`Visual profile ${name} deleted.`, "info"); },
|
|
2435
|
+
reset: () => { resetVisualProfiles(home); ctx.ui.notify("Visual profile catalog cleared; active settings unchanged.", "info"); },
|
|
2436
|
+
};
|
|
2437
|
+
try {
|
|
2438
|
+
adapter = await discoverYoloUiAdapter(pi, ctx);
|
|
2439
|
+
if (closed) { adapter?.dispose(); return; }
|
|
2440
|
+
unobserve = adapter?.observe(() => { void refreshYolo(); });
|
|
2441
|
+
await refreshYolo();
|
|
2442
|
+
if (closed) return;
|
|
2443
|
+
await ctx.ui.custom<null>((tui, theme, _keys, done) => {
|
|
2444
|
+
finish = () => done(null);
|
|
2445
|
+
if (closed) done(null);
|
|
2446
|
+
requestYoloRender = () => { if (!closed) tui.requestRender(); };
|
|
2447
|
+
requestCustomizeRender = requestYoloRender;
|
|
2448
|
+
customizeView = new VisualCustomizeView({ rows, profiles, theme, requestRender: requestYoloRender, rowsAvailable: () => Math.max(0, Math.floor(tui.terminal.rows * 0.85) - 2), onError: (error) => { if (!closed) ctx.ui.notify(`${customizeView?.title() ?? "Customization"}: ${error.message}`, "error"); }, onClose: close });
|
|
2449
|
+
const view = customizeView;
|
|
2450
|
+
// Own interaction lifetime here, leaving the shared view unchanged.
|
|
2451
|
+
return {
|
|
2452
|
+
render: (width) => closed ? [] : view.render(width),
|
|
2453
|
+
handleInput: (data) => { if (!closed) view.handleInput(data); },
|
|
2454
|
+
invalidate: () => { if (!closed) view.invalidate(); },
|
|
2455
|
+
dispose: close,
|
|
2456
|
+
};
|
|
2457
|
+
}, { overlay: true, overlayOptions: { anchor: "center", width: "70%", minWidth: 60, maxHeight: "85%" } });
|
|
2458
|
+
} finally { close(); }
|
|
2459
|
+
},
|
|
2460
|
+
});
|
|
2461
|
+
pi.registerCommand("gentle:vim", {
|
|
2462
|
+
description: "Show or set global Vim prompt editing (status|enable|disable); no argument opens a menu.",
|
|
2463
|
+
handler: async (args, ctx) => {
|
|
2464
|
+
let action = args.trim() || "status";
|
|
2465
|
+
if (!args.trim() && ctx.hasUI && typeof ctx.ui.select === "function") {
|
|
2466
|
+
const selected = await ctx.ui.select(`Gentle Vim (current: ${vimPolicy})`, ["enable", "disable", "status"]);
|
|
2467
|
+
if (selected === undefined) return;
|
|
2468
|
+
action = selected;
|
|
2469
|
+
}
|
|
2470
|
+
if (action !== "status" && action !== "enable" && action !== "disable") {
|
|
2471
|
+
ctx.ui.notify("Use /gentle:vim status|enable|disable.", "warning");
|
|
2472
|
+
return;
|
|
2473
|
+
}
|
|
2474
|
+
try {
|
|
2475
|
+
if (action !== "status") writeVimPolicy(action === "enable" ? "on" : "off", animationOptions);
|
|
2476
|
+
const result = resolveVimPolicy(animationOptions);
|
|
2477
|
+
vimPolicy = result.policy;
|
|
2478
|
+
prompt?.setVimPolicy(vimPolicy);
|
|
2479
|
+
reportVim(ctx, result);
|
|
2480
|
+
} catch (error) {
|
|
2481
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
2482
|
+
}
|
|
2483
|
+
},
|
|
2484
|
+
});
|
|
1033
2485
|
pi.registerCommand("gentle:animations", {
|
|
1034
2486
|
description: "Show or set global animations; no argument opens a selectable menu (quality|performance|potato, plus status).",
|
|
1035
2487
|
// No argument opens a selectable menu when an interactive UI is present;
|
|
@@ -1101,15 +2553,39 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
1101
2553
|
// below has delivered the pending text. Nothing is sent from here: Pi
|
|
1102
2554
|
// is mid-turn, so the text simply waits and goes out, once, when that
|
|
1103
2555
|
// turn settles. It is never dropped.
|
|
2556
|
+
// A new turn always starts unlabeled: any ODD phase reported for the
|
|
2557
|
+
// previous turn must never leak into this one. The new turn's tool
|
|
2558
|
+
// activity, or an explicit orchestrator report, labels it again.
|
|
2559
|
+
oddPhaseRegistry.clear(ctx.sessionManager.getSessionId());
|
|
1104
2560
|
prompt?.setWorking(true);
|
|
1105
2561
|
// The dev-binary card is a startup notice: it leaves with the first prompt.
|
|
1106
2562
|
if (ctx.hasUI) ctx.ui.setWidget(DEV_BINARY_WIDGET_KEY, undefined);
|
|
1107
2563
|
});
|
|
2564
|
+
pi.on("tool_execution_start", (event, ctx) => {
|
|
2565
|
+
// The working label follows the primary session's own tool activity so
|
|
2566
|
+
// it never depends on the model remembering gentle_odd_phase, which
|
|
2567
|
+
// stays the explicit refinement (its report is already explicit).
|
|
2568
|
+
// Subagents run as headless `pi --mode rpc` children whose env never
|
|
2569
|
+
// carries the interactive-host signal (lib/agents-runner.ts), so they
|
|
2570
|
+
// never infer; their process-local registry could not reach this
|
|
2571
|
+
// label anyway.
|
|
2572
|
+
if (!ctx.hasUI || !isInteractiveMode(ctx.mode) || event.toolName === "gentle_odd_phase") return;
|
|
2573
|
+
const phase = inferOddPhase(event.toolName, event.args);
|
|
2574
|
+
if (phase) {
|
|
2575
|
+
const delegated = event.toolName === "subagent_run" || /^mcp__.+?__subagent_run$/.test(event.toolName);
|
|
2576
|
+
oddPhaseRegistry.infer(ctx.sessionManager.getSessionId(), phase, delegated ? "delegation" : "tool");
|
|
2577
|
+
}
|
|
2578
|
+
});
|
|
1108
2579
|
pi.on("agent_settled", (_event, ctx) => {
|
|
1109
2580
|
// Pi clears its own run-active flag before emitting agent_settled, so
|
|
1110
2581
|
// this is normally idle; if a run is somehow still in flight the prompt
|
|
1111
2582
|
// stays working and the pending text waits for the next settle.
|
|
1112
2583
|
if (!ctx.isIdle()) return;
|
|
2584
|
+
// Covers both a normal finish and an aborted turn settling idle: the
|
|
2585
|
+
// input falls back to the generic "working…" label until the next
|
|
2586
|
+
// turn's own agent_start clears it again (belt-and-suspenders with the
|
|
2587
|
+
// clear above).
|
|
2588
|
+
oddPhaseRegistry.clear(ctx.sessionManager.getSessionId());
|
|
1113
2589
|
prompt?.setWorking(false);
|
|
1114
2590
|
if (pendingQueuedText === undefined) return;
|
|
1115
2591
|
const queued = pendingQueuedText;
|
|
@@ -1129,7 +2605,14 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
1129
2605
|
}
|
|
1130
2606
|
});
|
|
1131
2607
|
pi.on("agent_end", async (_event, ctx) => {
|
|
1132
|
-
|
|
2608
|
+
// A slow or failing Git never holds the turn end.
|
|
2609
|
+
trackChangeRefresh(refreshChanges(ctx));
|
|
1133
2610
|
void refreshUsage(ctx, false);
|
|
1134
2611
|
});
|
|
2612
|
+
pi.on("model_select", (event, ctx) => {
|
|
2613
|
+
announceAdaptiveReasoning(ctx, event.model);
|
|
2614
|
+
});
|
|
2615
|
+
pi.on("thinking_level_select", (_event, ctx) => {
|
|
2616
|
+
announceAdaptiveReasoning(ctx, ctx.model);
|
|
2617
|
+
});
|
|
1135
2618
|
}
|