gentle-pi 3.7.0 → 4.0.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 +37 -6
- package/assets/agents/gentle-ai-explore.md +4 -4
- package/assets/agents/gentle-ai-verify.md +6 -4
- package/assets/agents/gentle-ai-worker.md +7 -9
- package/assets/orchestrator-delegation.md +40 -41
- package/assets/orchestrator-memory.md +1 -22
- package/assets/orchestrator-skills.md +1 -1
- package/assets/orchestrator.md +12 -24
- package/assets/support/strict-tdd-verify.md +4 -266
- package/assets/support/strict-tdd.md +8 -360
- package/bin/gentle-shell.mjs +254 -29
- package/docs/delegated-verification.md +26 -1
- package/docs/gentle-agents-activity.md +24 -0
- package/docs/gentle-shell.md +92 -28
- package/docs/native-authority-architecture.md +2 -2
- package/docs/prompt-history.md +280 -0
- package/docs/readme-reference.md +112 -206
- package/docs/telemetry.md +1 -1
- package/docs/yolo-mode.md +86 -0
- package/extensions/child-context.ts +26 -0
- package/extensions/child-safety.ts +23 -0
- package/extensions/gentle-agents.ts +429 -262
- package/extensions/gentle-ai.ts +781 -683
- package/extensions/gentle-shell.ts +1375 -88
- package/extensions/gentle-stats.ts +101 -0
- package/extensions/gentle-todo.ts +18 -7
- package/extensions/history/atomic-write.ts +38 -0
- package/extensions/history/hide-prompts.ts +183 -0
- package/extensions/history/index.ts +1419 -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/quiet-tools.ts +179 -87
- package/extensions/resume-hint.ts +60 -0
- package/extensions/skill-registry.ts +16 -12
- package/extensions/startup-banner.ts +60 -31
- package/lib/agent-assets.ts +604 -0
- package/lib/agent-profile-pin.ts +12 -0
- package/lib/agents-message-delivery.ts +181 -0
- package/lib/agents-protocol.ts +60 -0
- package/lib/agents-runner.ts +96 -98
- package/lib/agents-view.ts +26 -3
- package/lib/agents-widget.ts +16 -9
- package/lib/append-system-prompt.ts +21 -0
- package/lib/bounded-writer-admission.ts +147 -0
- package/lib/card-style-policy.ts +60 -0
- package/lib/child-context-files.ts +166 -0
- package/lib/codemode-renderer.ts +185 -0
- package/lib/command-palette-catalog.ts +3 -9
- package/lib/command-palette.ts +25 -14
- package/lib/destructive-command-guard.ts +144 -0
- package/lib/gentle-ai-elapsed-store.ts +87 -0
- package/lib/gentle-ai-renderer.ts +196 -39
- package/lib/gentle-shell-launcher.ts +24 -13
- package/lib/gentle-shell-resume-hint.ts +176 -0
- package/lib/history-capture-policy.ts +95 -0
- package/lib/model-routing-authority.ts +5 -1
- package/lib/nan-provider.ts +227 -0
- package/lib/native-review-cli.ts +49 -108
- package/lib/odd-phase-inference.ts +231 -0
- package/lib/odd-phase.ts +141 -0
- package/lib/overlay-repaint.ts +26 -0
- package/lib/pi-tui-keys.ts +53 -0
- package/lib/review-candidate-view-owner.ts +67 -17
- package/lib/review-candidate-view.ts +112 -25
- package/lib/review-reminder-receipt.ts +48 -8
- package/lib/review-risk-assessment.ts +156 -11
- package/lib/review-sidebar-state.ts +223 -0
- package/lib/selection-engine.ts +515 -0
- package/lib/session-messaging-grants.ts +135 -0
- package/lib/session-worktree-registry.ts +14 -2
- package/lib/shell-bar.ts +179 -24
- package/lib/shell-card.ts +287 -18
- package/lib/shell-changes-view.ts +2 -1
- package/lib/shell-prompt.ts +102 -6
- package/lib/shell-sidebar-layout.ts +78 -14
- package/lib/shell-sidebar.ts +15 -1
- package/lib/shell-todo.ts +23 -13
- package/lib/shell-usage-view.ts +9 -4
- package/lib/shell-usage.ts +66 -10
- package/lib/stats-collector.ts +381 -0
- package/lib/stats-view.ts +431 -0
- package/lib/theme-customization.ts +52 -0
- package/lib/vim-editor-adapter.ts +379 -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 +108 -0
- package/lib/visual-customize-view.ts +330 -0
- package/lib/visual-profiles.ts +228 -0
- package/lib/yolo-session-policy.ts +240 -0
- package/package.json +20 -8
- package/runtime/gentle-shell-launcher.mjs +23 -12
- package/runtime/gentle-shell-resume-hint.mjs +177 -0
- package/runtime/native-review-cli.mjs +49 -108
- package/runtime/review-risk-assessment.mjs +154 -9
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/gentle-ai-installer.mjs +14 -13
- package/scripts/mirror-odd-routing.mjs +2 -2
- package/scripts/run-test-suite.mjs +76 -0
- package/scripts/test-packed-runner.mjs +31 -14
- package/scripts/verify-package-files.mjs +11 -22
- 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 +9 -15
- 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-profiles.test.ts +18 -0
- package/tests/agents-fake-child.ts +2 -2
- package/tests/agents-message-delivery.test.ts +106 -0
- package/tests/agents-protocol.test.ts +40 -0
- package/tests/agents-runner.test.ts +378 -89
- 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/autonomous-guard.test.ts +69 -1
- package/tests/bounded-writer-admission.test.ts +95 -0
- package/tests/branch-pr-skill.test.ts +43 -0
- package/tests/card-style-policy.test.ts +55 -0
- package/tests/chained-pr-skill.test.ts +124 -0
- package/tests/child-context-files.test.ts +255 -0
- package/tests/child-safety.test.ts +82 -0
- package/tests/codemode-rendering.test.ts +491 -0
- package/tests/command-palette.test.ts +39 -3
- package/tests/delegated-key-learnings-contract.test.ts +0 -76
- package/tests/destructive-command-guard.test.ts +84 -0
- package/tests/devbinary/native-review-parity.devtest.ts +170 -2
- package/tests/devbinary/non-git-subagent-bootstrap.devtest.ts +193 -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 +54 -0
- package/tests/gentle-agents.test.ts +1047 -252
- package/tests/gentle-ai-binary.test.ts +3 -3
- package/tests/gentle-ai-elapsed-store.test.ts +68 -0
- package/tests/gentle-ai-installer.test.ts +68 -54
- package/tests/gentle-ai-renderer.test.ts +487 -8
- package/tests/gentle-ai.test.ts +288 -65
- package/tests/gentle-card-text.ts +2 -1
- package/tests/gentle-shell-bin.test.ts +651 -114
- package/tests/gentle-shell-launcher.test.ts +99 -52
- package/tests/gentle-shell-resume-hint.test.ts +270 -0
- package/tests/gentle-shell.test.ts +3839 -187
- package/tests/gentle-stats.test.ts +152 -0
- package/tests/gentle-theme.test.ts +4 -1
- package/tests/gentle-todo.test.ts +80 -8
- package/tests/history-atomic-write.test.ts +57 -0
- package/tests/history-capture-policy.test.ts +102 -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 +29 -19
- package/tests/issue-creation-skill.test.ts +61 -0
- package/tests/model-routing-authority.test.ts +16 -0
- package/tests/nan-provider.test.ts +471 -0
- package/tests/native-review-capability-contract.test.ts +7 -1
- package/tests/native-review-cli.test.ts +6 -120
- package/tests/native-review-parity-runtime.test.ts +100 -3
- package/tests/odd-integration.test.ts +67 -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 +11 -6
- package/tests/odd-routing-contract.test.ts +86 -35
- package/tests/orchestrator-budget.test.ts +14 -39
- package/tests/orchestrator-rdd-ownership.test.ts +3 -3
- package/tests/overlay-repaint.test.ts +74 -0
- package/tests/package-manifest.test.ts +252 -115
- package/tests/packed-runner-owned-path.test.ts +46 -0
- package/tests/persona-single-channel.test.ts +6 -6
- package/tests/provider-defect-handoff.test.ts +3 -11
- package/tests/quiet-bash-runtime.test.ts +76 -0
- package/tests/quiet-tool-rendering.test.ts +409 -184
- package/tests/rdd-aware-verification-contract.test.ts +76 -1
- package/tests/rdd-status-line.test.ts +9 -4
- package/tests/resume-hint-extension.test.ts +122 -0
- package/tests/review-agent-end-preflight.test.ts +176 -12
- package/tests/review-candidate-owner-retry.test.ts +22 -1
- package/tests/review-candidate-view.test.ts +298 -0
- package/tests/review-contract-prompt.test.ts +108 -43
- package/tests/review-controller-lock-status.test.ts +0 -1
- package/tests/review-controller-native-routing.test.ts +611 -5
- package/tests/review-controller-workspace-root.test.ts +163 -4
- package/tests/review-host-relay-routing.test.ts +338 -2
- package/tests/review-integration-v2-forward.test.ts +200 -0
- package/tests/review-ledger-contract.test.ts +10 -34
- package/tests/review-reminder-receipt.test.ts +47 -1
- package/tests/review-risk-assessment.test.ts +498 -6
- package/tests/review-sidebar-state.test.ts +402 -0
- package/tests/run-test-suite.test.ts +124 -0
- package/tests/runtime-harness.mjs +145 -786
- package/tests/runtime-metrics-children.test.ts +16 -23
- package/tests/selection-engine.test.ts +421 -0
- package/tests/session-messaging-grants.test.ts +255 -0
- package/tests/session-worktree-registry.test.ts +77 -0
- package/tests/shell-bar.test.ts +382 -1
- package/tests/shell-card.test.ts +353 -1
- package/tests/shell-changes-view.test.ts +52 -0
- package/tests/shell-prompt.test.ts +94 -2
- package/tests/shell-sidebar-layout.test.ts +325 -21
- package/tests/shell-sidebar-scroll-benchmark.test.ts +255 -0
- package/tests/shell-todo.test.ts +87 -1
- package/tests/shell-usage-view.test.ts +27 -0
- package/tests/shell-usage.test.ts +73 -0
- package/tests/skill-registry.test.ts +50 -1
- package/tests/startup-banner.test.ts +130 -2
- package/tests/stats-collector.test.ts +195 -0
- package/tests/stats-view.test.ts +202 -0
- package/tests/telemetry-trigger.test.ts +81 -20
- package/tests/theme-customization.test.ts +72 -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 +110 -0
- package/tests/visual-customize-view.test.ts +418 -0
- package/tests/visual-profiles.test.ts +87 -0
- package/tests/yolo-customize.test.ts +256 -0
- package/tests/yolo-mode-runtime.test.ts +161 -0
- package/tests/yolo-mode.test.ts +261 -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
|
@@ -0,0 +1,804 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
|
+
import { dirname, resolve } from "node:path";
|
|
8
|
+
import { CURSOR_MARKER, Editor, visibleWidth } from "@earendil-works/pi-tui";
|
|
9
|
+
import { AUDITED_PI_EDITOR_VERSIONS, createVimEditorAdapter, isAuditedPiEditorVersion } from "../lib/vim-editor-adapter.ts";
|
|
10
|
+
import { resolveVimRuntime, VIM_AGENT_INDEX_PATTERN, VIM_CLI_ENTRY_PATTERN } from "../extensions/gentle-shell.ts";
|
|
11
|
+
import { VimOperatorEngine } from "../lib/vim-operator-engine.ts";
|
|
12
|
+
import { VimVisualEngine } from "../lib/vim-visual-engine.ts";
|
|
13
|
+
|
|
14
|
+
function editor(): Editor {
|
|
15
|
+
return new Editor({ terminal: { rows: 24 }, requestRender() {} } as never, { borderColor: (s: string) => s } as never);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Decode the effective reverse-video state at each displayed cell, rather
|
|
19
|
+
// than treating the presence of an SGR 7 anywhere on a row as selection.
|
|
20
|
+
function paintedCells(row: string): Array<{ char: string; inverse: boolean }> {
|
|
21
|
+
const cells: Array<{ char: string; inverse: boolean }> = [];
|
|
22
|
+
let inverse = false;
|
|
23
|
+
const tokens = row.match(/\x1b\[[0-9;]*m|\x1b_pi:c\x07|[^\x1b]/g) ?? [];
|
|
24
|
+
assert.equal(tokens.join(""), row, "only known editor escape sequences are present");
|
|
25
|
+
for (const token of tokens) {
|
|
26
|
+
if (token === CURSOR_MARKER) continue;
|
|
27
|
+
if (token.startsWith("\x1b[")) {
|
|
28
|
+
const codes = token.slice(2, -1).split(";").map(Number);
|
|
29
|
+
for (let i = 0; i < codes.length; i++) {
|
|
30
|
+
if ((codes[i] === 38 || codes[i] === 48 || codes[i] === 58) && codes[i + 1] === 2) i += 4;
|
|
31
|
+
else if ((codes[i] === 38 || codes[i] === 48 || codes[i] === 58) && codes[i + 1] === 5) i += 2;
|
|
32
|
+
else if (codes[i] === 0 || codes[i] === 27) inverse = false;
|
|
33
|
+
else if (codes[i] === 7) inverse = true;
|
|
34
|
+
}
|
|
35
|
+
} else cells.push({ char: token, inverse });
|
|
36
|
+
}
|
|
37
|
+
return cells;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function selectedColumns(row: string): number[] {
|
|
41
|
+
return paintedCells(row).flatMap((cell, col) => cell.inverse ? [col] : []);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Exercise both module graphs from the exact declared development install.
|
|
45
|
+
// No user PATH shim or optional global install is needed for bundled identity.
|
|
46
|
+
const target = fileURLToPath(new URL("../node_modules/@earendil-works/pi-coding-agent/dist/bundle/cli.js", import.meta.url));
|
|
47
|
+
const skip0871 = undefined;
|
|
48
|
+
// Development resolves Pi through an open devDependency range, so the version
|
|
49
|
+
// under test is the installed TUI metadata, never the manifest specifier.
|
|
50
|
+
const INSTALLED_PI = (createRequire(import.meta.url)("@earendil-works/pi-tui/package.json") as { version: string }).version;
|
|
51
|
+
|
|
52
|
+
let runtimeTui: typeof import("@earendil-works/pi-tui") | undefined;
|
|
53
|
+
let runtimeAgent: typeof import("@earendil-works/pi-coding-agent") | undefined;
|
|
54
|
+
let runtimeTuiPackage: { version: string } | undefined;
|
|
55
|
+
let runtimeAgentPackage: { version: string } | undefined;
|
|
56
|
+
let bundledAgent: typeof import("@earendil-works/pi-coding-agent") | undefined;
|
|
57
|
+
let BundledEditor: typeof Editor | undefined;
|
|
58
|
+
let virtualAgent: { CustomEditor?: typeof import("@earendil-works/pi-coding-agent").CustomEditor; Editor?: typeof Editor } | undefined;
|
|
59
|
+
let virtualTui: { CustomEditor?: typeof import("@earendil-works/pi-coding-agent").CustomEditor; Editor?: typeof Editor } | undefined;
|
|
60
|
+
|
|
61
|
+
if (target) {
|
|
62
|
+
const runtimeRequire = createRequire(realpathSync(target));
|
|
63
|
+
const runtimeTuiPath = runtimeRequire.resolve("@earendil-works/pi-tui");
|
|
64
|
+
const runtimeAgentPath = resolve(dirname(realpathSync(target)), "../index.js");
|
|
65
|
+
runtimeTuiPackage = runtimeRequire(resolve(dirname(runtimeTuiPath), "../package.json")) as { version: string };
|
|
66
|
+
runtimeAgentPackage = runtimeRequire(resolve(dirname(runtimeAgentPath), "../package.json")) as { version: string };
|
|
67
|
+
runtimeTui = await import(pathToFileURL(runtimeTuiPath).href) as typeof import("@earendil-works/pi-tui");
|
|
68
|
+
runtimeAgent = await import(pathToFileURL(runtimeAgentPath).href) as typeof import("@earendil-works/pi-coding-agent");
|
|
69
|
+
// The PATH CLI runs the bundled graph, not the package's unbundled dist/index.js.
|
|
70
|
+
bundledAgent = await import(pathToFileURL(resolve(dirname(realpathSync(target)), "index.js")).href) as typeof import("@earendil-works/pi-coding-agent");
|
|
71
|
+
BundledEditor = Object.getPrototypeOf(bundledAgent.CustomEditor.prototype)?.constructor as typeof Editor;
|
|
72
|
+
// Follow the public bundle's actual imported module chain to the virtual
|
|
73
|
+
// loader; no hash or chunk filename is assumed by the compatibility gate.
|
|
74
|
+
const bundleIndex = resolve(dirname(realpathSync(target)), "index.js");
|
|
75
|
+
const bundleImports = [...readFileSync(bundleIndex, "utf8").matchAll(/from"(\.\/chunks\/[^\"]+\.js)"/g)];
|
|
76
|
+
const virtualPaths = bundleImports.flatMap((match) => {
|
|
77
|
+
const source = readFileSync(resolve(dirname(bundleIndex), match[1]!), "utf8");
|
|
78
|
+
return [...source.matchAll(/import\("(\.\/virtual-modules-[^\"]+\.js)"\)/g)]
|
|
79
|
+
.map((found) => resolve(dirname(bundleIndex), "chunks", found[1]!));
|
|
80
|
+
});
|
|
81
|
+
assert.equal(new Set(virtualPaths).size, 1, "bundle must have exactly one reachable virtual module map");
|
|
82
|
+
const virtualModule = await import(pathToFileURL(virtualPaths[0]!).href) as {
|
|
83
|
+
VIRTUAL_MODULES: Record<string, { CustomEditor?: typeof bundledAgent.CustomEditor; Editor?: typeof Editor }>;
|
|
84
|
+
};
|
|
85
|
+
virtualAgent = virtualModule.VIRTUAL_MODULES["@earendil-works/pi-coding-agent"];
|
|
86
|
+
virtualTui = virtualModule.VIRTUAL_MODULES["@earendil-works/pi-tui"];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
test("local bundled virtual host is admitted only through its exact bundled class and version", { skip: skip0871 }, () => {
|
|
90
|
+
assert.equal(bundledAgent.VERSION, INSTALLED_PI);
|
|
91
|
+
assert.notEqual(bundledAgent.CustomEditor, runtimeAgent.CustomEditor);
|
|
92
|
+
assert.notEqual(BundledEditor, runtimeTui.Editor);
|
|
93
|
+
assert.equal(virtualAgent?.CustomEditor, bundledAgent.CustomEditor);
|
|
94
|
+
assert.equal(virtualTui?.Editor, BundledEditor);
|
|
95
|
+
assert.equal(Object.getPrototypeOf(bundledAgent.CustomEditor.prototype), BundledEditor.prototype);
|
|
96
|
+
assert.deepEqual(resolveVimRuntime(target, bundledAgent.CustomEditor), { version: INSTALLED_PI, editorClass: BundledEditor });
|
|
97
|
+
assert.equal(resolveVimRuntime("/nonexistent/cli.js", bundledAgent.CustomEditor), undefined);
|
|
98
|
+
const e = new bundledAgent.CustomEditor({ terminal: { rows: 6, columns: 30 }, requestRender() {} } as never,
|
|
99
|
+
{ borderColor: (s: string) => s } as never, { matches: () => false } as never);
|
|
100
|
+
e.setText("alpha beta");
|
|
101
|
+
const adapter = createVimEditorAdapter(e, INSTALLED_PI, BundledEditor, bundledAgent.VERSION);
|
|
102
|
+
adapter.move({ line: 0, col: 6 });
|
|
103
|
+
adapter.replace({ line: 0, col: 6 }, { line: 0, col: 10 }, "snow");
|
|
104
|
+
assert.equal(e.getText(), "alpha snow");
|
|
105
|
+
adapter.undo();
|
|
106
|
+
assert.equal(e.getText(), "alpha beta");
|
|
107
|
+
e.setText("[paste #1 3 chars] tail");
|
|
108
|
+
(e as unknown as { pastes: Map<number, string> }).pastes.set(1, "abc");
|
|
109
|
+
assert.ok(!adapter.motionBoundaries()[0]!.includes(4));
|
|
110
|
+
const rows = e.render(30);
|
|
111
|
+
assert.ok(adapter.renderSelection(30, { line: 0, col: 0 }, { line: 0, col: 18 }, rows).some(row => row.includes("\x1b[7m")));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("bundled installed editor accepts visual c/p ranges at Unicode and multiline endpoints", { skip: skip0871 }, () => {
|
|
115
|
+
const e = new bundledAgent.CustomEditor({ terminal: { rows: 6, columns: 30 }, requestRender() {} } as never,
|
|
116
|
+
{ borderColor: (s: string) => s } as never, { matches: () => false } as never);
|
|
117
|
+
const adapter = createVimEditorAdapter(e, INSTALLED_PI, BundledEditor, bundledAgent.VERSION);
|
|
118
|
+
const visual = new VimVisualEngine();
|
|
119
|
+
const operator = new VimOperatorEngine();
|
|
120
|
+
e.setText("👩💻é\n雪");
|
|
121
|
+
adapter.move({ line: 0, col: 0 });
|
|
122
|
+
visual.start("char", e.getCursor());
|
|
123
|
+
const yankRange = visual.range(e.getText(), e.getCursor(), adapter.motionBoundaries())!;
|
|
124
|
+
operator.visualRegister(adapter.readRange(yankRange.start, yankRange.end), false);
|
|
125
|
+
visual.cancel();
|
|
126
|
+
adapter.move({ line: 1, col: 0 });
|
|
127
|
+
visual.start("char", e.getCursor());
|
|
128
|
+
const pasteRange = visual.range(e.getText(), e.getCursor(), adapter.motionBoundaries())!;
|
|
129
|
+
const selected = adapter.readRange(pasteRange.start, pasteRange.end);
|
|
130
|
+
assert.equal(selected, "雪");
|
|
131
|
+
const paste = operator.visualEdit("p", e.getText(), pasteRange).edit!;
|
|
132
|
+
adapter.replace(paste.start, paste.end, paste.text);
|
|
133
|
+
adapter.move(paste.cursor);
|
|
134
|
+
operator.visualRegister(selected, false);
|
|
135
|
+
assert.equal(e.getText(), "👩💻é\n👩💻");
|
|
136
|
+
visual.cancel();
|
|
137
|
+
visual.start("char", e.getCursor());
|
|
138
|
+
const changeRange = visual.range(e.getText(), e.getCursor(), adapter.motionBoundaries())!;
|
|
139
|
+
const change = operator.visualEdit("c", e.getText(), changeRange).edit!;
|
|
140
|
+
adapter.replace(change.start, change.end, change.text);
|
|
141
|
+
adapter.move(change.cursor);
|
|
142
|
+
assert.equal(e.getText(), "👩💻é\n");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("bundled installed full-line characterwise visual c/p stages preserve host rows and autocomplete", { skip: skip0871 }, () => {
|
|
146
|
+
for (const command of ["c", "p"] as const) {
|
|
147
|
+
const e = new bundledAgent.CustomEditor({ terminal: { rows: 6, columns: 30 }, requestRender() {} } as never,
|
|
148
|
+
{ borderColor: (s: string) => s } as never, { matches: () => false } as never);
|
|
149
|
+
const adapter = createVimEditorAdapter(e, INSTALLED_PI, BundledEditor, bundledAgent.VERSION);
|
|
150
|
+
const visual = new VimVisualEngine();
|
|
151
|
+
const operator = new VimOperatorEngine();
|
|
152
|
+
e.setText("asdf asdf asd f");
|
|
153
|
+
adapter.move({ line: 0, col: 0 });
|
|
154
|
+
visual.start("char", e.getCursor());
|
|
155
|
+
adapter.move({ line: 0, col: e.getText().length });
|
|
156
|
+
const range = visual.range(e.getText(), e.getCursor(), adapter.motionBoundaries())!;
|
|
157
|
+
assert.equal(range.linewise, false);
|
|
158
|
+
assert.equal(adapter.readRange(range.start, range.end), "asdf asdf asd f");
|
|
159
|
+
const frame = e.render(30);
|
|
160
|
+
assert.equal(adapter.renderSelection(30, range.start, range.end, frame).length, frame.length);
|
|
161
|
+
if (command === "p") operator.visualRegister("saved", false);
|
|
162
|
+
(e as unknown as { autocompleteState: unknown }).autocompleteState = "force";
|
|
163
|
+
const edit = operator.visualEdit(command, e.getText(), range).edit!;
|
|
164
|
+
if (edit.insert) adapter.beginInsertSession();
|
|
165
|
+
adapter.replace(edit.start, edit.end, edit.text);
|
|
166
|
+
adapter.move(edit.cursor);
|
|
167
|
+
assert.equal(e.getText(), command === "c" ? "" : "saved");
|
|
168
|
+
assert.equal((e as unknown as { autocompleteState: unknown }).autocompleteState, null);
|
|
169
|
+
assert.ok(e.render(30).length > 0);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("runtime identity resolves only the matching installed coding-agent/TUI pair", { skip: skip0871 }, () => {
|
|
174
|
+
assert.deepEqual(resolveVimRuntime(target, runtimeAgent.CustomEditor), { version: INSTALLED_PI, editorClass: runtimeTui.Editor });
|
|
175
|
+
assert.deepEqual(resolveVimRuntime(target), { version: INSTALLED_PI, editorClass: Editor }, "the declared local pair retains its own constructor identity");
|
|
176
|
+
// A virtual-module Editor alias can be the host class, so only the
|
|
177
|
+
// matching installed pair may certify its version.
|
|
178
|
+
assert.equal(runtimeAgentPackage.version, INSTALLED_PI);
|
|
179
|
+
assert.deepEqual(resolveVimRuntime(target, runtimeAgent.CustomEditor), { version: runtimeTuiPackage.version, editorClass: runtimeTui.Editor });
|
|
180
|
+
assert.equal(resolveVimRuntime(target, class Impostor extends runtimeTui.Editor {} as typeof runtimeAgent.CustomEditor), undefined);
|
|
181
|
+
assert.deepEqual(resolveVimRuntime("/nonexistent/cli.js", runtimeAgent.CustomEditor), { version: INSTALLED_PI, editorClass: Editor });
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
function assertInstalledPiPairBehavior(version: string, EditorClass: typeof Editor, CustomClass: { prototype: unknown } | undefined, verifiedVersion?: string): void {
|
|
185
|
+
assert.equal(CustomClass ? Object.getPrototypeOf(CustomClass.prototype) : EditorClass.prototype, EditorClass.prototype);
|
|
186
|
+
const e = new EditorClass({ terminal: { rows: 6, columns: 22 }, requestRender() {} } as never, { borderColor: (s: string) => s } as never);
|
|
187
|
+
e.setText("alpha beta\nthird line");
|
|
188
|
+
const adapter = createVimEditorAdapter(e, version, EditorClass, verifiedVersion);
|
|
189
|
+
adapter.move({ line: 0, col: 6 });
|
|
190
|
+
adapter.replace({ line: 0, col: 6 }, { line: 0, col: 10 }, "snow");
|
|
191
|
+
assert.equal(e.getText(), "alpha snow\nthird line");
|
|
192
|
+
adapter.undo();
|
|
193
|
+
assert.equal(e.getText(), "alpha beta\nthird line");
|
|
194
|
+
e.addToHistory("earlier draft");
|
|
195
|
+
e.setText("");
|
|
196
|
+
e.handleInput(`\x1b[200~${"z".repeat(1001)}\x1b[201~`);
|
|
197
|
+
e.insertTextAtCursor("\ntail");
|
|
198
|
+
const pastedText = e.getText();
|
|
199
|
+
const expandedText = e.getExpandedText();
|
|
200
|
+
adapter.replace({ line: 1, col: 0 }, { line: 1, col: 4 }, "snow");
|
|
201
|
+
assert.equal(e.getExpandedText(), expandedText.replace("tail", "snow"), `${version}: real paste survives edit`);
|
|
202
|
+
adapter.undo();
|
|
203
|
+
assert.equal(e.getText(), pastedText, `${version}: undo restores collapsed marker`);
|
|
204
|
+
assert.equal(e.getExpandedText(), expandedText, `${version}: undo restores paste registry`);
|
|
205
|
+
e.setText("");
|
|
206
|
+
e.handleInput("\x1b[A");
|
|
207
|
+
assert.equal(e.getText(), "earlier draft", `${version}: history survives private edit`);
|
|
208
|
+
e.setText("[paste #1 3 chars] wrapping text\nthird line");
|
|
209
|
+
(e as unknown as { pastes: Map<number, string> }).pastes.set(1, "abc");
|
|
210
|
+
assert.ok(!adapter.motionBoundaries()[0]!.includes(4), `${version}: paste marker atomic`);
|
|
211
|
+
e.focused = true;
|
|
212
|
+
const rows = e.render(18);
|
|
213
|
+
const selected = adapter.renderSelection(18, { line: 0, col: 0 }, { line: 0, col: "[paste #1 3 chars]".length }, rows);
|
|
214
|
+
assert.ok(selected.some((row: string) => row.includes("\x1b[7m")), `${version}: selection`);
|
|
215
|
+
assert.ok(selected.join("").includes(CURSOR_MARKER), `${version}: cursor`);
|
|
216
|
+
assert.ok((e as unknown as { scrollOffset: number }).scrollOffset >= 0);
|
|
217
|
+
assert.ok(rows.length > 3, `${version}: wrapped frame`);
|
|
218
|
+
const privateEditor = e as unknown as { autocompleteState: string; autocompleteList: { render(width: number): string[] }; scrollOffset: number };
|
|
219
|
+
privateEditor.autocompleteState = "force";
|
|
220
|
+
privateEditor.autocompleteList = { render: () => ["completion"] };
|
|
221
|
+
assert.ok(e.render(18).some((row: string) => row.includes("completion")), `${version}: autocomplete renders after frame`);
|
|
222
|
+
privateEditor.autocompleteState = "";
|
|
223
|
+
e.setText("repeat ".repeat(60));
|
|
224
|
+
adapter.move({ line: 0, col: 0 });
|
|
225
|
+
e.render(18);
|
|
226
|
+
adapter.move({ line: 0, col: e.getText().length });
|
|
227
|
+
const scrolled = e.render(18);
|
|
228
|
+
assert.ok(privateEditor.scrollOffset > 0, `${version}: scrolled wraps`);
|
|
229
|
+
assert.equal(adapter.renderSelection(18, { line: 0, col: 0 }, { line: 0, col: 6 }, scrolled).length, scrolled.length);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
test("actual bundled Pi 1.0.0 proves editing, undo, paste, selection, wrap and autocomplete", () => {
|
|
233
|
+
assert.equal(bundledAgent.VERSION, "1.0.0");
|
|
234
|
+
assertInstalledPiPairBehavior(bundledAgent.VERSION, BundledEditor, bundledAgent.CustomEditor, bundledAgent.VERSION);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("installed audited Pi local pair proves version, constructor identity, editing, paste, selection, wrap and autocomplete", () => {
|
|
238
|
+
assertInstalledPiPairBehavior(INSTALLED_PI, Editor, undefined);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("resolveVimRuntime's default entry resolves the declared local audited install without any PATH Pi", () => {
|
|
242
|
+
assert.deepEqual(resolveVimRuntime(), { version: INSTALLED_PI, editorClass: Editor });
|
|
243
|
+
assert.deepEqual(resolveVimRuntime("/nonexistent/cli.js"), { version: INSTALLED_PI, editorClass: Editor });
|
|
244
|
+
assert.deepEqual(resolveVimRuntime("C:\\nonexistent\\cli.js"), { version: INSTALLED_PI, editorClass: Editor });
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("resolveVimRuntime path patterns admit Windows and POSIX separators and reject impostors", () => {
|
|
248
|
+
assert.equal(VIM_CLI_ENTRY_PATTERN.test("/opt/pi/dist/bundle/cli.js"), true);
|
|
249
|
+
assert.equal(VIM_CLI_ENTRY_PATTERN.test("C:\\pi\\dist\\bundle\\cli.js"), true);
|
|
250
|
+
assert.equal(VIM_CLI_ENTRY_PATTERN.test("dist/bundle/cli.js"), true);
|
|
251
|
+
assert.equal(VIM_CLI_ENTRY_PATTERN.test("dist\\bundle\\cli.js"), true);
|
|
252
|
+
assert.equal(VIM_CLI_ENTRY_PATTERN.test("/opt/pi/other-dist/bundle/cli.js"), false);
|
|
253
|
+
assert.equal(VIM_CLI_ENTRY_PATTERN.test("C:\\pi\\other-dist\\bundle\\cli.js"), false);
|
|
254
|
+
assert.equal(VIM_CLI_ENTRY_PATTERN.test("/opt/pi/dist/bundle/cli.js.map"), false);
|
|
255
|
+
|
|
256
|
+
assert.equal(VIM_AGENT_INDEX_PATTERN.test("/node_modules/@earendil-works/pi-coding-agent/dist/index.js"), true);
|
|
257
|
+
assert.equal(VIM_AGENT_INDEX_PATTERN.test("C:\\node_modules\\@earendil-works\\pi-coding-agent\\dist\\index.js"), true);
|
|
258
|
+
assert.equal(VIM_AGENT_INDEX_PATTERN.test("dist/index.js"), true);
|
|
259
|
+
assert.equal(VIM_AGENT_INDEX_PATTERN.test("dist\\index.js"), true);
|
|
260
|
+
assert.equal(VIM_AGENT_INDEX_PATTERN.test("/node_modules/@earendil-works/pi-coding-agent/notdist/index.js"), false);
|
|
261
|
+
assert.equal(VIM_AGENT_INDEX_PATTERN.test("C:\\node_modules\\@earendil-works\\pi-coding-agent\\notdist\\index.js"), false);
|
|
262
|
+
assert.equal(VIM_AGENT_INDEX_PATTERN.test("/dist/index.js.map"), false);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("resolveVimRuntime resolves local bundle cli entrypoint when provided", () => {
|
|
266
|
+
const localCli = resolve("node_modules/@earendil-works/pi-coding-agent/dist/bundle/cli.js");
|
|
267
|
+
if (existsSync(localCli)) {
|
|
268
|
+
assert.deepEqual(resolveVimRuntime(localCli), { version: INSTALLED_PI, editorClass: Editor });
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("audited Pi editor releases are one frozen exact allowlist that includes the installed TUI", () => {
|
|
273
|
+
assert.deepEqual([...AUDITED_PI_EDITOR_VERSIONS], ["0.99.1", "0.99.2", "1.0.0"]);
|
|
274
|
+
assert.equal(INSTALLED_PI, "1.0.0", "this audit must exercise actual Pi 1.0.0, not a fabricated release");
|
|
275
|
+
assert.ok(Object.isFrozen(AUDITED_PI_EDITOR_VERSIONS));
|
|
276
|
+
assert.throws(() => (AUDITED_PI_EDITOR_VERSIONS as unknown as string[]).push("0.99.3"), TypeError);
|
|
277
|
+
assert.ok(isAuditedPiEditorVersion(INSTALLED_PI), `installed pi-tui ${INSTALLED_PI} must be audited`);
|
|
278
|
+
for (const unknown of ["0.99.3", "0.99.10", "0.99", "v0.99.2", " 0.99.2", "0.99.2-rc.1", ">=0.99.2", "1.0.1", ">=1.0.0", "v1.0.0", " 1.0.0", "1.0.0-rc.1", undefined, 99.2]) {
|
|
279
|
+
assert.equal(isAuditedPiEditorVersion(unknown), false, String(unknown));
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// A fabricated package root reuses the real installed classes but declares its
|
|
284
|
+
// own release metadata, isolating the audited-version allowlist from identity.
|
|
285
|
+
function fabricatedPiCli(t: test.TestContext, version: string, tuiVersion = version, bundledVersion = version): string {
|
|
286
|
+
const root = realpathSync(mkdtempSync(resolve(tmpdir(), "gentle-vim-audit-")));
|
|
287
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
288
|
+
const realBundle = resolve(dirname(realpathSync(target)), "index.js");
|
|
289
|
+
const realAgent = resolve(dirname(realpathSync(target)), "../index.js");
|
|
290
|
+
const realTui = createRequire(realpathSync(target)).resolve("@earendil-works/pi-tui");
|
|
291
|
+
const tuiRoot = resolve(root, "node_modules", "@earendil-works", "pi-tui");
|
|
292
|
+
mkdirSync(resolve(root, "dist", "bundle"), { recursive: true });
|
|
293
|
+
mkdirSync(resolve(tuiRoot, "dist"), { recursive: true });
|
|
294
|
+
writeFileSync(resolve(root, "package.json"), JSON.stringify({ name: "@earendil-works/pi-coding-agent", version }));
|
|
295
|
+
writeFileSync(resolve(root, "dist", "bundle", "cli.js"), "");
|
|
296
|
+
writeFileSync(resolve(root, "dist", "bundle", "index.js"),
|
|
297
|
+
`const real = require(${JSON.stringify(realBundle)});\nmodule.exports = { CustomEditor: real.CustomEditor, VERSION: ${JSON.stringify(bundledVersion)} };\n`);
|
|
298
|
+
writeFileSync(resolve(root, "dist", "index.js"), `module.exports = { CustomEditor: require(${JSON.stringify(realAgent)}).CustomEditor };\n`);
|
|
299
|
+
writeFileSync(resolve(tuiRoot, "package.json"), JSON.stringify({ name: "@earendil-works/pi-tui", version: tuiVersion, main: "dist/index.js" }));
|
|
300
|
+
writeFileSync(resolve(tuiRoot, "dist", "index.js"), `module.exports = { Editor: require(${JSON.stringify(realTui)}).Editor };\n`);
|
|
301
|
+
return resolve(root, "dist", "bundle", "cli.js");
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
test("bundled host admits each audited release and rejects unknown or mismatched bundle metadata", (t) => {
|
|
305
|
+
for (const version of ["0.99.1", "0.99.2", "1.0.0"]) {
|
|
306
|
+
assert.deepEqual(resolveVimRuntime(fabricatedPiCli(t, version), bundledAgent.CustomEditor), { version, editorClass: BundledEditor }, version);
|
|
307
|
+
}
|
|
308
|
+
for (const version of ["0.99.3", "1.0.1", ">=1.0.0", "v1.0.0"]) {
|
|
309
|
+
assert.equal(resolveVimRuntime(fabricatedPiCli(t, version), bundledAgent.CustomEditor), undefined, version);
|
|
310
|
+
}
|
|
311
|
+
assert.equal(resolveVimRuntime(fabricatedPiCli(t, "1.0.0", "1.0.0", "0.99.2"), bundledAgent.CustomEditor), undefined);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("installed agent/TUI pair admits each audited release and skips unknown or mismatched pairs", (t) => {
|
|
315
|
+
for (const version of ["0.99.1", "0.99.2", "1.0.0"]) {
|
|
316
|
+
assert.deepEqual(resolveVimRuntime(fabricatedPiCli(t, version), runtimeAgent.CustomEditor), { version, editorClass: runtimeTui.Editor }, version);
|
|
317
|
+
}
|
|
318
|
+
// Rejected candidates must resolve to the real local pair, not their claim.
|
|
319
|
+
for (const version of ["0.99.3", "1.0.1", ">=1.0.0", "v1.0.0"]) {
|
|
320
|
+
assert.deepEqual(resolveVimRuntime(fabricatedPiCli(t, version), runtimeAgent.CustomEditor), { version: INSTALLED_PI, editorClass: runtimeTui.Editor }, version);
|
|
321
|
+
}
|
|
322
|
+
assert.deepEqual(resolveVimRuntime(fabricatedPiCli(t, "0.99.2", "1.0.0"), runtimeAgent.CustomEditor),
|
|
323
|
+
{ version: INSTALLED_PI, editorClass: runtimeTui.Editor }, "mismatched pair falls back to verified local metadata");
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("adapter admits each verified audited release and rejects unknown or mismatched claims without mutation", () => {
|
|
327
|
+
const host = () => new BundledEditor!({ terminal: { rows: 6 }, requestRender() {} } as never, { borderColor: (s: string) => s } as never);
|
|
328
|
+
for (const version of ["0.99.1", "0.99.2", "1.0.0"]) {
|
|
329
|
+
const e = host();
|
|
330
|
+
e.setText("alpha beta");
|
|
331
|
+
const adapter = createVimEditorAdapter(e, version, BundledEditor, version);
|
|
332
|
+
adapter.move({ line: 0, col: 6 });
|
|
333
|
+
adapter.replace({ line: 0, col: 6 }, { line: 0, col: 10 }, "snow");
|
|
334
|
+
assert.equal(e.getText(), "alpha snow", version);
|
|
335
|
+
}
|
|
336
|
+
const e = host();
|
|
337
|
+
e.setText("untouched");
|
|
338
|
+
const frame = e.render(30);
|
|
339
|
+
const cursor = e.getCursor();
|
|
340
|
+
const snapshots = structuredClone((e as unknown as { undoStack: { stack: unknown[] } }).undoStack.stack);
|
|
341
|
+
for (const version of ["0.99.3", "1.0.1", ">=1.0.0", "v1.0.0"]) {
|
|
342
|
+
assert.throws(() => createVimEditorAdapter(e, version, BundledEditor, version), /unsupported/i, version);
|
|
343
|
+
assert.throws(() => createVimEditorAdapter(editor(), version), /unsupported/i, version);
|
|
344
|
+
}
|
|
345
|
+
assert.throws(() => createVimEditorAdapter(e, "1.0.0", BundledEditor, "0.99.2"), /unsupported/i);
|
|
346
|
+
assert.equal(e.getText(), "untouched");
|
|
347
|
+
assert.deepEqual(e.getCursor(), cursor);
|
|
348
|
+
assert.deepEqual(e.render(30), frame);
|
|
349
|
+
assert.deepEqual((e as unknown as { undoStack: { stack: unknown[] } }).undoStack.stack, snapshots);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("installed audited Pi pair proves version, constructor identity, editing, paste, selection, wrap and autocomplete", { skip: skip0871 }, () => {
|
|
353
|
+
assert.equal(runtimeTuiPackage.version, INSTALLED_PI);
|
|
354
|
+
assert.equal(runtimeAgentPackage.version, INSTALLED_PI);
|
|
355
|
+
assertInstalledPiPairBehavior(INSTALLED_PI, runtimeTui.Editor, runtimeAgent.CustomEditor);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("installed CustomEditor subclass admits the same adapter and preserves its own frame", { skip: skip0871 }, () => {
|
|
359
|
+
class RuntimePrompt extends runtimeAgent.CustomEditor {}
|
|
360
|
+
const e = new RuntimePrompt({ terminal: { rows: 6, columns: 30 }, requestRender() {} } as never,
|
|
361
|
+
{ borderColor: (s: string) => s } as never, { matches: () => false } as never);
|
|
362
|
+
e.setText("one two");
|
|
363
|
+
const adapter = createVimEditorAdapter(e, INSTALLED_PI, runtimeTui.Editor);
|
|
364
|
+
adapter.move({ line: 0, col: 0 });
|
|
365
|
+
adapter.replace({ line: 0, col: 0 }, { line: 0, col: 3 }, "snow");
|
|
366
|
+
assert.equal(e.getText(), "snow two");
|
|
367
|
+
adapter.undo();
|
|
368
|
+
assert.equal(e.getText(), "one two");
|
|
369
|
+
const rows = e.render(30);
|
|
370
|
+
assert.equal(adapter.renderSelection(30, { line: 0, col: 0 }, { line: 0, col: 3 }, rows).length, rows.length);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("runtime identity and prototype mismatch reject without mutation", { skip: skip0871 }, () => {
|
|
374
|
+
const e = new runtimeTui.Editor({ terminal: { rows: 6 }, requestRender() {} } as never, { borderColor: (s: string) => s } as never);
|
|
375
|
+
e.setText("untouched");
|
|
376
|
+
assert.throws(() => createVimEditorAdapter(e, "0.85.1", Editor), /unsupported/i);
|
|
377
|
+
assert.throws(() => createVimEditorAdapter(e, "0.88.0", runtimeTui.Editor), /unsupported/i);
|
|
378
|
+
const forged = Object.create(e) as typeof e;
|
|
379
|
+
assert.throws(() => createVimEditorAdapter(forged, INSTALLED_PI, runtimeTui.Editor), /unsupported/i);
|
|
380
|
+
assert.equal(e.getText(), "untouched");
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test("audited shape rejects corrupt undo snapshots without mutation", () => {
|
|
384
|
+
const e = editor();
|
|
385
|
+
e.setText("safe");
|
|
386
|
+
const before = e.getText();
|
|
387
|
+
const stack = (e as unknown as { undoStack: { stack: unknown[] } }).undoStack.stack;
|
|
388
|
+
stack.push({ state: { lines: ["unsafe"], cursorLine: 0, cursorCol: 0 }, pastes: {}, pasteCounter: 0 });
|
|
389
|
+
assert.throws(() => createVimEditorAdapter(e, INSTALLED_PI), /unsupported/i);
|
|
390
|
+
assert.equal(e.getText(), before);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("bundled constructor needs matching runtime authority, not a guessed version", () => {
|
|
394
|
+
const e = new BundledEditor!({ terminal: { rows: 6 }, requestRender() {} } as never,
|
|
395
|
+
{ borderColor: (s: string) => s } as never);
|
|
396
|
+
assert.throws(() => createVimEditorAdapter(e, INSTALLED_PI, BundledEditor), /unsupported/i);
|
|
397
|
+
assert.throws(() => createVimEditorAdapter(e, INSTALLED_PI, BundledEditor, "0.99.0"), /unsupported/i);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("rejects unknown editor shape without mutation", () => {
|
|
401
|
+
const unknown = { getText: () => "untouched" };
|
|
402
|
+
assert.throws(() => createVimEditorAdapter(unknown, INSTALLED_PI), /unsupported/i);
|
|
403
|
+
assert.equal(unknown.getText(), "untouched");
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test("Unicode and multiline cursor stays on atomic grapheme boundaries", () => {
|
|
407
|
+
const e = editor();
|
|
408
|
+
e.setText("a👩💻b\néx");
|
|
409
|
+
const adapter = createVimEditorAdapter(e, INSTALLED_PI);
|
|
410
|
+
adapter.move({ line: 0, col: 1 });
|
|
411
|
+
adapter.moveByGraphemes(1);
|
|
412
|
+
assert.deepEqual(e.getCursor(), { line: 0, col: 6 });
|
|
413
|
+
adapter.moveByGraphemes(2);
|
|
414
|
+
assert.deepEqual(e.getCursor(), { line: 1, col: 0 });
|
|
415
|
+
assert.throws(() => adapter.move({ line: 0, col: 2 }), /boundary/i);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test("duplicate literal registered paste markers reject private Vim operations without changing expanded text", () => {
|
|
419
|
+
const e = editor();
|
|
420
|
+
e.handleInput(`\x1b[200~${"z".repeat(1001)}\x1b[201~`);
|
|
421
|
+
const markerText = e.getText();
|
|
422
|
+
e.insertTextAtCursor(` ${markerText}`);
|
|
423
|
+
const before = e.getText();
|
|
424
|
+
const expanded = e.getExpandedText();
|
|
425
|
+
assert.throws(() => createVimEditorAdapter(e, INSTALLED_PI), /duplicate.*paste marker/i);
|
|
426
|
+
assert.equal(e.getText(), before);
|
|
427
|
+
assert.equal(e.getExpandedText(), expanded);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("motion boundaries treat registered collapsed paste as one unit, not marker-shaped plain text", () => {
|
|
431
|
+
const e = editor();
|
|
432
|
+
e.setText("a");
|
|
433
|
+
e.handleInput(`\x1b[200~${"z".repeat(1001)}\x1b[201~`);
|
|
434
|
+
const end = e.getText().length;
|
|
435
|
+
const adapter = createVimEditorAdapter(e, INSTALLED_PI);
|
|
436
|
+
assert.deepEqual(adapter.motionBoundaries()[0], [0, 1, end]);
|
|
437
|
+
adapter.move({ line: 0, col: 1 });
|
|
438
|
+
assert.throws(() => adapter.move({ line: 0, col: 2 }), /boundary/i);
|
|
439
|
+
const plain = editor();
|
|
440
|
+
plain.setText("a[paste #1 1001 chars]b");
|
|
441
|
+
assert.ok(createVimEditorAdapter(plain, INSTALLED_PI).motionBoundaries()[0]!.includes(2));
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test("a complete insert session undoes in one unit without crossing an earlier edit", () => {
|
|
445
|
+
const e = editor();
|
|
446
|
+
e.setText("base");
|
|
447
|
+
const adapter = createVimEditorAdapter(e, INSTALLED_PI);
|
|
448
|
+
adapter.replace({ line: 0, col: 4 }, { line: 0, col: 4 }, "!");
|
|
449
|
+
adapter.beginInsertSession();
|
|
450
|
+
e.handleInput("a"); e.handleInput(" "); e.handleInput("b");
|
|
451
|
+
e.handleInput("\x1b[200~é\n👩💻\x1b[201~");
|
|
452
|
+
adapter.endInsertSession();
|
|
453
|
+
assert.equal(e.getText(), "base!a bé\n👩💻");
|
|
454
|
+
adapter.undo();
|
|
455
|
+
assert.equal(e.getText(), "base!");
|
|
456
|
+
adapter.undo();
|
|
457
|
+
assert.equal(e.getText(), "base");
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test("change operators followed immediately by Escape preserve deletion as one undo unit", () => {
|
|
461
|
+
for (const keys of [["c", "w"], ["s"], ["S"]]) {
|
|
462
|
+
const e = editor();
|
|
463
|
+
e.setText("alpha beta");
|
|
464
|
+
const adapter = createVimEditorAdapter(e, INSTALLED_PI);
|
|
465
|
+
const operator = new VimOperatorEngine();
|
|
466
|
+
let edit;
|
|
467
|
+
for (const key of keys) edit = operator.input(key, e.getText(), { line: 0, col: 0 }, adapter.motionBoundaries())?.edit ?? edit;
|
|
468
|
+
assert.ok(edit?.insert, keys.join(""));
|
|
469
|
+
adapter.beginInsertSession();
|
|
470
|
+
adapter.replace(edit.start, edit.end, edit.text);
|
|
471
|
+
adapter.move(edit.cursor);
|
|
472
|
+
adapter.markInsertAnchor();
|
|
473
|
+
adapter.endInsertSession();
|
|
474
|
+
assert.notEqual(e.getText(), "alpha beta");
|
|
475
|
+
adapter.undo();
|
|
476
|
+
assert.equal(e.getText(), "alpha beta", `${keys.join("")} must undo its deletion`);
|
|
477
|
+
adapter.undo();
|
|
478
|
+
assert.equal(e.getText(), "", "a second undo reaches the preexisting baseline");
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test("insert capture records only anchored semantic text, not edits elsewhere or paste markers", () => {
|
|
483
|
+
const e = editor(); e.setText("ab");
|
|
484
|
+
const adapter = createVimEditorAdapter(e, INSTALLED_PI);
|
|
485
|
+
adapter.move({ line: 0, col: 1 }); adapter.beginInsertSession();
|
|
486
|
+
e.handleInput("👩💻"); e.handleInput("é");
|
|
487
|
+
assert.equal(adapter.endInsertSession(), "👩💻é");
|
|
488
|
+
adapter.move({ line: 0, col: 0 }); adapter.beginInsertSession();
|
|
489
|
+
adapter.replace({ line: 0, col: 1 }, { line: 0, col: 7 }, "x");
|
|
490
|
+
assert.equal(adapter.endInsertSession(), undefined);
|
|
491
|
+
adapter.beginInsertSession();
|
|
492
|
+
assert.equal(adapter.endInsertSession(), undefined);
|
|
493
|
+
const paste = editor();
|
|
494
|
+
const guarded = createVimEditorAdapter(paste, INSTALLED_PI);
|
|
495
|
+
guarded.beginInsertSession();
|
|
496
|
+
paste.handleInput(`\x1b[200~${"z".repeat(1001)}\x1b[201~`);
|
|
497
|
+
assert.equal(guarded.endInsertSession(), undefined);
|
|
498
|
+
assert.equal(paste.getExpandedText(), "z".repeat(1001));
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test("rejected duplicate marker closes insert session without touching undo, allowing a repaired retry", () => {
|
|
502
|
+
const e = editor();
|
|
503
|
+
e.handleInput(`\x1b[200~${"z".repeat(1001)}\x1b[201~`);
|
|
504
|
+
const markerText = e.getText();
|
|
505
|
+
const adapter = createVimEditorAdapter(e, INSTALLED_PI);
|
|
506
|
+
adapter.beginInsertSession();
|
|
507
|
+
e.insertTextAtCursor(markerText);
|
|
508
|
+
const undo = (e as unknown as { undoStack: { stack: unknown[] } }).undoStack.stack.slice();
|
|
509
|
+
assert.throws(() => adapter.endInsertSession(), /duplicate registered paste marker/i);
|
|
510
|
+
assert.deepEqual((e as unknown as { undoStack: { stack: unknown[] } }).undoStack.stack, undo,
|
|
511
|
+
"rejection must not group or rewrite undo snapshots");
|
|
512
|
+
assert.equal(e.getText(), markerText + markerText);
|
|
513
|
+
// Simulate the host repairing the invalid draft before private Vim operations resume.
|
|
514
|
+
const state = (e as unknown as { state: { lines: string[]; cursorCol: number } }).state;
|
|
515
|
+
state.lines = [markerText]; state.cursorCol = markerText.length;
|
|
516
|
+
adapter.beginInsertSession();
|
|
517
|
+
assert.equal(adapter.endInsertSession(), undefined);
|
|
518
|
+
assert.deepEqual((e as unknown as { undoStack: { stack: unknown[] } }).undoStack.stack, undo);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
test("insert session does not resurrect an undone pre-session snapshot", () => {
|
|
522
|
+
const e = editor();
|
|
523
|
+
e.setText("base");
|
|
524
|
+
const adapter = createVimEditorAdapter(e, INSTALLED_PI);
|
|
525
|
+
adapter.beginInsertSession();
|
|
526
|
+
adapter.undo();
|
|
527
|
+
assert.equal(e.getText(), "");
|
|
528
|
+
e.handleInput("X"); e.handleInput(" ");
|
|
529
|
+
adapter.endInsertSession();
|
|
530
|
+
assert.equal(e.getText(), "X ");
|
|
531
|
+
adapter.undo();
|
|
532
|
+
assert.notEqual(e.getText(), "base", "Pi's replacement snapshots must not resurrect the old baseline");
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
test("empty insert session does not add an undo unit or lose paste registration", () => {
|
|
536
|
+
const e = editor();
|
|
537
|
+
e.handleInput(`\x1b[200~${"z".repeat(1001)}\x1b[201~`);
|
|
538
|
+
const adapter = createVimEditorAdapter(e, INSTALLED_PI);
|
|
539
|
+
adapter.beginInsertSession();
|
|
540
|
+
adapter.endInsertSession();
|
|
541
|
+
assert.equal(e.getExpandedText(), "z".repeat(1001));
|
|
542
|
+
adapter.undo();
|
|
543
|
+
assert.equal(e.getText(), "");
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
test("equal-text range replacement does not hide an earlier undo", () => {
|
|
547
|
+
const e = editor();
|
|
548
|
+
e.setText("abc");
|
|
549
|
+
const adapter = createVimEditorAdapter(e, INSTALLED_PI);
|
|
550
|
+
adapter.replace({ line: 0, col: 0 }, { line: 0, col: 1 }, "");
|
|
551
|
+
assert.equal(e.getText(), "bc");
|
|
552
|
+
adapter.replace({ line: 0, col: 0 }, { line: 0, col: 2 }, "bc");
|
|
553
|
+
adapter.undo();
|
|
554
|
+
assert.equal(e.getText(), "abc");
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test("one range edit is one undo; history and collapsed paste registry survive", () => {
|
|
558
|
+
const e = editor();
|
|
559
|
+
e.addToHistory("earlier");
|
|
560
|
+
e.handleInput(`\x1b[200~${"z".repeat(1001)}\x1b[201~`);
|
|
561
|
+
e.insertTextAtCursor("👩💻\ntail");
|
|
562
|
+
const before = e.getText();
|
|
563
|
+
const expanded = e.getExpandedText();
|
|
564
|
+
const adapter = createVimEditorAdapter(e, INSTALLED_PI);
|
|
565
|
+
adapter.replace({ line: 1, col: 0 }, { line: 1, col: 4 }, "new");
|
|
566
|
+
assert.equal(e.getText(), before.replace("tail", "new"));
|
|
567
|
+
assert.equal(e.getExpandedText(), expanded.replace("tail", "new"));
|
|
568
|
+
e.handleInput("\x1f");
|
|
569
|
+
assert.equal(e.getText(), before);
|
|
570
|
+
assert.equal(e.getExpandedText(), expanded);
|
|
571
|
+
e.setText("");
|
|
572
|
+
e.handleInput("\x1b[A");
|
|
573
|
+
assert.equal(e.getText(), "earlier");
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
test("visual register reads reject registered paste markers without mutating the editor", () => {
|
|
577
|
+
const e = editor();
|
|
578
|
+
e.handleInput(`\x1b[200~${"z".repeat(1001)}\x1b[201~`);
|
|
579
|
+
const markerText = e.getText();
|
|
580
|
+
const adapter = createVimEditorAdapter(e, INSTALLED_PI);
|
|
581
|
+
assert.throws(() => adapter.readRange({ line: 0, col: 0 }, { line: 0, col: markerText.length }), /paste marker/i);
|
|
582
|
+
assert.equal(e.getText(), markerText);
|
|
583
|
+
assert.equal(e.getExpandedText(), "z".repeat(1001));
|
|
584
|
+
const plain = editor(); plain.setText("[paste #1 1001 chars]");
|
|
585
|
+
assert.equal(createVimEditorAdapter(plain, INSTALLED_PI).readRange({ line: 0, col: 0 }, { line: 0, col: plain.getText().length }), plain.getText());
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
test("real Pi editor shifts tab-indented lines in both directions with one undo per edit", () => {
|
|
589
|
+
for (const key of [">", "<"] as const) {
|
|
590
|
+
const e = editor();
|
|
591
|
+
// Pi's public setText normalizes tabs; seed the actual private editor state
|
|
592
|
+
// to exercise the adapter's version-gated handling of legacy literal tabs.
|
|
593
|
+
e.setText(" 👩💻 alpha\nnext");
|
|
594
|
+
(e as unknown as { state: { lines: string[] } }).state.lines[0] = "\t👩💻 alpha";
|
|
595
|
+
const adapter = createVimEditorAdapter(e, INSTALLED_PI);
|
|
596
|
+
const operator = new VimOperatorEngine();
|
|
597
|
+
let result;
|
|
598
|
+
for (const stroke of [key, key]) result = operator.input(stroke, e.getText(), { line: 0, col: 0 }, adapter.motionBoundaries());
|
|
599
|
+
assert.ok(result?.edit, `${key}${key} must produce a real edit`);
|
|
600
|
+
adapter.replace(result.edit.start, result.edit.end, result.edit.text);
|
|
601
|
+
adapter.move(result.edit.cursor);
|
|
602
|
+
assert.equal(e.getText(), key === ">" ? " \t👩💻 alpha\nnext" : "👩💻 alpha\nnext");
|
|
603
|
+
adapter.undo();
|
|
604
|
+
assert.equal(e.getText(), "\t👩💻 alpha\nnext", `${key}${key} is one Pi undo unit`);
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
test("tab-bearing replacement cannot duplicate a registered paste marker", () => {
|
|
609
|
+
const e = editor();
|
|
610
|
+
e.handleInput(`\x1b[200~${"z".repeat(1001)}\x1b[201~`);
|
|
611
|
+
const original = e.getText();
|
|
612
|
+
const expanded = e.getExpandedText();
|
|
613
|
+
const adapter = createVimEditorAdapter(e, INSTALLED_PI);
|
|
614
|
+
assert.throws(() => adapter.replace({ line: 0, col: 0 }, { line: 0, col: 0 }, `\t${original}`), /duplicate.*paste marker/i);
|
|
615
|
+
assert.equal(e.getText(), original);
|
|
616
|
+
assert.equal(e.getExpandedText(), expanded);
|
|
617
|
+
adapter.replace({ line: 0, col: 0 }, { line: 0, col: 0 }, "\t👩💻");
|
|
618
|
+
assert.equal(e.getText(), `\t👩💻${original}`);
|
|
619
|
+
assert.equal(e.getExpandedText(), `\t👩💻${expanded}`);
|
|
620
|
+
adapter.undo();
|
|
621
|
+
assert.equal(e.getText(), original);
|
|
622
|
+
assert.equal(e.getExpandedText(), expanded);
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
test("operator insertion and registered marker guards keep one undo snapshot", () => {
|
|
626
|
+
const e = editor();
|
|
627
|
+
e.setText("a\nb");
|
|
628
|
+
const adapter = createVimEditorAdapter(e, INSTALLED_PI);
|
|
629
|
+
adapter.replace({ line: 0, col: 1 }, { line: 0, col: 1 }, "👩💻\n");
|
|
630
|
+
assert.equal(e.getText(), "a👩💻\n\nb");
|
|
631
|
+
e.handleInput("\x1f");
|
|
632
|
+
assert.equal(e.getText(), "a\nb");
|
|
633
|
+
const paste = editor();
|
|
634
|
+
paste.handleInput(`\x1b[200~${"z".repeat(1001)}\x1b[201~`);
|
|
635
|
+
const end = paste.getText().length;
|
|
636
|
+
assert.throws(() => createVimEditorAdapter(paste, INSTALLED_PI).replace(
|
|
637
|
+
{ line: 0, col: 0 }, { line: 0, col: end }, ""), /paste marker/i);
|
|
638
|
+
assert.equal(paste.getExpandedText(), "z".repeat(1001));
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
test("selection survives fake cursor reset and retains surrounding color", () => {
|
|
642
|
+
const e = editor();
|
|
643
|
+
e.setText("abcd");
|
|
644
|
+
createVimEditorAdapter(e, INSTALLED_PI).move({ line: 0, col: 1 });
|
|
645
|
+
const frame = createVimEditorAdapter(e, INSTALLED_PI).renderSelection(20, { line: 0, col: 0 }, { line: 0, col: 4 });
|
|
646
|
+
const row = frame.find((line) => line.includes("a")) ?? frame.join("");
|
|
647
|
+
let reverse = false;
|
|
648
|
+
const selected: string[] = [];
|
|
649
|
+
for (const token of row.matchAll(/\x1b\[[0-9;]*m|[^\x1b]/g)) {
|
|
650
|
+
const value = token[0];
|
|
651
|
+
if (value.startsWith("\x1b[")) {
|
|
652
|
+
for (const code of value.slice(2, -1).split(";").map(Number)) {
|
|
653
|
+
if (code === 0 || code === 27) reverse = false;
|
|
654
|
+
if (code === 7) reverse = true;
|
|
655
|
+
}
|
|
656
|
+
} else if ("abcd".includes(value)) selected.push(reverse ? value : `!${value}`);
|
|
657
|
+
}
|
|
658
|
+
assert.deepEqual(selected, ["a", "b", "c", "d"], "every visible cell must have effective reverse SGR");
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
test("truecolor channels do not change inverse state across selected and unselected cells", () => {
|
|
662
|
+
const e = editor();
|
|
663
|
+
e.setText("ab");
|
|
664
|
+
const adapter = createVimEditorAdapter(e, INSTALLED_PI);
|
|
665
|
+
const rows = e.render(20);
|
|
666
|
+
rows[1] = rows[1]!.replace("ab", "a\x1b[38;2;7;0;27mb");
|
|
667
|
+
const row = adapter.renderSelection(20, { line: 0, col: 0 }, { line: 0, col: 1 }, rows)[1]!;
|
|
668
|
+
let inverse = false;
|
|
669
|
+
let foreground = "";
|
|
670
|
+
const cells: Array<[string, boolean, string]> = [];
|
|
671
|
+
for (const token of row.matchAll(/\x1b\[[0-9;]*m|[^\x1b]/g)) {
|
|
672
|
+
const value = token[0];
|
|
673
|
+
if (value.startsWith("\x1b[")) {
|
|
674
|
+
const codes = value.slice(2, -1).split(";").map(Number);
|
|
675
|
+
for (let i = 0; i < codes.length; i++) {
|
|
676
|
+
if (codes[i] === 38 && codes[i + 1] === 2) { foreground = codes.slice(i + 2, i + 5).join(";"); i += 4; }
|
|
677
|
+
else if (codes[i] === 0 || codes[i] === 27) inverse = false;
|
|
678
|
+
else if (codes[i] === 7) inverse = true;
|
|
679
|
+
}
|
|
680
|
+
} else if (value === "a" || value === "b") cells.push([value, inverse, foreground]);
|
|
681
|
+
}
|
|
682
|
+
assert.deepEqual(cells, [["a", true, ""], ["b", false, "7;0;27"]]);
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
test("focused cursor marker survives highlighting while unfocused rows remain valid", () => {
|
|
686
|
+
const e = editor();
|
|
687
|
+
e.setText("abcd");
|
|
688
|
+
const adapter = createVimEditorAdapter(e, INSTALLED_PI);
|
|
689
|
+
adapter.move({ line: 0, col: 2 });
|
|
690
|
+
e.focused = true;
|
|
691
|
+
const source = e.render(20);
|
|
692
|
+
assert.ok(source.some((row) => row.includes(CURSOR_MARKER)));
|
|
693
|
+
const highlighted = adapter.renderSelection(20, { line: 0, col: 0 }, { line: 0, col: 4 }, source);
|
|
694
|
+
assert.ok(highlighted.some((row) => row.includes(CURSOR_MARKER) && row.includes("\x1b[7m")));
|
|
695
|
+
const unfocused = source.map((row) => row.replaceAll(CURSOR_MARKER, ""));
|
|
696
|
+
assert.ok(adapter.renderSelection(20, { line: 0, col: 0 }, { line: 0, col: 4 }, unfocused).some((row) => row.includes("\x1b[7m")));
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
test("selection ending at the software cursor retains its inverse cell", () => {
|
|
700
|
+
const e = editor();
|
|
701
|
+
e.setText("abcd");
|
|
702
|
+
const adapter = createVimEditorAdapter(e, INSTALLED_PI);
|
|
703
|
+
adapter.move({ line: 0, col: 2 });
|
|
704
|
+
e.focused = false;
|
|
705
|
+
const row = adapter.renderSelection(20, { line: 0, col: 0 }, { line: 0, col: 2 }).find((line) => line.includes("a"))!;
|
|
706
|
+
assert.ok(row.includes("\x1b[7m"), "selection must be visible");
|
|
707
|
+
assert.doesNotMatch(row, /\x1b\[7m\x1b\[27m[^]*c/);
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
test("scrolled identical wraps paint exactly the selected visible cells, not earlier copies", () => {
|
|
711
|
+
const e = editor();
|
|
712
|
+
e.setText("a".repeat(80));
|
|
713
|
+
const adapter = createVimEditorAdapter(e, INSTALLED_PI);
|
|
714
|
+
adapter.move({ line: 0, col: 80 });
|
|
715
|
+
const rows = adapter.renderSelection(6, { line: 0, col: 55 }, { line: 0, col: 67 });
|
|
716
|
+
assert.equal((e as unknown as { scrollOffset: number }).scrollOffset, 9);
|
|
717
|
+
assert.deepEqual(rows.slice(1, -1).map(selectedColumns), [
|
|
718
|
+
[], [], [0, 1, 2, 3, 4], [0, 1, 2, 3, 4], [0, 1], [], [5],
|
|
719
|
+
], "last row contains Pi's inverse software cursor at the end of the last wrap");
|
|
720
|
+
assert.ok(rows.every((row) => visibleWidth(row) === 6));
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
test("focused marker remains at its original visual column through selection", () => {
|
|
724
|
+
const e = editor();
|
|
725
|
+
e.setText("abcd");
|
|
726
|
+
e.focused = true;
|
|
727
|
+
const adapter = createVimEditorAdapter(e, INSTALLED_PI);
|
|
728
|
+
adapter.move({ line: 0, col: 2 });
|
|
729
|
+
const before = e.render(12);
|
|
730
|
+
const after = adapter.renderSelection(12, { line: 0, col: 0 }, { line: 0, col: 3 }, before);
|
|
731
|
+
assert.equal(paintedCells(after[1]!.split(CURSOR_MARKER)[0]!).length,
|
|
732
|
+
paintedCells(before[1]!.split(CURSOR_MARKER)[0]!).length, "marker stays at the same terminal column");
|
|
733
|
+
assert.deepEqual(paintedCells(after[1]!).slice(0, 4),
|
|
734
|
+
"abcd".split("").map((char, index) => ({ char, inverse: index < 3 })));
|
|
735
|
+
assert.ok(after.every((row) => visibleWidth(row) === 12));
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
test("empty logical line column zero keeps the focused cursor and paints neighboring rows", () => {
|
|
739
|
+
const e = editor();
|
|
740
|
+
e.setText("a\n\nb");
|
|
741
|
+
e.focused = true;
|
|
742
|
+
const adapter = createVimEditorAdapter(e, INSTALLED_PI);
|
|
743
|
+
adapter.move({ line: 1, col: 0 });
|
|
744
|
+
const rows = adapter.renderSelection(12, { line: 0, col: 0 }, { line: 2, col: 1 });
|
|
745
|
+
assert.equal(rows[2]!.indexOf(CURSOR_MARKER), 0);
|
|
746
|
+
assert.deepEqual(rows.slice(1, 4).map(selectedColumns), [[0], [0], [0]]);
|
|
747
|
+
assert.ok(rows.every((row) => visibleWidth(row) === 12));
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
test("split paste marker paints its visible cells across scroll without selecting adjacent text", () => {
|
|
751
|
+
const e = editor();
|
|
752
|
+
e.setText("a".repeat(35));
|
|
753
|
+
e.handleInput(`\x1b[200~${"z".repeat(1001)}\x1b[201~`);
|
|
754
|
+
const pasteEnd = e.getText().length;
|
|
755
|
+
e.insertTextAtCursor("TAIL");
|
|
756
|
+
const adapter = createVimEditorAdapter(e, INSTALLED_PI);
|
|
757
|
+
adapter.move({ line: 0, col: pasteEnd });
|
|
758
|
+
const rows = adapter.renderSelection(8, { line: 0, col: 35 }, { line: 0, col: pasteEnd });
|
|
759
|
+
assert.ok((e as unknown as { scrollOffset: number }).scrollOffset > 0);
|
|
760
|
+
const internal = e as unknown as { scrollOffset: number; layoutText(width: number): Array<{ text: string; hasCursor: boolean; cursorPos?: number }> };
|
|
761
|
+
const layout = internal.layoutText(7);
|
|
762
|
+
let start = layout.slice(0, internal.scrollOffset).reduce((n, chunk) => n + chunk.text.length, 0);
|
|
763
|
+
const painted = rows.slice(1, -1).map((row, index) => {
|
|
764
|
+
const chunk = layout[internal.scrollOffset + index]!;
|
|
765
|
+
const expected = [...chunk.text].flatMap((_, col) => start + col >= 35 && start + col < pasteEnd ? [col] : []);
|
|
766
|
+
if (chunk.hasCursor && chunk.cursorPos !== undefined) expected.push(chunk.cursorPos);
|
|
767
|
+
start += chunk.text.length;
|
|
768
|
+
return { row, expected };
|
|
769
|
+
});
|
|
770
|
+
assert.ok(painted.some(({ row }) => paintedCells(row).some((cell) => cell.char === "[" && cell.inverse)));
|
|
771
|
+
assert.ok(painted.some(({ row }) => paintedCells(row).some((cell) => cell.char === "]" && cell.inverse)));
|
|
772
|
+
for (const { row, expected } of painted) assert.deepEqual(selectedColumns(row), expected,
|
|
773
|
+
"marker cells and Pi's native cursor are the only inverse cells in each visible row");
|
|
774
|
+
assert.ok(rows.every((row) => visibleWidth(row) === 8));
|
|
775
|
+
assert.equal(e.getExpandedText(), "a".repeat(35) + "z".repeat(1001) + "TAIL");
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
test("installed-version mismatch fails closed without changing the rendered frame or editor state", () => {
|
|
779
|
+
const e = editor();
|
|
780
|
+
e.setText("abcd");
|
|
781
|
+
e.focused = true;
|
|
782
|
+
const frame = e.render(12);
|
|
783
|
+
assert.throws(() => createVimEditorAdapter(e, "0.85.1"), /unsupported/i);
|
|
784
|
+
assert.deepEqual(e.render(12), frame);
|
|
785
|
+
assert.equal(e.getText(), "abcd");
|
|
786
|
+
assert.deepEqual(e.getCursor(), { line: 0, col: 4 });
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
test("empty logical line accepts cursor column zero", () => {
|
|
790
|
+
const e = editor();
|
|
791
|
+
e.setText("a\n\nb");
|
|
792
|
+
createVimEditorAdapter(e, INSTALLED_PI).move({ line: 1, col: 0 });
|
|
793
|
+
assert.deepEqual(e.getCursor(), { line: 1, col: 0 });
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
test("visual highlight spans wraps and treats collapsed paste as one selection unit", () => {
|
|
797
|
+
const e = editor();
|
|
798
|
+
e.handleInput(`\x1b[200~${"z".repeat(1001)}\x1b[201~`);
|
|
799
|
+
e.insertTextAtCursor(" following long words");
|
|
800
|
+
const adapter = createVimEditorAdapter(e, INSTALLED_PI);
|
|
801
|
+
const lines = adapter.renderSelection(12, { line: 0, col: 0 }, { line: 0, col: e.getText().length });
|
|
802
|
+
assert.ok(lines.filter((line) => line.includes("\x1b[7m")).length > 1);
|
|
803
|
+
assert.equal(e.getExpandedText(), "z".repeat(1001) + " following long words");
|
|
804
|
+
});
|