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
|
@@ -0,0 +1,1424 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 ExoPro. Inspired by @jasonish/pi-prompt-history
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
// Prompt-history extension entry: the selector open flow over the slice-1
|
|
5
|
+
// writer and slice-2 drains, with the search input (filterPrompts +
|
|
6
|
+
// forwardToSearch fallthrough), the lazy loaded window, the project<->global
|
|
7
|
+
// scope toggle, the preview panel + wheel handling, the slice-4 import
|
|
8
|
+
// (legacy migration + seed bootstrap inside getWriter), the slice-5
|
|
9
|
+
// modal delete (store sweep + exact tombstone), and the slice-6
|
|
10
|
+
// session_shutdown GC/compaction.
|
|
11
|
+
//
|
|
12
|
+
// Capture is OPT-IN: nothing is recorded unless an explicit
|
|
13
|
+
// GENTLE_PI_HISTORY_CAPTURE=1|true|on, or (with no explicit env value) the
|
|
14
|
+
// persisted Gentle → Customize preference, turns it on. The selector honors
|
|
15
|
+
// the same gate: with capture off, opening the selector is a no-op — no
|
|
16
|
+
// registry entry, no writer init, no store reads, no deletes. Turning
|
|
17
|
+
// capture off only stops NEW captures; files already written stay on disk
|
|
18
|
+
// (docs/prompt-history.md).
|
|
19
|
+
|
|
20
|
+
import { randomUUID } from "node:crypto";
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import {
|
|
24
|
+
DynamicBorder,
|
|
25
|
+
type ExtensionAPI,
|
|
26
|
+
type ExtensionCommandContext,
|
|
27
|
+
type Theme,
|
|
28
|
+
} from "@earendil-works/pi-coding-agent";
|
|
29
|
+
import {
|
|
30
|
+
Container,
|
|
31
|
+
type Focusable,
|
|
32
|
+
getKeybindings,
|
|
33
|
+
Input,
|
|
34
|
+
matchesKey,
|
|
35
|
+
stripTerminalSequences,
|
|
36
|
+
type TUI,
|
|
37
|
+
type TuiMouseEvent,
|
|
38
|
+
truncateToWidth,
|
|
39
|
+
} from "@earendil-works/pi-tui";
|
|
40
|
+
import { bundledGentlePiAgentHome, gentlePiConfigHome } from "../../lib/agent-home.ts";
|
|
41
|
+
import {
|
|
42
|
+
historyCaptureEnabled,
|
|
43
|
+
historyCaptureEnvOverride,
|
|
44
|
+
resolveHistoryCapturePolicy,
|
|
45
|
+
} from "../../lib/history-capture-policy.ts";
|
|
46
|
+
import { hidePrompt } from "./hide-prompts.ts";
|
|
47
|
+
import {
|
|
48
|
+
appendSessionCapture,
|
|
49
|
+
bootstrapProjectSeed,
|
|
50
|
+
deleteFromGlobal,
|
|
51
|
+
deleteFromProject,
|
|
52
|
+
type DrainResult,
|
|
53
|
+
drainGlobal,
|
|
54
|
+
drainProject,
|
|
55
|
+
ensureRegistryEntry,
|
|
56
|
+
gcProjectDir,
|
|
57
|
+
migrateLegacyStores,
|
|
58
|
+
openSessionWriter,
|
|
59
|
+
type SessionWriterState,
|
|
60
|
+
sessionFilePath,
|
|
61
|
+
type SweepResult,
|
|
62
|
+
} from "./store.ts";
|
|
63
|
+
import {
|
|
64
|
+
buildPromptRecords,
|
|
65
|
+
clampPreviewOffset,
|
|
66
|
+
clampSelectedIndex,
|
|
67
|
+
dedupePromptEntries,
|
|
68
|
+
deleteConfirmFooterText,
|
|
69
|
+
deleteConfirmStep,
|
|
70
|
+
deletionActionsFor,
|
|
71
|
+
editorOverlayMargin,
|
|
72
|
+
filterPrompts,
|
|
73
|
+
getVisiblePromptRecords,
|
|
74
|
+
type HeaderLayoutMode,
|
|
75
|
+
initialLoadedCount,
|
|
76
|
+
loadedCountAfterDelete,
|
|
77
|
+
loadedCountForQuery,
|
|
78
|
+
loadedCountForTarget,
|
|
79
|
+
moveSelectedIndex,
|
|
80
|
+
nextLoadedCount,
|
|
81
|
+
pageSelectedIndex,
|
|
82
|
+
planHeaderLayout,
|
|
83
|
+
scopeRadioText,
|
|
84
|
+
shouldGrowWindow,
|
|
85
|
+
STORE_DELETE_FAILED_TEXT,
|
|
86
|
+
storeDeleteFollowUp,
|
|
87
|
+
storeDeleteNotice,
|
|
88
|
+
withExpandedHistoryGlobals,
|
|
89
|
+
type PiHistoryGlobals,
|
|
90
|
+
type PromptEntry,
|
|
91
|
+
type PromptRecord,
|
|
92
|
+
} from "./selector-helpers.ts";
|
|
93
|
+
|
|
94
|
+
const SHORTCUT = "ctrl+shift+r";
|
|
95
|
+
const MAX_VISIBLE = 10;
|
|
96
|
+
const PREVIEW_ROWS = 10;
|
|
97
|
+
// Lazy windowing (design §D3; user-tuned 2026-09-08). PRELOAD_BUFFER=3
|
|
98
|
+
// fires growth as the cursor enters the final 3 loaded rows; BATCH_SIZE=10
|
|
99
|
+
// loads exactly one viewport per growth; INITIAL_BATCH=10 paints one
|
|
100
|
+
// viewport at open. PRELOAD_BUFFER <= MAX_VISIBLE keeps a jump within one
|
|
101
|
+
// viewport covered by the catch-up loop; review all three together.
|
|
102
|
+
const INITIAL_BATCH = 10;
|
|
103
|
+
const BATCH_SIZE = 10;
|
|
104
|
+
const PRELOAD_BUFFER = 3;
|
|
105
|
+
// Wheel regions over the fixed 30-row overlay geometry (design §D6): the
|
|
106
|
+
// preview container always renders at rows 17-26. The list region is
|
|
107
|
+
// mode-dependent (see listWheelFirstRow): the responsive header reclaims
|
|
108
|
+
// rows without changing the 30-row total, and only the compact mode both
|
|
109
|
+
// shifts the list start (border at row 5) and paints one list row fewer.
|
|
110
|
+
const LIST_WHEEL_Y_FIRST = 5;
|
|
111
|
+
const LIST_WHEEL_Y_LAST = 14;
|
|
112
|
+
const PREVIEW_WHEEL_Y_FIRST = 17;
|
|
113
|
+
const PREVIEW_WHEEL_Y_LAST = 26;
|
|
114
|
+
/** Minimum columns between the counts text and a right-flushed radio before shrinking deletes the spacer and stacks the header (user-directed). */
|
|
115
|
+
const HEADER_INLINE_MIN_GAP = 4;
|
|
116
|
+
|
|
117
|
+
// Default selector footer line (PR #1393): shown whenever a delete is not
|
|
118
|
+
// armed; the armed state swaps it for the confirmation copy.
|
|
119
|
+
const SELECTOR_FOOTER_HELP =
|
|
120
|
+
"↑↓ move • PgUp/PgDn page • tab scope • enter select and quit • ctrl+shift+↑/↓ preview • ctrl+shift+backspace delete • esc cancel";
|
|
121
|
+
|
|
122
|
+
/** Width of the "→ " / " " prefix on each entry line. */
|
|
123
|
+
const ENTRY_PREFIX_WIDTH = 2;
|
|
124
|
+
|
|
125
|
+
// Legacy agent dir: pre-v1 editor-history files live directly here and are
|
|
126
|
+
// migrated into the store root by migrateLegacyStores().
|
|
127
|
+
const AGENT_DIR = join(homedir(), ".pi", "agent");
|
|
128
|
+
// v2 multi-concurrency store root (design: tmp/multi-concurrency-design.md).
|
|
129
|
+
// It is also the tombstone state dir: <root>/hidden.json.
|
|
130
|
+
const PI_HISTORY_ROOT = join(AGENT_DIR, "history");
|
|
131
|
+
// Sessions root for the one-level transcript scan (spec C1, design §D5).
|
|
132
|
+
// Read-only by invariant — transcripts are never written by this extension.
|
|
133
|
+
const SESSIONS_ROOT = join(AGENT_DIR, "sessions");
|
|
134
|
+
|
|
135
|
+
export interface HistoryDeps {
|
|
136
|
+
env?: NodeJS.ProcessEnv;
|
|
137
|
+
/** Gentle config home holding the Customize preference (default: from env). */
|
|
138
|
+
gentlePiConfigHome?: string;
|
|
139
|
+
root?: string;
|
|
140
|
+
cwd?: string;
|
|
141
|
+
instanceId?: string;
|
|
142
|
+
now?: () => number;
|
|
143
|
+
agentDir?: string;
|
|
144
|
+
sessionsRoot?: string;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Strict opt-in: an explicit GENTLE_PI_HISTORY_CAPTURE value (1|true|on or
|
|
149
|
+
* 0|false|off, case-insensitive) wins; otherwise the persisted Customize
|
|
150
|
+
* preference under the Gentle config home decides; a missing, malformed or
|
|
151
|
+
* unreadable preference is off. Read per call so a Customize toggle applies
|
|
152
|
+
* without restart. Turning capture off stops new captures; files already on
|
|
153
|
+
* disk are left untouched (deletes run from the selector while capture is on).
|
|
154
|
+
*/
|
|
155
|
+
export function captureEnabled(
|
|
156
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
157
|
+
configHome: string = gentlePiConfigHome(env),
|
|
158
|
+
): boolean {
|
|
159
|
+
return historyCaptureEnabled({ env, gentlePiConfigHome: configHome });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Why capture is off, naming the control that actually decides it. A
|
|
164
|
+
* malformed or unreadable preference is reported as such: Customize refuses
|
|
165
|
+
* to rewrite it, so "turn it on in Customize" would not help.
|
|
166
|
+
*/
|
|
167
|
+
function captureDisabledMessage(
|
|
168
|
+
env: NodeJS.ProcessEnv,
|
|
169
|
+
configHome: string,
|
|
170
|
+
): string {
|
|
171
|
+
if (historyCaptureEnvOverride(env) === "off") {
|
|
172
|
+
return "Prompt history is disabled by GENTLE_PI_HISTORY_CAPTURE, which overrides the Gentle → Customize → History preference.";
|
|
173
|
+
}
|
|
174
|
+
const preference = resolveHistoryCapturePolicy({ gentlePiConfigHome: configHome });
|
|
175
|
+
if (preference.malformed) {
|
|
176
|
+
return `Prompt history is disabled because the Gentle → Customize → History preference is invalid or unreadable: ${preference.globalFile}. Fix or remove that file, or set GENTLE_PI_HISTORY_CAPTURE=1.`;
|
|
177
|
+
}
|
|
178
|
+
return "Prompt history is disabled. Turn on \"Prompt history capture\" in Gentle → Customize → History, or set GENTLE_PI_HISTORY_CAPTURE=1.";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// Sanitization
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Replace control characters with visible escape notation so the terminal
|
|
187
|
+
* renders them as text instead of interpreting them as commands.
|
|
188
|
+
* Preserves \n (newlines) and \t (tabs).
|
|
189
|
+
*/
|
|
190
|
+
function sanitizeForDisplay(text: string): string {
|
|
191
|
+
let out = "";
|
|
192
|
+
for (let i = 0; i < text.length; i++) {
|
|
193
|
+
const cp = text.codePointAt(i);
|
|
194
|
+
if (cp === undefined) break;
|
|
195
|
+
if (cp === 0x0a) {
|
|
196
|
+
out += "\n";
|
|
197
|
+
} else if (cp === 0x09) {
|
|
198
|
+
out += "\t";
|
|
199
|
+
} else if (cp < 0x20 || cp === 0x7f) {
|
|
200
|
+
out += "\\x" + cp.toString(16).padStart(2, "0");
|
|
201
|
+
} else if (cp >= 0x80 && cp < 0xa0) {
|
|
202
|
+
out += "\\x" + cp.toString(16).padStart(2, "0");
|
|
203
|
+
} else {
|
|
204
|
+
// Astral code points (> 0xFFFF) span a surrogate pair; append the
|
|
205
|
+
// full code point, not just the high surrogate at text[i], so emoji
|
|
206
|
+
// and other non-BMP characters survive sanitization intact.
|
|
207
|
+
out += cp > 0xffff ? String.fromCodePoint(cp) : text[i];
|
|
208
|
+
}
|
|
209
|
+
if (cp > 0xffff) i++; // skip low surrogate of astral pair
|
|
210
|
+
}
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// Types
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
/** Keybinding lookup returned by getKeybindings(). */
|
|
219
|
+
interface Keybindings {
|
|
220
|
+
matches(data: string, action: string): boolean;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
type InputMatcher = (data: string, kb: Keybindings) => boolean;
|
|
224
|
+
type InputHandler = () => void;
|
|
225
|
+
|
|
226
|
+
interface DispatchEntry {
|
|
227
|
+
match: InputMatcher;
|
|
228
|
+
handler: InputHandler;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Notification sink for selector feedback; an absent callback drops notifications. */
|
|
232
|
+
type SelectorNotify = (message: string, level: "error" | "warning" | "info") => void;
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Store access the open flow hands the selector (per-load deps root/cwd):
|
|
236
|
+
* the selector never resolves store paths itself, so a bare constructor
|
|
237
|
+
* (tests, tooling) cannot touch any store. `root` is also the tombstone
|
|
238
|
+
* state dir (hidden.json); `drain` is the fail-closed scope drain.
|
|
239
|
+
*/
|
|
240
|
+
interface SelectorStore {
|
|
241
|
+
root: string;
|
|
242
|
+
cwd: string;
|
|
243
|
+
drain: (scope: HistoryScope) => DrainResult;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Single rendered row; always occupies exactly one terminal row. */
|
|
247
|
+
class FixedRowText {
|
|
248
|
+
private text: string;
|
|
249
|
+
private readonly centered: boolean;
|
|
250
|
+
|
|
251
|
+
constructor(text: string = "", centered = false) {
|
|
252
|
+
this.text = text;
|
|
253
|
+
this.centered = centered;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Replace the row content in place; padding contract comes from render(). */
|
|
257
|
+
setText(next: string): void {
|
|
258
|
+
this.text = next;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
invalidate(): void {}
|
|
262
|
+
|
|
263
|
+
render(width: number): string[] {
|
|
264
|
+
if (width <= 0) return [" "] as string[];
|
|
265
|
+
if (this.text.length === 0) {
|
|
266
|
+
// Use a space so the terminal always renders this as a visible row
|
|
267
|
+
// and differential rendering correctly detects it as a changed line.
|
|
268
|
+
return [" ".repeat(width)] as string[];
|
|
269
|
+
}
|
|
270
|
+
const rendered = this.centered
|
|
271
|
+
? (() => {
|
|
272
|
+
// Truncate first so an overlong help row can never exceed width,
|
|
273
|
+
// then center the truncated copy (design §C hardening).
|
|
274
|
+
const truncated = truncateToWidth(this.text, width, "…");
|
|
275
|
+
const visible = stripTerminalSequences(truncated);
|
|
276
|
+
const pad = Math.max(0, Math.floor((width - visible.length) / 2));
|
|
277
|
+
return " ".repeat(pad) + truncated;
|
|
278
|
+
})()
|
|
279
|
+
: truncateToWidth(this.text, width, "…");
|
|
280
|
+
// Pad to full terminal width so the overlay fully overwrites
|
|
281
|
+
// whatever is beneath it and leaves no ghost characters on dismiss.
|
|
282
|
+
// Measure the VISIBLE width: SGR escape sequences (colored rows from
|
|
283
|
+
// rebuildListWithWidth) occupy no terminal cells.
|
|
284
|
+
const visible = rendered.replace(/\x1b\[[0-9;]*m/g, "");
|
|
285
|
+
return [rendered + " ".repeat(Math.max(0, width - visible.length))];
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** A row that renders as ZERO lines when its text is empty, letting the fixed 30-row overlay reclaim the row instead of pushing content out the bottom. */
|
|
290
|
+
class OptionalRow {
|
|
291
|
+
private text = "";
|
|
292
|
+
|
|
293
|
+
setText(next: string): void {
|
|
294
|
+
this.text = next;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
invalidate(): void {}
|
|
298
|
+
|
|
299
|
+
render(width: number): string[] {
|
|
300
|
+
if (this.text.length === 0) return [];
|
|
301
|
+
return [truncateToWidth(this.text, width, "…")];
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Word-wrap plain text so each line fits within maxWidth characters. */
|
|
306
|
+
function wordWrapText(text: string, maxWidth: number): string[] {
|
|
307
|
+
if (maxWidth <= 0) return [text || " "];
|
|
308
|
+
const paragraphs = text.split("\n");
|
|
309
|
+
const result: string[] = [];
|
|
310
|
+
for (const para of paragraphs) {
|
|
311
|
+
if (para.length === 0) {
|
|
312
|
+
result.push("");
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
let remaining = para;
|
|
316
|
+
while (remaining.length > 0) {
|
|
317
|
+
if (remaining.length <= maxWidth) {
|
|
318
|
+
result.push(remaining);
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
const breakAt = remaining.lastIndexOf(" ", maxWidth);
|
|
322
|
+
if (breakAt <= 0) {
|
|
323
|
+
result.push(remaining.substring(0, maxWidth));
|
|
324
|
+
remaining = remaining.substring(maxWidth);
|
|
325
|
+
} else {
|
|
326
|
+
result.push(remaining.substring(0, breakAt));
|
|
327
|
+
remaining = remaining.substring(breakAt + 1);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return result.length > 0 ? result : [""];
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
// TUI Selector
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
class PromptHistorySelector extends Container implements Focusable {
|
|
339
|
+
private readonly searchInput: Input;
|
|
340
|
+
private readonly previewContainer: Container;
|
|
341
|
+
private readonly listContainer: Container;
|
|
342
|
+
private readonly headerRow: FixedRowText;
|
|
343
|
+
private readonly headerLine2: OptionalRow;
|
|
344
|
+
private readonly headerLine3: OptionalRow;
|
|
345
|
+
private readonly hintRow: OptionalRow;
|
|
346
|
+
private readonly hintText: string;
|
|
347
|
+
/** Current responsive header mode; drives the list wheel region. */
|
|
348
|
+
private headerMode: HeaderLayoutMode = "inline";
|
|
349
|
+
private readonly previewLabelRow: FixedRowText;
|
|
350
|
+
private readonly footerRow: FixedRowText;
|
|
351
|
+
private records: PromptRecord[];
|
|
352
|
+
private readonly theme: Theme;
|
|
353
|
+
private readonly tui: TUI;
|
|
354
|
+
private readonly onSelect: (record: PromptRecord) => void;
|
|
355
|
+
private readonly onCancel: () => void;
|
|
356
|
+
/** Notification sink for selector feedback (wired by the factory). */
|
|
357
|
+
private readonly onNotify?: SelectorNotify;
|
|
358
|
+
/** Injected store access; null disables scope drains and deletes. */
|
|
359
|
+
private readonly store: SelectorStore | null;
|
|
360
|
+
private filteredRecords: PromptRecord[] = [];
|
|
361
|
+
private selectedIndex = 0;
|
|
362
|
+
/** Number of records loaded (newest-first) from the top of `records`. */
|
|
363
|
+
private loadedCount = 0;
|
|
364
|
+
/** Active scope (design v2): project (default) or global. */
|
|
365
|
+
private scope: HistoryScope = "project";
|
|
366
|
+
/** Last render width, used for entry truncation. */
|
|
367
|
+
private lastWidth = 800;
|
|
368
|
+
/** Word-wrapped lines of the currently selected prompt. */
|
|
369
|
+
private wrappedPreviewLines: string[] = [];
|
|
370
|
+
/** Scroll offset into wrappedPreviewLines for the preview viewport. */
|
|
371
|
+
private previewScrollOffset = 0;
|
|
372
|
+
/**
|
|
373
|
+
* Modal delete confirmation (PR #1393 follow-up): armed by the first
|
|
374
|
+
* ctrl+shift+backspace press; while armed, y executes, n/Esc cancels,
|
|
375
|
+
* and every other key is swallowed. Nothing is deleted on the arming
|
|
376
|
+
* press.
|
|
377
|
+
*/
|
|
378
|
+
private confirmArmed = false;
|
|
379
|
+
|
|
380
|
+
/** Dispatch table: first match wins, fallthrough last. */
|
|
381
|
+
private readonly dispatch: readonly DispatchEntry[] = [
|
|
382
|
+
{
|
|
383
|
+
match: (_d, kb) => kb.matches(_d, "tui.select.up"),
|
|
384
|
+
handler: () => this.moveUp(),
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
match: (_d, kb) => kb.matches(_d, "tui.select.down"),
|
|
388
|
+
handler: () => this.moveDown(),
|
|
389
|
+
},
|
|
390
|
+
{
|
|
391
|
+
match: (_d, kb) => kb.matches(_d, "tui.select.pageUp"),
|
|
392
|
+
handler: () => this.pageListUp(),
|
|
393
|
+
},
|
|
394
|
+
{
|
|
395
|
+
match: (_d, kb) => kb.matches(_d, "tui.select.pageDown"),
|
|
396
|
+
handler: () => this.pageListDown(),
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
match: (d, kb) => d === "\r" || kb.matches(d, "tui.select.confirm"),
|
|
400
|
+
handler: () => this.selectCurrent(),
|
|
401
|
+
},
|
|
402
|
+
{ match: (d, _kb) => d === "\t", handler: () => this.toggleScope() },
|
|
403
|
+
{
|
|
404
|
+
match: (_d, kb) => kb.matches(_d, "tui.select.cancel"),
|
|
405
|
+
handler: () => this.onCancel(),
|
|
406
|
+
},
|
|
407
|
+
// Home/End jump the list only while the search box is empty; with any
|
|
408
|
+
// text they fall through to the search input and move its caret.
|
|
409
|
+
{
|
|
410
|
+
match: (d, _kb) => matchesKey(d, "home") && this.listOwnsHomeEnd(),
|
|
411
|
+
handler: () => this.jumpToFirst(),
|
|
412
|
+
},
|
|
413
|
+
{
|
|
414
|
+
match: (d, _kb) => matchesKey(d, "end") && this.listOwnsHomeEnd(),
|
|
415
|
+
handler: () => this.jumpToLast(),
|
|
416
|
+
},
|
|
417
|
+
{
|
|
418
|
+
match: (d, _kb) => matchesKey(d, "ctrl+shift+backspace"),
|
|
419
|
+
handler: () => this.deleteCurrent(),
|
|
420
|
+
},
|
|
421
|
+
{
|
|
422
|
+
match: (d, _kb) => matchesKey(d, "ctrl+shift+up"),
|
|
423
|
+
handler: () => this.previewPageUp(),
|
|
424
|
+
},
|
|
425
|
+
{
|
|
426
|
+
match: (d, _kb) => matchesKey(d, "ctrl+shift+down"),
|
|
427
|
+
handler: () => this.previewPageDown(),
|
|
428
|
+
},
|
|
429
|
+
];
|
|
430
|
+
|
|
431
|
+
private _focused = false;
|
|
432
|
+
get focused(): boolean {
|
|
433
|
+
return this._focused;
|
|
434
|
+
}
|
|
435
|
+
set focused(value: boolean) {
|
|
436
|
+
this._focused = value;
|
|
437
|
+
this.searchInput.focused = value;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
constructor(
|
|
441
|
+
tui: TUI,
|
|
442
|
+
theme: Theme,
|
|
443
|
+
records: PromptRecord[],
|
|
444
|
+
onSelect: (record: PromptRecord) => void,
|
|
445
|
+
onCancel: () => void,
|
|
446
|
+
onNotify?: SelectorNotify,
|
|
447
|
+
store?: SelectorStore,
|
|
448
|
+
) {
|
|
449
|
+
super();
|
|
450
|
+
this.tui = tui;
|
|
451
|
+
this.theme = theme;
|
|
452
|
+
this.records = records;
|
|
453
|
+
this.loadedCount = initialLoadedCount(records.length, INITIAL_BATCH);
|
|
454
|
+
this.onSelect = onSelect;
|
|
455
|
+
this.onCancel = onCancel;
|
|
456
|
+
this.onNotify = onNotify;
|
|
457
|
+
this.store = store ?? null;
|
|
458
|
+
|
|
459
|
+
// ── Search panel (top) ──
|
|
460
|
+
this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
461
|
+
this.headerRow = new FixedRowText(
|
|
462
|
+
theme.fg("accent", theme.bold(" History Search ")),
|
|
463
|
+
);
|
|
464
|
+
this.addChild(this.headerRow);
|
|
465
|
+
this.headerLine2 = new OptionalRow();
|
|
466
|
+
this.headerLine3 = new OptionalRow();
|
|
467
|
+
this.addChild(this.headerLine2);
|
|
468
|
+
this.addChild(this.headerLine3);
|
|
469
|
+
this.hintText =
|
|
470
|
+
"Type to filter (multi-word AND substring, case-insensitive)";
|
|
471
|
+
this.hintRow = new OptionalRow();
|
|
472
|
+
this.hintRow.setText(this.theme.fg("dim", this.hintText));
|
|
473
|
+
this.addChild(this.hintRow);
|
|
474
|
+
this.searchInput = new Input();
|
|
475
|
+
this.searchInput.onSubmit = () => this.selectCurrent();
|
|
476
|
+
this.searchInput.onEscape = () => this.onCancel();
|
|
477
|
+
this.addChild(this.searchInput);
|
|
478
|
+
this.addChild(new DynamicBorder((s: string) => theme.fg("dim", s)));
|
|
479
|
+
|
|
480
|
+
this.listContainer = new Container();
|
|
481
|
+
this.addChild(this.listContainer);
|
|
482
|
+
|
|
483
|
+
// ── Preview panel (bottom) ──
|
|
484
|
+
this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
485
|
+
this.previewLabelRow = new FixedRowText(
|
|
486
|
+
theme.fg("accent", theme.bold(" Preview ")),
|
|
487
|
+
);
|
|
488
|
+
this.addChild(this.previewLabelRow);
|
|
489
|
+
this.previewContainer = new Container();
|
|
490
|
+
this.addChild(this.previewContainer);
|
|
491
|
+
|
|
492
|
+
this.addChild(new DynamicBorder((s: string) => theme.fg("dim", s)));
|
|
493
|
+
this.footerRow = new FixedRowText(
|
|
494
|
+
theme.fg("dim", SELECTOR_FOOTER_HELP),
|
|
495
|
+
true /* centered */,
|
|
496
|
+
);
|
|
497
|
+
this.addChild(this.footerRow);
|
|
498
|
+
this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
499
|
+
|
|
500
|
+
this.applyFilter("");
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// -- Filtering & list building ------------------------------------------
|
|
504
|
+
|
|
505
|
+
private applyFilter(query: string): void {
|
|
506
|
+
// AC-L2-3r (user-directed 2026-09-08): a non-empty query implies
|
|
507
|
+
// full-snapshot visibility — one-shot and idempotent, never a batch —
|
|
508
|
+
// so per-keypress incremental loads remain impossible (C2).
|
|
509
|
+
this.loadedCount = loadedCountForQuery(
|
|
510
|
+
this.loadedCount,
|
|
511
|
+
this.records.length,
|
|
512
|
+
query,
|
|
513
|
+
);
|
|
514
|
+
this.filteredRecords = filterPrompts(
|
|
515
|
+
this.records.slice(0, this.loadedCount),
|
|
516
|
+
query,
|
|
517
|
+
);
|
|
518
|
+
this.selectedIndex = clampSelectedIndex(
|
|
519
|
+
this.selectedIndex,
|
|
520
|
+
this.filteredRecords.length,
|
|
521
|
+
);
|
|
522
|
+
this.previewScrollOffset = 0;
|
|
523
|
+
this.rebuildList();
|
|
524
|
+
this.rebuildPreview();
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
private rebuildList(): void {
|
|
528
|
+
this.rebuildListWithWidth(this.lastWidth);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** Styled title + position + loaded-counts prefix shared by the inline and stacked header layouts. */
|
|
532
|
+
private headerCountsText(
|
|
533
|
+
titleText: string,
|
|
534
|
+
positionText: string,
|
|
535
|
+
loadedText: string,
|
|
536
|
+
): string {
|
|
537
|
+
return (
|
|
538
|
+
this.theme.fg("accent", this.theme.bold(titleText)) +
|
|
539
|
+
this.theme.fg("dim", positionText) +
|
|
540
|
+
this.theme.fg("dim", loadedText)
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** Rebuild list rows: header counter + entries. Always MAX_VISIBLE rows (MAX_VISIBLE - 1 in compact mode). */
|
|
545
|
+
private rebuildListWithWidth(width: number): void {
|
|
546
|
+
const count = this.filteredRecords.length;
|
|
547
|
+
const position = count === 0 ? 0 : this.selectedIndex + 1;
|
|
548
|
+
const titleText = " History Search ";
|
|
549
|
+
const positionText = ` · ${position} of ${count} `;
|
|
550
|
+
const loadedText = ` · loaded ${this.loadedCount} of ${this.records.length} `;
|
|
551
|
+
const leftWidth =
|
|
552
|
+
titleText.length + positionText.length + loadedText.length;
|
|
553
|
+
const radioFull = scopeRadioText(this.scope, false);
|
|
554
|
+
// Radio label compaction is fit-driven too: abbreviate only when the
|
|
555
|
+
// full radio cannot fit the row it would occupy (user-directed paste).
|
|
556
|
+
// Stacked and compact rows print it after one leading space; inline
|
|
557
|
+
// mode always fits it by construction.
|
|
558
|
+
const radioText =
|
|
559
|
+
width >= radioFull.length + 1
|
|
560
|
+
? radioFull
|
|
561
|
+
: scopeRadioText(this.scope, true);
|
|
562
|
+
const mode = planHeaderLayout(
|
|
563
|
+
width,
|
|
564
|
+
leftWidth,
|
|
565
|
+
radioFull.length,
|
|
566
|
+
HEADER_INLINE_MIN_GAP,
|
|
567
|
+
);
|
|
568
|
+
this.headerMode = mode;
|
|
569
|
+
if (mode === "inline") {
|
|
570
|
+
this.headerRow.setText(
|
|
571
|
+
this.headerCountsText(titleText, positionText, loadedText) +
|
|
572
|
+
// Right-aligned scope radio: pad from plain-text lengths so the
|
|
573
|
+
// radio ends flush at the header's last column at any width.
|
|
574
|
+
" ".repeat(Math.max(1, width - leftWidth - radioText.length)) +
|
|
575
|
+
this.theme.fg("dim", radioText),
|
|
576
|
+
);
|
|
577
|
+
this.headerLine2.setText("");
|
|
578
|
+
this.headerLine3.setText("");
|
|
579
|
+
} else if (mode === "stacked") {
|
|
580
|
+
// Tablet: the spacer is deleted — the radio wraps to its own row
|
|
581
|
+
// under the full counts line (user-directed paste, leading space).
|
|
582
|
+
this.headerRow.setText(
|
|
583
|
+
this.headerCountsText(titleText, positionText, loadedText),
|
|
584
|
+
);
|
|
585
|
+
this.headerLine2.setText(` ${this.theme.fg("dim", radioText)}`);
|
|
586
|
+
this.headerLine3.setText("");
|
|
587
|
+
} else {
|
|
588
|
+
// Compact (mobile): three rows — counts split off, radio abbreviated
|
|
589
|
+
// (user-directed paste).
|
|
590
|
+
this.headerRow.setText(
|
|
591
|
+
this.theme.fg("accent", this.theme.bold(titleText)) +
|
|
592
|
+
this.theme.fg("dim", ` · ${position} of ${count}`),
|
|
593
|
+
);
|
|
594
|
+
// Leading space aligns both rows with the title's own left padding
|
|
595
|
+
// space (user-directed compact paste).
|
|
596
|
+
this.headerLine2.setText(
|
|
597
|
+
this.theme.fg(
|
|
598
|
+
"dim",
|
|
599
|
+
` loaded ${this.loadedCount} of ${this.records.length}`,
|
|
600
|
+
),
|
|
601
|
+
);
|
|
602
|
+
this.headerLine3.setText(` ${this.theme.fg("dim", radioText)}`);
|
|
603
|
+
}
|
|
604
|
+
// Stacked modes reclaim the hint row so the overlay stays 30 rows.
|
|
605
|
+
this.hintRow.setText(
|
|
606
|
+
mode === "inline" ? this.theme.fg("dim", this.hintText) : "",
|
|
607
|
+
);
|
|
608
|
+
this.listContainer.clear();
|
|
609
|
+
|
|
610
|
+
if (count === 0) {
|
|
611
|
+
this.listContainer.addChild(
|
|
612
|
+
new FixedRowText(this.theme.fg("warning", "No matching prompts")),
|
|
613
|
+
);
|
|
614
|
+
// Compact still paints one list row fewer in the empty state, or the
|
|
615
|
+
// 3-row header would push the fixed 30-row overlay to 31 rows.
|
|
616
|
+
const listRows = mode === "compact" ? MAX_VISIBLE - 1 : MAX_VISIBLE;
|
|
617
|
+
for (let i = 1; i < listRows; i++) {
|
|
618
|
+
this.listContainer.addChild(new FixedRowText());
|
|
619
|
+
}
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Compact paints one list row fewer (reclaimed by the 3-row header);
|
|
624
|
+
// the preview block keeps PREVIEW_ROWS so the 30-row total holds.
|
|
625
|
+
const listRows = mode === "compact" ? MAX_VISIBLE - 1 : MAX_VISIBLE;
|
|
626
|
+
const entryMax = Math.floor(width * 0.95) - ENTRY_PREFIX_WIDTH;
|
|
627
|
+
|
|
628
|
+
const visible = getVisiblePromptRecords(
|
|
629
|
+
this.filteredRecords,
|
|
630
|
+
this.selectedIndex,
|
|
631
|
+
listRows,
|
|
632
|
+
);
|
|
633
|
+
|
|
634
|
+
for (const { record, isSelected } of visible) {
|
|
635
|
+
const prefix = isSelected ? "→ " : " ";
|
|
636
|
+
// Armed delete (PR #1393): the armed row repaints in the error color
|
|
637
|
+
// while the confirmation is pending, then reverts on disarm.
|
|
638
|
+
const color = isSelected
|
|
639
|
+
? this.confirmArmed
|
|
640
|
+
? "error"
|
|
641
|
+
: "accent"
|
|
642
|
+
: "text";
|
|
643
|
+
const compacted = sanitizeForDisplay(record.text)
|
|
644
|
+
.replace(/\s+/g, " ")
|
|
645
|
+
.trim();
|
|
646
|
+
const truncated = truncateToWidth(compacted, entryMax, "…");
|
|
647
|
+
const line = prefix + this.theme.fg(color, truncated);
|
|
648
|
+
this.listContainer.addChild(new FixedRowText(line));
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
for (let i = visible.length; i < listRows; i++) {
|
|
652
|
+
this.listContainer.addChild(new FixedRowText());
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** List wheel region start: compact shifts the list down one row. */
|
|
657
|
+
private get listWheelFirstRow(): number {
|
|
658
|
+
return this.headerMode === "compact"
|
|
659
|
+
? LIST_WHEEL_Y_FIRST + 1
|
|
660
|
+
: LIST_WHEEL_Y_FIRST;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Rebuild preview: word-wrap the full selected prompt text and show
|
|
665
|
+
* a PREVIEW_ROWS-tall viewport starting at previewScrollOffset.
|
|
666
|
+
* Content starts immediately below the "Preview" label (no top padding).
|
|
667
|
+
* PgUp/PgDn scroll through the wrapped lines.
|
|
668
|
+
*/
|
|
669
|
+
private rebuildPreviewWithWidth(width: number): void {
|
|
670
|
+
this.previewContainer.clear();
|
|
671
|
+
|
|
672
|
+
const wrapWidth = Math.max(1, width - 2);
|
|
673
|
+
const selected = this.filteredRecords[this.selectedIndex];
|
|
674
|
+
if (selected) {
|
|
675
|
+
const safeText = sanitizeForDisplay(selected.text);
|
|
676
|
+
this.wrappedPreviewLines = wordWrapText(safeText, wrapWidth);
|
|
677
|
+
this.previewScrollOffset = clampPreviewOffset(
|
|
678
|
+
this.previewScrollOffset,
|
|
679
|
+
this.wrappedPreviewLines.length,
|
|
680
|
+
PREVIEW_ROWS,
|
|
681
|
+
);
|
|
682
|
+
} else {
|
|
683
|
+
this.wrappedPreviewLines = [];
|
|
684
|
+
this.previewScrollOffset = 0;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// P1-3 indicator: fresh wrap is known here — one update site covers all
|
|
688
|
+
// paths; the label appends the 1-based range only when content overflows.
|
|
689
|
+
this.previewLabelRow.setText(this.previewLabelRowText());
|
|
690
|
+
|
|
691
|
+
for (let i = 0; i < PREVIEW_ROWS; i++) {
|
|
692
|
+
const lineIdx = this.previewScrollOffset + i;
|
|
693
|
+
if (lineIdx < this.wrappedPreviewLines.length) {
|
|
694
|
+
// Pad the plain text to wrapWidth so FixedRowText.render()
|
|
695
|
+
// never truncates — the visible width is always ≤ width-2.
|
|
696
|
+
const raw = this.wrappedPreviewLines[lineIdx];
|
|
697
|
+
const padded = raw + " ".repeat(Math.max(0, wrapWidth - raw.length));
|
|
698
|
+
this.previewContainer.addChild(
|
|
699
|
+
new FixedRowText(this.theme.fg("text", padded)),
|
|
700
|
+
);
|
|
701
|
+
} else {
|
|
702
|
+
this.previewContainer.addChild(new FixedRowText());
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/** " Preview " label; appends the 1-based visible range only on overflow. */
|
|
708
|
+
private previewLabelRowText(): string {
|
|
709
|
+
const total = this.wrappedPreviewLines.length;
|
|
710
|
+
if (total <= PREVIEW_ROWS) {
|
|
711
|
+
return this.theme.fg("accent", this.theme.bold(" Preview "));
|
|
712
|
+
}
|
|
713
|
+
const start = this.previewScrollOffset + 1;
|
|
714
|
+
const end = Math.min(this.previewScrollOffset + PREVIEW_ROWS, total);
|
|
715
|
+
return this.theme.fg(
|
|
716
|
+
"accent",
|
|
717
|
+
this.theme.bold(` Preview — ${start}–${end}/${total} `),
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
private rebuildPreview(): void {
|
|
722
|
+
this.rebuildPreviewWithWidth(this.lastWidth);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// -- Selection actions --------------------------------------------------
|
|
726
|
+
|
|
727
|
+
private selectCurrent(): void {
|
|
728
|
+
const selected = this.filteredRecords[this.selectedIndex];
|
|
729
|
+
if (selected) this.onSelect(selected);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Toggle project <-> global (design v2): re-drain the other scope,
|
|
734
|
+
* rebuild the records, reset the window. Tab's only role. Fail-closed
|
|
735
|
+
* (slice-02 contract): a blocked drain keeps the working scope and
|
|
736
|
+
* surfaces the recovery warning instead of a blocked (entry-less) list.
|
|
737
|
+
*/
|
|
738
|
+
private toggleScope(): void {
|
|
739
|
+
const previous = this.scope;
|
|
740
|
+
this.scope = this.scope === "project" ? "global" : "project";
|
|
741
|
+
const drained: DrainResult = this.store?.drain(this.scope) ?? {
|
|
742
|
+
status: "ok",
|
|
743
|
+
prompts: [],
|
|
744
|
+
};
|
|
745
|
+
if (drained.status === "blocked") {
|
|
746
|
+
this.scope = previous;
|
|
747
|
+
this.onNotify?.(drained.message, "error");
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
this.records = recordsFromEntries(drained.prompts);
|
|
751
|
+
this.loadedCount = initialLoadedCount(this.records.length, INITIAL_BATCH);
|
|
752
|
+
this.applyFilter(this.searchInput.getValue());
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Delete-combo entry (slice-05 D3): the FIRST press arms the modal
|
|
757
|
+
* confirm for the selected row; while armed, the modal router in
|
|
758
|
+
* handleInput calls executeDelete() on `y`. Session-derived rows are
|
|
759
|
+
* read-only (slice-05 D1): a delete press on one is a silent no-op.
|
|
760
|
+
*/
|
|
761
|
+
private deleteCurrent(): void {
|
|
762
|
+
const selected = this.filteredRecords[this.selectedIndex];
|
|
763
|
+
if (!selected) return;
|
|
764
|
+
|
|
765
|
+
// Session rows are read-only: session transcripts are immutable and
|
|
766
|
+
// owned by Pi core — the extension never deletes from or writes to
|
|
767
|
+
// them. Silent no-op: no arm, no footer change, no tombstone.
|
|
768
|
+
if ((selected.source ?? "editor") === "session") return;
|
|
769
|
+
|
|
770
|
+
if (!this.confirmArmed) {
|
|
771
|
+
this.armDelete();
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
this.executeDelete();
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/** Arm the confirm: footer copy + error-colored row, nothing executes. */
|
|
778
|
+
private armDelete(): void {
|
|
779
|
+
this.confirmArmed = true;
|
|
780
|
+
this.refreshDeleteFooter();
|
|
781
|
+
this.rebuildList(); // repaint the armed-row highlight
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/** The executing half of the delete: leave the armed state, then mutate. */
|
|
785
|
+
private executeDelete(): void {
|
|
786
|
+
// Leave the armed state first: help footer back, highlight dropped.
|
|
787
|
+
this.confirmArmed = false;
|
|
788
|
+
this.refreshDeleteFooter();
|
|
789
|
+
this.rebuildList(); // drop the highlight before the flow mutates rows
|
|
790
|
+
|
|
791
|
+
const selected = this.filteredRecords[this.selectedIndex];
|
|
792
|
+
if (!selected || !this.store) return;
|
|
793
|
+
const { root, cwd } = this.store;
|
|
794
|
+
|
|
795
|
+
// C4 delete flows (design §F): the record's provenance decides the
|
|
796
|
+
// actions via the pure planner over the injected store root/cwd.
|
|
797
|
+
const actions = deletionActionsFor(selected.source ?? "editor");
|
|
798
|
+
|
|
799
|
+
let sweep: SweepResult | null = null;
|
|
800
|
+
if (actions.deleteFromEditorStore) {
|
|
801
|
+
// Store path: physically remove EVERY copy from the JSONL store, one
|
|
802
|
+
// atomic rewrite per file. A thrown store failure is contained here
|
|
803
|
+
// (PR #1393): toast + abort — nothing was removed and no tombstone is
|
|
804
|
+
// written, so the delete never lies about state.
|
|
805
|
+
try {
|
|
806
|
+
sweep =
|
|
807
|
+
this.scope === "global"
|
|
808
|
+
? deleteFromGlobal(root, selected.text)
|
|
809
|
+
: deleteFromProject(root, cwd, selected.text);
|
|
810
|
+
} catch {
|
|
811
|
+
this.onNotify?.(STORE_DELETE_FAILED_TEXT, "error");
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
// Files that could not be read or rewritten may still hold a copy:
|
|
815
|
+
// still write the tombstone that hides them; the notice waits for
|
|
816
|
+
// the hide result below.
|
|
817
|
+
const followUp = storeDeleteFollowUp(sweep);
|
|
818
|
+
if (!followUp.proceed) return;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// Tombstone ALWAYS: the session transcripts are immutable and would
|
|
822
|
+
// re-supply the deleted prompt on the next merge (hide-file suppresses
|
|
823
|
+
// the twin). Only the session path aborts on a hide error — the store
|
|
824
|
+
// row is already gone on the editor path, so the splice proceeds; its
|
|
825
|
+
// toast says exactly that (PR #1393).
|
|
826
|
+
const hide = hidePrompt(root, selected.text);
|
|
827
|
+
if (hide.status === "error") {
|
|
828
|
+
if (!actions.deleteFromEditorStore) {
|
|
829
|
+
this.onNotify?.(hide.message, "error");
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
// One notice for the editor path, stating both halves: a partial sweep
|
|
834
|
+
// only says "hidden" when the tombstone was actually written.
|
|
835
|
+
if (sweep) {
|
|
836
|
+
const notice = storeDeleteNotice(sweep, hide.status === "error");
|
|
837
|
+
if (notice) this.onNotify?.(notice, "error");
|
|
838
|
+
}
|
|
839
|
+
// Remove from the master records array so a subsequent filter doesn't
|
|
840
|
+
// bring it back.
|
|
841
|
+
const idx = this.records.indexOf(selected);
|
|
842
|
+
if (idx !== -1) {
|
|
843
|
+
this.records.splice(idx, 1);
|
|
844
|
+
// C4 delete backfill (design §B3): shrink the window with the splice,
|
|
845
|
+
// then pull the next unloaded row while any remain — genuine shrink
|
|
846
|
+
// only at exhaustion.
|
|
847
|
+
this.loadedCount = loadedCountAfterDelete(
|
|
848
|
+
this.loadedCount,
|
|
849
|
+
this.records.length,
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// Re-apply current filter (rebuilds filteredRecords, list, preview).
|
|
854
|
+
this.applyFilter(this.searchInput.getValue());
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/** Footer line: confirm copy while armed, help otherwise. */
|
|
858
|
+
private refreshDeleteFooter(): void {
|
|
859
|
+
if (!this.confirmArmed) {
|
|
860
|
+
this.footerRow.setText(this.theme.fg("dim", SELECTOR_FOOTER_HELP));
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
this.footerRow.setText(
|
|
864
|
+
this.theme.fg("warning", deleteConfirmFooterText()),
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/** Leave the armed state: restore the help footer and the plain row. */
|
|
869
|
+
private disarmDeleteConfirm(): void {
|
|
870
|
+
this.confirmArmed = false;
|
|
871
|
+
this.refreshDeleteFooter();
|
|
872
|
+
this.rebuildList();
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// -- Navigation ---------------------------------------------------------
|
|
876
|
+
|
|
877
|
+
private moveUp(): void {
|
|
878
|
+
this.selectedIndex = moveSelectedIndex(
|
|
879
|
+
this.selectedIndex,
|
|
880
|
+
this.filteredRecords.length,
|
|
881
|
+
-1,
|
|
882
|
+
);
|
|
883
|
+
if (
|
|
884
|
+
shouldGrowWindow(
|
|
885
|
+
this.selectedIndex,
|
|
886
|
+
this.loadedCount,
|
|
887
|
+
this.records.length,
|
|
888
|
+
PRELOAD_BUFFER,
|
|
889
|
+
)
|
|
890
|
+
) {
|
|
891
|
+
this.loadedCount = nextLoadedCount(
|
|
892
|
+
this.loadedCount,
|
|
893
|
+
this.records.length,
|
|
894
|
+
BATCH_SIZE,
|
|
895
|
+
);
|
|
896
|
+
this.applyFilter(this.searchInput.getValue());
|
|
897
|
+
}
|
|
898
|
+
this.previewScrollOffset = 0;
|
|
899
|
+
this.rebuildList();
|
|
900
|
+
this.rebuildPreview();
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
private moveDown(): void {
|
|
904
|
+
// Grow-before-move (design §D1): the C2 trigger fires while the cursor
|
|
905
|
+
// sits in the final PRELOAD_BUFFER rows of the loaded window, so the
|
|
906
|
+
// modulo below moves into freshly loaded rows — a wrap to index 0 is
|
|
907
|
+
// reachable only on the exhausted set.
|
|
908
|
+
if (
|
|
909
|
+
shouldGrowWindow(
|
|
910
|
+
this.selectedIndex,
|
|
911
|
+
this.loadedCount,
|
|
912
|
+
this.records.length,
|
|
913
|
+
PRELOAD_BUFFER,
|
|
914
|
+
)
|
|
915
|
+
) {
|
|
916
|
+
this.loadedCount = nextLoadedCount(
|
|
917
|
+
this.loadedCount,
|
|
918
|
+
this.records.length,
|
|
919
|
+
BATCH_SIZE,
|
|
920
|
+
);
|
|
921
|
+
this.applyFilter(this.searchInput.getValue());
|
|
922
|
+
}
|
|
923
|
+
this.selectedIndex = moveSelectedIndex(
|
|
924
|
+
this.selectedIndex,
|
|
925
|
+
this.filteredRecords.length,
|
|
926
|
+
1,
|
|
927
|
+
);
|
|
928
|
+
this.previewScrollOffset = 0;
|
|
929
|
+
this.rebuildList();
|
|
930
|
+
this.rebuildPreview();
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/** Page the LIST up by MAX_VISIBLE with clamping (no wrap). */
|
|
934
|
+
private pageListUp(): void {
|
|
935
|
+
this.selectedIndex = pageSelectedIndex(
|
|
936
|
+
this.selectedIndex,
|
|
937
|
+
this.filteredRecords.length,
|
|
938
|
+
-MAX_VISIBLE,
|
|
939
|
+
);
|
|
940
|
+
this.previewScrollOffset = 0;
|
|
941
|
+
this.rebuildList();
|
|
942
|
+
this.rebuildPreview();
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/** Page the LIST down by MAX_VISIBLE with clamping (no wrap). */
|
|
946
|
+
private pageListDown(): void {
|
|
947
|
+
// PgDn catch-up (design §D7): grow in whole batches until the paged-to
|
|
948
|
+
// row is loaded BEFORE the selection lands on it.
|
|
949
|
+
const grown = loadedCountForTarget(
|
|
950
|
+
this.loadedCount,
|
|
951
|
+
this.records.length,
|
|
952
|
+
this.selectedIndex + MAX_VISIBLE,
|
|
953
|
+
BATCH_SIZE,
|
|
954
|
+
);
|
|
955
|
+
if (grown !== this.loadedCount) {
|
|
956
|
+
this.loadedCount = grown;
|
|
957
|
+
this.applyFilter(this.searchInput.getValue());
|
|
958
|
+
}
|
|
959
|
+
this.selectedIndex = pageSelectedIndex(
|
|
960
|
+
this.selectedIndex,
|
|
961
|
+
this.filteredRecords.length,
|
|
962
|
+
MAX_VISIBLE,
|
|
963
|
+
);
|
|
964
|
+
this.previewScrollOffset = 0;
|
|
965
|
+
this.rebuildList();
|
|
966
|
+
this.rebuildPreview();
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
private previewPageUp(): void {
|
|
970
|
+
this.previewScrollOffset = Math.max(
|
|
971
|
+
0,
|
|
972
|
+
this.previewScrollOffset - PREVIEW_ROWS,
|
|
973
|
+
);
|
|
974
|
+
this.rebuildPreview();
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
private previewPageDown(): void {
|
|
978
|
+
this.previewScrollOffset = clampPreviewOffset(
|
|
979
|
+
this.previewScrollOffset + PREVIEW_ROWS,
|
|
980
|
+
this.wrappedPreviewLines.length,
|
|
981
|
+
PREVIEW_ROWS,
|
|
982
|
+
);
|
|
983
|
+
this.rebuildPreview();
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* An empty search box has no caret to move, so Home/End keep their list
|
|
988
|
+
* jumps; any text (whitespace included) routes them to the caret, and End
|
|
989
|
+
* then never loads the whole list.
|
|
990
|
+
*/
|
|
991
|
+
private listOwnsHomeEnd(): boolean {
|
|
992
|
+
return this.searchInput.getValue().length === 0;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
private jumpToFirst(): void {
|
|
996
|
+
if (this.filteredRecords.length === 0) return;
|
|
997
|
+
this.selectedIndex = 0;
|
|
998
|
+
this.previewScrollOffset = 0;
|
|
999
|
+
this.rebuildList();
|
|
1000
|
+
this.rebuildPreview();
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
private jumpToLast(): void {
|
|
1004
|
+
// End full-jump (design §D7): one-shot load of everything BEFORE the
|
|
1005
|
+
// empty guard, so End also surfaces matches beyond the window.
|
|
1006
|
+
if (this.loadedCount < this.records.length) {
|
|
1007
|
+
this.loadedCount = this.records.length;
|
|
1008
|
+
this.applyFilter(this.searchInput.getValue());
|
|
1009
|
+
}
|
|
1010
|
+
if (this.filteredRecords.length === 0) return;
|
|
1011
|
+
this.selectedIndex = this.filteredRecords.length - 1;
|
|
1012
|
+
this.previewScrollOffset = 0;
|
|
1013
|
+
this.rebuildList();
|
|
1014
|
+
this.rebuildPreview();
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// -- Input handling -----------------------------------------------------
|
|
1018
|
+
|
|
1019
|
+
private forwardToSearch(data: string): void {
|
|
1020
|
+
this.searchInput.handleInput(data);
|
|
1021
|
+
this.selectedIndex = 0;
|
|
1022
|
+
this.applyFilter(this.searchInput.getValue());
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
handleInput(data: string): void {
|
|
1026
|
+
// Modal armed confirm (slice-05 D3): while a delete is armed the pure
|
|
1027
|
+
// router consumes EVERY key — y executes, n/Esc cancels (esc must NOT
|
|
1028
|
+
// close the overlay here), anything else stays armed and is swallowed
|
|
1029
|
+
// — so no key reaches the dispatch table or the search input. When not
|
|
1030
|
+
// armed, behavior is unchanged.
|
|
1031
|
+
if (this.confirmArmed) {
|
|
1032
|
+
const step = deleteConfirmStep(
|
|
1033
|
+
this.confirmArmed,
|
|
1034
|
+
matchesKey(data, "ctrl+shift+backspace"),
|
|
1035
|
+
matchesKey(data, "escape"),
|
|
1036
|
+
data,
|
|
1037
|
+
);
|
|
1038
|
+
if (step.execute) this.executeDelete();
|
|
1039
|
+
else if (step.cancel) this.disarmDeleteConfirm();
|
|
1040
|
+
this.tui.requestRender();
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
const kb = getKeybindings();
|
|
1044
|
+
let handled = false;
|
|
1045
|
+
for (const { match, handler } of this.dispatch) {
|
|
1046
|
+
if (match(data, kb)) {
|
|
1047
|
+
handler();
|
|
1048
|
+
handled = true;
|
|
1049
|
+
break;
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
if (!handled) this.forwardToSearch(data);
|
|
1053
|
+
this.tui.requestRender();
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// -- Mouse (wheel-only) -------------------------------------------------
|
|
1057
|
+
|
|
1058
|
+
/**
|
|
1059
|
+
* Wheel-only mouse handling over the fixed 30-row geometry (design
|
|
1060
|
+
* §D6). Non-wheel events stay host-owned (undefined = Container child
|
|
1061
|
+
* dispatch); EVERY wheel path — including the no-op regions — reaches
|
|
1062
|
+
* the single consumed return, closing the pre-existing SGR-fallthrough
|
|
1063
|
+
* hazard where raw wheel bytes were typed into the search box.
|
|
1064
|
+
*/
|
|
1065
|
+
override handleMouse(
|
|
1066
|
+
event: TuiMouseEvent,
|
|
1067
|
+
): ReturnType<Container["handleMouse"]> {
|
|
1068
|
+
if (event.type !== "wheel") return undefined;
|
|
1069
|
+
// A wheel scroll can move the selection off the armed row — disarm so
|
|
1070
|
+
// the next delete press re-arms for the NEW row first (PR #1393).
|
|
1071
|
+
if (this.confirmArmed) this.disarmDeleteConfirm();
|
|
1072
|
+
const delta = event.wheelDelta ?? 0;
|
|
1073
|
+
if (event.y >= this.listWheelFirstRow && event.y <= LIST_WHEEL_Y_LAST) {
|
|
1074
|
+
const steps = Math.min(Math.abs(delta), this.filteredRecords.length);
|
|
1075
|
+
for (let i = 0; i < steps; i++) {
|
|
1076
|
+
if (delta > 0) this.moveDown();
|
|
1077
|
+
else this.moveUp();
|
|
1078
|
+
}
|
|
1079
|
+
} else if (
|
|
1080
|
+
event.y >= PREVIEW_WHEEL_Y_FIRST &&
|
|
1081
|
+
event.y <= PREVIEW_WHEEL_Y_LAST
|
|
1082
|
+
) {
|
|
1083
|
+
if (delta !== 0) {
|
|
1084
|
+
this.previewScrollOffset = clampPreviewOffset(
|
|
1085
|
+
this.previewScrollOffset + (delta > 0 ? 1 : -1),
|
|
1086
|
+
this.wrappedPreviewLines.length,
|
|
1087
|
+
PREVIEW_ROWS,
|
|
1088
|
+
);
|
|
1089
|
+
this.rebuildPreview();
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
return {
|
|
1093
|
+
handled: true,
|
|
1094
|
+
target: {
|
|
1095
|
+
component: this,
|
|
1096
|
+
originX: event.screenX - event.x,
|
|
1097
|
+
originY: event.screenY - event.y,
|
|
1098
|
+
width: event.width,
|
|
1099
|
+
height: event.height,
|
|
1100
|
+
},
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// -- Render override for dynamic entry width ---------------------------
|
|
1105
|
+
|
|
1106
|
+
/** Fixed overlay height so the TUI never repositions the panel. */
|
|
1107
|
+
private static readonly OVERLAY_LINES = 30;
|
|
1108
|
+
|
|
1109
|
+
override render(width: number): string[] {
|
|
1110
|
+
if (width !== this.lastWidth) {
|
|
1111
|
+
// Pre-clamp against the previous wrap so a width change can never
|
|
1112
|
+
// drive the rebuilds with a stale selection/offset (AC-P1-4.1/4.2).
|
|
1113
|
+
this.selectedIndex = clampSelectedIndex(
|
|
1114
|
+
this.selectedIndex,
|
|
1115
|
+
this.filteredRecords.length,
|
|
1116
|
+
);
|
|
1117
|
+
this.previewScrollOffset = clampPreviewOffset(
|
|
1118
|
+
this.previewScrollOffset,
|
|
1119
|
+
this.wrappedPreviewLines.length,
|
|
1120
|
+
PREVIEW_ROWS,
|
|
1121
|
+
);
|
|
1122
|
+
}
|
|
1123
|
+
this.lastWidth = width;
|
|
1124
|
+
this.rebuildListWithWidth(width);
|
|
1125
|
+
this.rebuildPreviewWithWidth(width);
|
|
1126
|
+
const raw = super.render(width);
|
|
1127
|
+
// Pad or trim to exactly OVERLAY_LINES so the overlay never shifts.
|
|
1128
|
+
const blank = " ".repeat(Math.max(1, width));
|
|
1129
|
+
while (raw.length < PromptHistorySelector.OVERLAY_LINES) raw.push(blank);
|
|
1130
|
+
return raw.slice(0, PromptHistorySelector.OVERLAY_LINES);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
// ---------------------------------------------------------------------------
|
|
1135
|
+
// Overlay glue
|
|
1136
|
+
// ---------------------------------------------------------------------------
|
|
1137
|
+
|
|
1138
|
+
type SelectorDone = (result: PromptRecord | null) => void;
|
|
1139
|
+
|
|
1140
|
+
type SelectorFactory = (
|
|
1141
|
+
tui: unknown,
|
|
1142
|
+
theme: unknown,
|
|
1143
|
+
keybindings: unknown,
|
|
1144
|
+
done: SelectorDone,
|
|
1145
|
+
) => PromptHistorySelector;
|
|
1146
|
+
|
|
1147
|
+
function castSelectorArgs(tui: unknown, theme: unknown): [TUI, Theme] {
|
|
1148
|
+
return [tui as TUI, theme as Theme];
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/** TUI handle captured when the selector overlay mounts. `terminal` feeds the sidebar overlay margin. */
|
|
1152
|
+
let selectorTui: { requestRender(): void; terminal?: unknown } | null = null;
|
|
1153
|
+
|
|
1154
|
+
/** Stored close callback for the currently-open overlay. Null when closed. */
|
|
1155
|
+
let activeOverlayClose: (() => void) | null = null;
|
|
1156
|
+
|
|
1157
|
+
function createPromptHistorySelectorFactory(
|
|
1158
|
+
records: PromptRecord[],
|
|
1159
|
+
onNotify?: SelectorNotify,
|
|
1160
|
+
store?: SelectorStore,
|
|
1161
|
+
): SelectorFactory {
|
|
1162
|
+
return (tui, theme, _keybindings, done) => {
|
|
1163
|
+
selectorTui = tui as { requestRender(): void; terminal?: unknown };
|
|
1164
|
+
const finish = (result: PromptRecord | null) => {
|
|
1165
|
+
activeOverlayClose = null;
|
|
1166
|
+
done(result);
|
|
1167
|
+
};
|
|
1168
|
+
// Expose close so the tool_call handler can dismiss the overlay.
|
|
1169
|
+
activeOverlayClose = () => finish(null);
|
|
1170
|
+
const [typedTui, typedTheme] = castSelectorArgs(tui, theme);
|
|
1171
|
+
return new PromptHistorySelector(
|
|
1172
|
+
typedTui,
|
|
1173
|
+
typedTheme,
|
|
1174
|
+
records,
|
|
1175
|
+
(record) => finish(record),
|
|
1176
|
+
() => finish(null),
|
|
1177
|
+
onNotify,
|
|
1178
|
+
store,
|
|
1179
|
+
);
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
async function runPromptHistorySelection(
|
|
1184
|
+
ctx: Pick<ExtensionCommandContext, "ui">,
|
|
1185
|
+
records: PromptRecord[],
|
|
1186
|
+
store?: SelectorStore,
|
|
1187
|
+
): Promise<PromptRecord | null> {
|
|
1188
|
+
const historyGlobals: PiHistoryGlobals = globalThis as Record<
|
|
1189
|
+
string,
|
|
1190
|
+
unknown
|
|
1191
|
+
>;
|
|
1192
|
+
return withExpandedHistoryGlobals(historyGlobals, async () =>
|
|
1193
|
+
ctx.ui.custom<PromptRecord | null>(
|
|
1194
|
+
createPromptHistorySelectorFactory(
|
|
1195
|
+
records,
|
|
1196
|
+
(message, level) => ctx.ui.notify(message, level),
|
|
1197
|
+
store,
|
|
1198
|
+
),
|
|
1199
|
+
{
|
|
1200
|
+
overlay: true,
|
|
1201
|
+
// pi-tui keeps the options object from showOverlay time, but calls
|
|
1202
|
+
// visible() on EVERY render pass before resolving the overlay layout
|
|
1203
|
+
// (compositeOverlays filters visible entries first), and re-reads
|
|
1204
|
+
// margin per layout resolution — the getter below therefore stays
|
|
1205
|
+
// live: resizing across the sidebar breakpoint re-seats the picker
|
|
1206
|
+
// while it stays open. While the gentle-shell fullscreen sidebar
|
|
1207
|
+
// paints, the margin confines width "100%" (and the bottom-center
|
|
1208
|
+
// anchor) to the editor column, less 1 column of padding; 0 keeps
|
|
1209
|
+
// the native full-window behavior.
|
|
1210
|
+
overlayOptions: () => {
|
|
1211
|
+
let rightMargin = editorOverlayMargin(selectorTui?.terminal);
|
|
1212
|
+
return {
|
|
1213
|
+
anchor: "bottom-center" as const,
|
|
1214
|
+
width: "100%" as const,
|
|
1215
|
+
offsetY: 5,
|
|
1216
|
+
get margin() {
|
|
1217
|
+
return rightMargin > 0 ? { right: rightMargin } : undefined;
|
|
1218
|
+
},
|
|
1219
|
+
visible: () => {
|
|
1220
|
+
rightMargin = editorOverlayMargin(selectorTui?.terminal);
|
|
1221
|
+
return true;
|
|
1222
|
+
},
|
|
1223
|
+
};
|
|
1224
|
+
},
|
|
1225
|
+
},
|
|
1226
|
+
),
|
|
1227
|
+
);
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
/** Build selector records from drain entries (shared by both scopes). */
|
|
1231
|
+
function recordsFromEntries(
|
|
1232
|
+
entries: Array<string | PromptEntry>,
|
|
1233
|
+
): PromptRecord[] {
|
|
1234
|
+
return buildPromptRecords(dedupePromptEntries(entries));
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
// ---------------------------------------------------------------------------
|
|
1238
|
+
// Open flow: capture gate → fail-closed drain → records → overlay
|
|
1239
|
+
// ---------------------------------------------------------------------------
|
|
1240
|
+
|
|
1241
|
+
type HistoryScope = "project" | "global";
|
|
1242
|
+
|
|
1243
|
+
/**
|
|
1244
|
+
* Per-load open flow over the slice-1 deps (env/root/cwd): the selector is
|
|
1245
|
+
* a pure store reader plus the explicit delete action, so it never
|
|
1246
|
+
* initializes the capture writer — the capture gate in openHistorySelector
|
|
1247
|
+
* runs before any store access and a capture-off session performs no
|
|
1248
|
+
* registry/writer/delete side effects on the open path.
|
|
1249
|
+
*/
|
|
1250
|
+
function createOpenFlow(
|
|
1251
|
+
env: NodeJS.ProcessEnv,
|
|
1252
|
+
configHome: string,
|
|
1253
|
+
root: string,
|
|
1254
|
+
cwd: string,
|
|
1255
|
+
) {
|
|
1256
|
+
/**
|
|
1257
|
+
* Scope drain for the selector: project scope drains the project's store
|
|
1258
|
+
* files; global scope is the store-only cross-project view (all project
|
|
1259
|
+
* dirs + the legacy global seed). Both apply the fail-closed tombstone
|
|
1260
|
+
* filter — the state dir is the store root itself (hidden.json contract).
|
|
1261
|
+
* The DrainResult is returned verbatim: `blocked` must stop the open flow
|
|
1262
|
+
* before any records build, and the selector's scope toggle surfaces it.
|
|
1263
|
+
*/
|
|
1264
|
+
function drainForScope(scope: HistoryScope): DrainResult {
|
|
1265
|
+
return scope === "project"
|
|
1266
|
+
? drainProject(root, cwd, 1000, root)
|
|
1267
|
+
: drainGlobal(root, 1000, root);
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
const store: SelectorStore = { root, cwd, drain: drainForScope };
|
|
1271
|
+
|
|
1272
|
+
async function openHistorySelector(
|
|
1273
|
+
ctx: Pick<ExtensionCommandContext, "ui">,
|
|
1274
|
+
): Promise<void> {
|
|
1275
|
+
// Capture gate (#1390) FIRST: with capture off the selector is a no-op —
|
|
1276
|
+
// no registry writes, no writer init, no store reads, no overlay.
|
|
1277
|
+
if (!captureEnabled(env, configHome)) {
|
|
1278
|
+
ctx.ui.notify(captureDisabledMessage(env, configHome), "warning");
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
// Store-only drain (user-directed): the selector reads the store files —
|
|
1283
|
+
// no live transcript merge. `blocked` (untrusted hidden.json) fails
|
|
1284
|
+
// CLOSED: surface the recovery message and stop before building records.
|
|
1285
|
+
const drained = drainForScope("project");
|
|
1286
|
+
if (drained.status === "blocked") {
|
|
1287
|
+
ctx.ui.notify(drained.message, "error");
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
const entries = drained.prompts;
|
|
1291
|
+
if (entries.length === 0) {
|
|
1292
|
+
// a22588fc empty-store policy: no history warns and skips the overlay.
|
|
1293
|
+
// A later slice changes this, not this one.
|
|
1294
|
+
ctx.ui.notify("No prompt history available.", "warning");
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
const records = recordsFromEntries(entries);
|
|
1299
|
+
const selected = await runPromptHistorySelection(ctx, records, store);
|
|
1300
|
+
if (selected) {
|
|
1301
|
+
// pasteToEditor routes through the editor's input pipeline (bracketed
|
|
1302
|
+
// paste), so the text renders immediately (a22588fc).
|
|
1303
|
+
ctx.ui.pasteToEditor(selected.text);
|
|
1304
|
+
// The overlay teardown can race the paste render: force one more
|
|
1305
|
+
// frame on the next tick so the editor box shows the text at once.
|
|
1306
|
+
setTimeout(() => selectorTui?.requestRender(), 0);
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
return { openHistorySelector };
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
export default function promptHistoryExtension(
|
|
1314
|
+
pi: ExtensionAPI,
|
|
1315
|
+
deps: HistoryDeps = {},
|
|
1316
|
+
): void {
|
|
1317
|
+
const env = deps.env ?? process.env;
|
|
1318
|
+
const configHome = deps.gentlePiConfigHome ?? gentlePiConfigHome(env);
|
|
1319
|
+
// Per-prompt gate: re-read so a Customize toggle applies live. A delegated
|
|
1320
|
+
// child never captures: its prompt is a delegation brief, not user history,
|
|
1321
|
+
// and the parent owns the store's init and GC (gentle-shell#1690).
|
|
1322
|
+
const capturing = () => env.GENTLE_PI_AGENTS_CHILD !== "1" && captureEnabled(env, configHome);
|
|
1323
|
+
// S9: a bundled Gentle Shell keeps the whole store in the agent home Pi runs
|
|
1324
|
+
// with; everywhere else it stays under ~/.pi/agent, where existing ones live.
|
|
1325
|
+
const bundledAgentDir = bundledGentlePiAgentHome(env);
|
|
1326
|
+
const root = deps.root ?? (bundledAgentDir === undefined ? PI_HISTORY_ROOT : join(bundledAgentDir, "history"));
|
|
1327
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
1328
|
+
const instanceId = deps.instanceId ?? randomUUID();
|
|
1329
|
+
const now = deps.now ?? Date.now;
|
|
1330
|
+
const agentDir = deps.agentDir ?? bundledAgentDir ?? AGENT_DIR;
|
|
1331
|
+
const sessionsRoot = deps.sessionsRoot ?? (bundledAgentDir === undefined ? SESSIONS_ROOT : join(bundledAgentDir, "sessions"));
|
|
1332
|
+
let writerState: SessionWriterState | null = null;
|
|
1333
|
+
|
|
1334
|
+
/**
|
|
1335
|
+
* One-time init per extension load: migrate legacy stores, register the
|
|
1336
|
+
* project, bootstrap the seed, then open this instance's exclusive file.
|
|
1337
|
+
*/
|
|
1338
|
+
const getWriter = (): SessionWriterState => {
|
|
1339
|
+
if (!writerState) {
|
|
1340
|
+
try {
|
|
1341
|
+
migrateLegacyStores(root, agentDir);
|
|
1342
|
+
} catch {
|
|
1343
|
+
// migration is best-effort; the gate keeps it one-shot
|
|
1344
|
+
}
|
|
1345
|
+
try {
|
|
1346
|
+
ensureRegistryEntry(root, cwd);
|
|
1347
|
+
} catch {
|
|
1348
|
+
// registry is advisory
|
|
1349
|
+
}
|
|
1350
|
+
try {
|
|
1351
|
+
bootstrapProjectSeed(
|
|
1352
|
+
root,
|
|
1353
|
+
cwd,
|
|
1354
|
+
sessionsRoot,
|
|
1355
|
+
500,
|
|
1356
|
+
root,
|
|
1357
|
+
);
|
|
1358
|
+
} catch {
|
|
1359
|
+
// bootstrap is a rebuildable cache
|
|
1360
|
+
}
|
|
1361
|
+
writerState = openSessionWriter(root, cwd, instanceId);
|
|
1362
|
+
}
|
|
1363
|
+
return writerState;
|
|
1364
|
+
};
|
|
1365
|
+
|
|
1366
|
+
// Warm migrate/registry/seed OFF the first-prompt path, but only for
|
|
1367
|
+
// opted-in sessions: with capture disabled nothing may be written —
|
|
1368
|
+
// no registry entry, no seed files, no store (docs/prompt-history.md).
|
|
1369
|
+
setImmediate(() => {
|
|
1370
|
+
if (!capturing()) return;
|
|
1371
|
+
try {
|
|
1372
|
+
getWriter();
|
|
1373
|
+
} catch {
|
|
1374
|
+
// init is best-effort; the lazy path retries on the next prompt
|
|
1375
|
+
}
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
// Persist every delivered user prompt (write-through, append-only JSONL),
|
|
1379
|
+
// but only for opted-in sessions — see captureEnabled(). The local
|
|
1380
|
+
// ExtensionAPI stub types handler args as unknown; narrow here.
|
|
1381
|
+
pi.on("before_agent_start", (...args: unknown[]) => {
|
|
1382
|
+
if (!capturing()) return;
|
|
1383
|
+
try {
|
|
1384
|
+
const event = args[0] as { prompt?: string } | undefined;
|
|
1385
|
+
appendSessionCapture(getWriter(), event?.prompt ?? "", now());
|
|
1386
|
+
} catch {
|
|
1387
|
+
// A capture failure must never break the agent loop or unregister
|
|
1388
|
+
// the handler - swallow and keep the next prompt capturable.
|
|
1389
|
+
}
|
|
1390
|
+
});
|
|
1391
|
+
|
|
1392
|
+
// Consolidate this project's store files on graceful shutdown (slice 6),
|
|
1393
|
+
// only for opted-in sessions: with capture off the store is never
|
|
1394
|
+
// rewritten. This instance's own capture file is never a merge candidate.
|
|
1395
|
+
pi.on("session_shutdown", () => {
|
|
1396
|
+
if (!capturing()) return;
|
|
1397
|
+
try {
|
|
1398
|
+
gcProjectDir(root, cwd, {
|
|
1399
|
+
keepFiles: [sessionFilePath(root, cwd, instanceId)],
|
|
1400
|
+
});
|
|
1401
|
+
} catch {
|
|
1402
|
+
// GC is best-effort and never blocks shutdown
|
|
1403
|
+
}
|
|
1404
|
+
});
|
|
1405
|
+
|
|
1406
|
+
// When a tool asks for user input while the history overlay is open,
|
|
1407
|
+
// dismiss the overlay so the tool can take over the UI.
|
|
1408
|
+
pi.on("tool_call", () => {
|
|
1409
|
+
activeOverlayClose?.();
|
|
1410
|
+
});
|
|
1411
|
+
|
|
1412
|
+
// Selector open flow (slice 3, stage 3): both entry points share it.
|
|
1413
|
+
const { openHistorySelector } = createOpenFlow(env, configHome, root, cwd);
|
|
1414
|
+
|
|
1415
|
+
pi.registerShortcut(SHORTCUT, {
|
|
1416
|
+
description: "Search prompt history",
|
|
1417
|
+
handler: async (ctx) => openHistorySelector(ctx),
|
|
1418
|
+
});
|
|
1419
|
+
|
|
1420
|
+
pi.registerCommand("history", {
|
|
1421
|
+
description: "Search prompt history",
|
|
1422
|
+
handler: async (_args, ctx) => openHistorySelector(ctx),
|
|
1423
|
+
});
|
|
1424
|
+
}
|