diff --git a/docs/gentle-shell.md b/docs/gentle-shell.md index 2e3ddd497..0017ab09d 100644 --- a/docs/gentle-shell.md +++ b/docs/gentle-shell.md @@ -15,7 +15,7 @@ The [v2.6.0 release](https://github.com/Gentleman-Programming/gentle-pi/releases - The Agents List and Details views preserve the orchestrator/session hierarchy and completion, abort, and lost-exit history. Parent-child queries and notifications have an explicit handoff path, while model, effort, and usage stay observable per task. - Named `/gentle:profiles` atomically route the orchestrator separately from packaged and review roles; see the [technical reference](readme-reference.md#agent-model-profiles) for the profile model. -The source checkout prepares `gentle-pi` `3.7.0` with a package-local Gentle AI `v3.7.0` pin; this does not imply that the package release has been published. +The source checkout prepares `gentle-pi` `3.7.1` with a package-local Gentle AI `v3.7.0` pin; this does not imply that the package release has been published. ## Shell interactions and runtime behavior diff --git a/docs/readme-reference.md b/docs/readme-reference.md index 7c5f59950..c69a16b5a 100644 --- a/docs/readme-reference.md +++ b/docs/readme-reference.md @@ -157,11 +157,11 @@ This installs the current npm release; select an explicit version if you need a ### Source checkout -This checkout declares `gentle-pi` `3.7.0` with a package-local Gentle AI `v3.7.0` pin. Checkout metadata alone is not proof of npm publication; verify the registry version and its release workflow. +This checkout declares `gentle-pi` `3.7.1` with a package-local Gentle AI `v3.7.0` pin. Checkout metadata alone is not proof of npm publication; verify the registry version and its release workflow. ### Pi compatibility -The current package requires Pi 0.85.1 or newer (development tests pin 0.87.1). Use the latest Pi release; gentle-pi does not update your installed Pi automatically. Children, including any `GENTLE_PI_AGENTS_PI` override, must emit `agent_settled`: `agent_end` records a run's output but is not completion because retries or queued continuations may follow. +The current package requires Pi 0.85.1 or newer (development tests pin 0.99.1). Use the latest Pi release; gentle-pi does not update your installed Pi automatically. Children, including any `GENTLE_PI_AGENTS_PI` override, must emit `agent_settled`: `agent_end` records a run's output but is not completion because retries or queued continuations may follow. The [`v2.6.0` release](https://github.com/Gentleman-Programming/gentle-shell/releases/tag/v2.6.0) added persistent registered worktrees and grouped `/gentle:changes` views; fuller workspace interaction details are in the [Gentle Shell reference](gentle-shell.md). It also adds named atomic `/gentle:profiles`, native review intended-untracked selection and provider continuations, and opt-in custom ask responses. Pi recognizes its global Git-managed package path; subsystems install with explicit recovery guidance when npm lifecycle work was skipped. Windows keeps child consoles hidden and fixes ownership mode; Gentle Todo keeps the next pending task visible when collapsed. @@ -977,7 +977,7 @@ The frame labels INSERT, NORMAL, VISUAL (characterwise), or VISUAL LINE (linewis **Deliberate `/` divergence from Claude Code:** NORMAL `/` hands off to **Pi's native slash commands and skills**, enters INSERT, and inserts `/` at the existing cursor. Pi offers slash completion only at the start of the first line; elsewhere it inserts a literal slash without moving or replacing the draft. There is **no reverse prompt-history search**. Pi's explicit history shortcuts still work, transferring to INSERT first. Unknown NORMAL printable input, encoded text and bracketed paste do not silently insert; application shortcuts can transfer to INSERT before acting. -This is a bounded command subset, not full Claude Code/Vim parity. The private editor adapter supports only the proven Pi coding-agent/TUI `0.85.1` and `0.87.1` package pairs: version metadata must come from a canonical candidate host package root whose actual `CustomEditor` and `Editor` classes match the loaded classes, never from the extension's local metadata or CLI path alone. Unknown versions, mismatched prototypes, or invalid layouts fail closed: a single compatibility warning is shown and the prompt continues with ordinary editing instead of silently entering inert NORMAL mode. Operations that would cross a registered collapsed paste marker, or encounter duplicate occurrences of a registered marker ID, are rejected without editing it. Visual highlighting relies on Pi's render layout and may be omitted if its geometry cannot be validated. No live-terminal proof of every layout or complete parity is claimed. +This is a bounded command subset, not full Claude Code/Vim parity. The private editor adapter supports only the proven Pi coding-agent/TUI `0.85.1`, `0.87.1` and `0.99.1` package pairs โ€” the list mirrors `SUPPORTED_VERSIONS` in `lib/vim-editor-adapter.ts`, which is the single source of truth for both the identity gate and `resolveVimRuntime()`: version metadata must come from a canonical candidate host package root whose actual `CustomEditor` and `Editor` classes match the loaded classes, never from the extension's local metadata or CLI path alone. Unknown versions, mismatched prototypes, or invalid layouts fail closed: a single compatibility warning is shown and the prompt continues with ordinary editing instead of silently entering inert NORMAL mode. Operations that would cross a registered collapsed paste marker, or encounter duplicate occurrences of a registered marker ID, are rejected without editing it. Visual highlighting relies on Pi's render layout and may be omitted if its geometry cannot be validated. No live-terminal proof of every layout or complete parity is claimed. Startup banner settings remain global in `banner.json` under `GENTLE_PI_CONFIG_HOME` (default `~/.pi/gentle-ai`). Existing `showRose` and `showTextLogo` opt-outs independently control the main startup artwork; both default to enabled. Changes apply on the next session or `/reload`. Color presets are `pink` (default), `cyan`, `yellow`, and `green`. The static sidebar heading is independent of these preferences and follows the active theme. diff --git a/extensions/gentle-shell.ts b/extensions/gentle-shell.ts index ef22678c9..886cb67f0 100644 --- a/extensions/gentle-shell.ts +++ b/extensions/gentle-shell.ts @@ -30,7 +30,7 @@ import { resolveAnimationPolicy, writeAnimationPolicy, type AnimationPolicy } fr import { resolveVimPolicy, writeVimPolicy, type VimPolicy } from "../lib/vim-policy.ts"; import { resolveHistoryCapture, writeHistoryCapturePolicy } from "../lib/history-capture-policy.ts"; import { createRequire } from "node:module"; -import { createVimEditorAdapter } from "../lib/vim-editor-adapter.ts"; +import { createVimEditorAdapter, SUPPORTED_VERSIONS } from "../lib/vim-editor-adapter.ts"; import { VimNormalEngine } from "../lib/vim-normal-engine.ts"; import { VimOperatorEngine, type OperatorResult } from "../lib/vim-operator-engine.ts"; import { VimVisualEngine } from "../lib/vim-visual-engine.ts"; @@ -60,7 +60,7 @@ export function resolveVimRuntime(entry = process.argv[1], customClass: typeof C const requireFromBundle = createRequire(bundlePath); const bundled = requireFromBundle(bundlePath) as { CustomEditor?: typeof CustomEditor; VERSION?: string }; const metadata = requireFromBundle(resolve(root, "package.json")) as { name?: string; version?: string }; - if (metadata.name === "@earendil-works/pi-coding-agent" && metadata.version === "0.87.1" && + if (metadata.name === "@earendil-works/pi-coding-agent" && SUPPORTED_VERSIONS.has(metadata.version ?? "") && bundled.VERSION === metadata.version && bundled.CustomEditor === customClass && typeof editorClass === "function" && editorClass.name === "Editor" && editorPrototype === editorClass.prototype && @@ -94,7 +94,7 @@ export function resolveVimRuntime(entry = process.argv[1], customClass: typeof C const tuiMetadata = requireFromRuntime(resolve(tuiRoot, "package.json")) as { version?: string; name?: string }; if (agent.CustomEditor === customClass && tui.Editor === editorClass && agentMetadata.name === "@earendil-works/pi-coding-agent" && tuiMetadata.name === "@earendil-works/pi-tui" && - (agentMetadata.version === "0.85.1" || agentMetadata.version === "0.87.1") && + (SUPPORTED_VERSIONS.has(agentMetadata.version ?? "")) && agentMetadata.version === tuiMetadata.version) return { version: tuiMetadata.version, editorClass }; } catch { /* Unknown package or constructor: ordinary editing stays active. */ } } diff --git a/lib/nan-provider.ts b/lib/nan-provider.ts index 6654efd06..7096d282b 100644 --- a/lib/nan-provider.ts +++ b/lib/nan-provider.ts @@ -2,6 +2,12 @@ import * as piAi from "@earendil-works/pi-ai"; import type { Provider, ProviderStreams, RefreshModelsContext } from "@earendil-works/pi-ai"; import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent"; +// Pi 0.99 made ProviderModelConfig a chat/image/classifier union. This provider serves chat +// models only, and pi's registration expects Model<"openai-completions">[], so the catalog is +// typed as the chat member: the image and classifier members carry no reasoning, contextWindow +// or maxTokens, which the union would have required here. +type ChatModelConfig = Extract; + export const NAN_PROVIDER_ID = "nan"; export const NAN_PROVIDER_BASE_URL = "https://api.nan.builders/v1"; export const NAN_MODELS_TIMEOUT_MS = 3_000; @@ -18,7 +24,7 @@ const ZERO_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; // Decimal bounds conservatively interpret the documented 1M/262K/131K labels. // Pi models text/image inputs only; MiMo's documented audio input is not advertised. // Where no output maximum is published, 8,192 is our conservative configured cap for coding with reasoning, not NaN's limit. -const CHAT_MODELS: ProviderModelConfig[] = [ +const CHAT_MODELS: ChatModelConfig[] = [ { id: "glm5.3", name: "GLM 5.3", input: ["text"], contextWindow: 1_000_000 }, { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", input: ["text", "image"], contextWindow: 1_000_000 }, { id: "glm5.3-flash", name: "GLM 5.3 Flash", input: ["text", "image"], contextWindow: 1_000_000 }, @@ -28,7 +34,7 @@ const CHAT_MODELS: ProviderModelConfig[] = [ { id: "qwen3.6", name: "Qwen 3.6", input: ["text", "image"], contextWindow: 262_000 }, ].map((model) => ({ ...model, - input: model.input as ProviderModelConfig["input"], + input: model.input as ChatModelConfig["input"], api: "openai-completions", reasoning: true, cost: ZERO_COST, @@ -39,11 +45,11 @@ const CHAT_MODELS: ProviderModelConfig[] = [ // A successful live catalog remains authoritative for the credential that fetched it. const OFFLINE_MODELS = CHAT_MODELS; -function cloneModel(model: ProviderModelConfig): ProviderModelConfig { +function cloneModel(model: ChatModelConfig): ChatModelConfig { return { ...model, input: [...model.input], cost: { ...model.cost } }; } -function knownChatModels(ids: readonly string[]): ProviderModelConfig[] { +function knownChatModels(ids: readonly string[]): ChatModelConfig[] { return ids.flatMap((id) => { const known = CHAT_MODELS.find((model) => model.id === id); return known ? [cloneModel(known)] : []; @@ -102,7 +108,7 @@ async function fetchLiveModelIds(options: { } } -function cloneCatalog(models: readonly ProviderModelConfig[]): ProviderModelConfig[] { +function cloneCatalog(models: readonly ChatModelConfig[]): ChatModelConfig[] { return models.map(cloneModel); } @@ -151,7 +157,7 @@ export function createNanProviderConfig(options: NanProviderOptions = {}): Provi } function createCatalogConfig(options: NanProviderOptions = {}): { - getModels(): ProviderModelConfig[]; + getModels(): ChatModelConfig[]; refreshModels(context: RefreshModelsContext): Promise; } { let catalog = cloneCatalog(OFFLINE_MODELS); diff --git a/lib/theme-customization.ts b/lib/theme-customization.ts index 447728a1a..0be2b0dd6 100644 --- a/lib/theme-customization.ts +++ b/lib/theme-customization.ts @@ -1,5 +1,9 @@ import { closeSync, constants, fstatSync, openSync, readFileSync, realpathSync } from "node:fs"; import { isAbsolute } from "node:path"; +// Namespace import on purpose: this module must also load on hosts that predate the +// colour parser (pi-tui 0.85.1/0.87.1 ship no colors.js at all), and a named import +// of a missing export would fail at load time instead of degrading at call time. +import * as piTui from "@earendil-works/pi-tui"; const MAX_BYTES = 256_000; const HEX = /^#[0-9a-fA-F]{6}$/; @@ -37,7 +41,20 @@ export function sourcePalettePreview(name: string, sourcePath: string | undefine } const prefix = background ? 48 : 38; if (typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 255) return `\x1b[${prefix};5;${value}m`; - if (typeof value === "string" && HEX.test(value)) return `\x1b[${prefix};2;${parseInt(value.slice(1, 3), 16)};${parseInt(value.slice(3, 5), 16)};${parseInt(value.slice(5, 7), 16)}m`; + if (typeof value === "string") { + // Pi 0.99 writes its built-in themes in okhsl() (and accepts oklch()/#rgb) while + // 0.85.1 and 0.87.1 only ever shipped hex. Normalise through the host's own + // parser when it is present, so the colour semantics stay Pi's and this module + // never reimplements a colour space; hosts that predate the parser keep the hex + // path below, which is all their theme files could contain anyway. + if (typeof piTui.parseColor === "function" && typeof piTui.colorToHex === "function") { + try { + const normalised: unknown = piTui.colorToHex(piTui.parseColor(value)); + if (typeof normalised === "string") value = normalised; + } catch { /* Syntax this host does not know: fail closed through the checks below. */ } + } + if (typeof value === "string" && HEX.test(value)) return `\x1b[${prefix};2;${parseInt(value.slice(1, 3), 16)};${parseInt(value.slice(3, 5), 16)};${parseInt(value.slice(5, 7), 16)}m`; + } throw new Error("Invalid theme palette color."); }; return { title: `${name} ยท source palette`, sample: `${escape("accent", true)} \x1b[0m ${escape("text", false)}Aa sample text\x1b[0m` }; diff --git a/lib/vim-editor-adapter.ts b/lib/vim-editor-adapter.ts index e73868d9e..d7db7ca0b 100644 --- a/lib/vim-editor-adapter.ts +++ b/lib/vim-editor-adapter.ts @@ -38,7 +38,17 @@ interface PrivateEditor { exitHistoryBrowsing(): void; } -const SUPPORTED_VERSIONS = new Set(["0.85.1", "0.87.1"]); +// Host Pi versions whose private editor layout this adapter has been verified +// against. This list is the single source of truth for the identity gate below and +// for resolveVimRuntime() in extensions/gentle-shell.ts, so a newly verified host +// is admitted in one place and the two checks cannot drift apart. +// +// 0.99.1 was admitted after auditing a real 0.99.1 Editor instance against every +// structural precondition asserted here (state.lines/cursorLine/cursorCol, pastes +// Map, pasteCounter, undoStack push/pop/stack/length, pushUndoSnapshot, undo, +// setCursorCol, cancelAutocomplete, exitHistoryBrowsing, layoutText, render, +// paddingX) and running the vim adapter suites against it. +export const SUPPORTED_VERSIONS = new Set(["0.85.1", "0.87.1", "0.99.1"]); const importedTuiMetadata: unknown = createRequire(import.meta.url)("@earendil-works/pi-tui/package.json"); const IMPORTED_TUI_VERSION = typeof importedTuiMetadata === "object" && importedTuiMetadata !== null && "version" in importedTuiMetadata ? importedTuiMetadata.version : undefined; @@ -47,8 +57,9 @@ const IMPORTED_TUI_VERSION = typeof importedTuiMetadata === "object" && imported // as the version metadata. An arbitrary object with matching fields is not an editor. function hasEditorIdentity(value: unknown, version: string, editorClass: typeof Editor, verifiedVersion?: string): boolean { if (!SUPPORTED_VERSIONS.has(version) || typeof value !== "object" || value === null || - (verifiedVersion !== undefined ? version !== verifiedVersion : - editorClass === Editor ? version !== IMPORTED_TUI_VERSION : version !== "0.87.1") || !(value instanceof editorClass)) return false; + (verifiedVersion !== undefined ? version !== verifiedVersion + : editorClass === Editor && version !== IMPORTED_TUI_VERSION) || + !(value instanceof editorClass)) return false; let prototype: unknown = Object.getPrototypeOf(value); for (let depth = 0; depth < 3; depth++) { if (prototype === editorClass.prototype) return true; diff --git a/package.json b/package.json index 5b3ba5d4a..6315364a6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gentle-pi", - "version": "3.7.0", + "version": "3.7.1", "description": "Turn Pi into el Gentleman: an ODD development harness with focused subagents, configured TDD evidence, native review, and skill discovery.", "license": "MIT", "type": "module", @@ -72,12 +72,12 @@ ] }, "dependencies": { - "@earendil-works/pi-ai": "0.87.1", - "@earendil-works/pi-tui": "0.87.1", "@heyhuynhgiabuu/pi-pretty": "0.6.27" }, "peerDependencies": { + "@earendil-works/pi-ai": "*", "@earendil-works/pi-coding-agent": ">=0.85.1", + "@earendil-works/pi-tui": "*", "typebox": "*" }, "peerDependenciesMeta": { @@ -86,7 +86,9 @@ } }, "devDependencies": { - "@earendil-works/pi-coding-agent": "0.87.1", + "@earendil-works/pi-ai": "0.99.1", + "@earendil-works/pi-coding-agent": "0.99.1", + "@earendil-works/pi-tui": "0.99.1", "@types/node": "^24.13.3", "typescript": "^5.9.3" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a29976dd..94c6f68ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,22 +8,22 @@ importers: .: dependencies: - '@earendil-works/pi-ai': - specifier: 0.87.1 - version: 0.87.1(ws@8.21.3) - '@earendil-works/pi-tui': - specifier: 0.87.1 - version: 0.87.1 '@heyhuynhgiabuu/pi-pretty': specifier: 0.6.27 - version: 0.6.27(@earendil-works/pi-ai@0.87.1(ws@8.21.3))(@earendil-works/pi-coding-agent@0.87.1(ws@8.21.3))(@earendil-works/pi-tui@0.87.1) + version: 0.6.27(@earendil-works/pi-ai@0.99.1(@aws-sdk/credential-provider-node@3.972.82)(@smithy/signature-v4@5.7.3)(undici@8.10.2)(ws@8.21.3))(@earendil-works/pi-coding-agent@0.99.1(@aws-sdk/credential-provider-node@3.972.82)(@smithy/signature-v4@5.7.3)(ws@8.21.3))(@earendil-works/pi-tui@0.99.1) typebox: specifier: '*' version: 1.1.38 devDependencies: + '@earendil-works/pi-ai': + specifier: 0.99.1 + version: 0.99.1(@aws-sdk/credential-provider-node@3.972.82)(@smithy/signature-v4@5.7.3)(undici@8.10.2)(ws@8.21.3) '@earendil-works/pi-coding-agent': - specifier: 0.87.1 - version: 0.87.1(ws@8.21.3) + specifier: 0.99.1 + version: 0.99.1(@aws-sdk/credential-provider-node@3.972.82)(@smithy/signature-v4@5.7.3)(ws@8.21.3) + '@earendil-works/pi-tui': + specifier: 0.99.1 + version: 0.99.1 '@types/node': specifier: ^24.13.3 version: 24.13.3 @@ -126,30 +126,38 @@ packages: resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} - '@earendil-works/chord@0.87.1': - resolution: {integrity: sha512-bg7IkJGFcEaMqqYgOGUiq5Ky9RghpRfrlZ8I/v/1b4bBZ02A7t3E+6uhPRbadwWb/kWsnVFbZsqOKRN4a3LLCg==} + '@earendil-works/chord@0.99.1': + resolution: {integrity: sha512-4xyn0IBzJ+Xu/iOGi2hjXJGAR61QEhEWZsIqTDqr+GmItdquYwBO5jYFnqGiBaTqlY12/EpM7QHoEKSHbyvOug==} engines: {node: '>=22.19.0'} - '@earendil-works/pi-agent-core@0.87.1': - resolution: {integrity: sha512-Zev3B0HK7YS5A4EZQ2XnEqiJuirx6QBiltJ+LpmjV5a/+2IU0cfKtIfnkNkORK707XOvKBY2WRtk7cAwHpbh2Q==} + '@earendil-works/pi-agent-core@0.99.1': + resolution: {integrity: sha512-zywvWnj5FujeuFI/x/CJHwwxhcLIQgjqseTA+bQgX4O8gJTcgjRd/I8SZnQDqJvxC9QcV12ujiGLviv6EgwcCg==} engines: {node: '>=22.19.0'} - '@earendil-works/pi-ai@0.87.1': - resolution: {integrity: sha512-X/3PfQBnnoeVdO9Cv8zHghUMglzlgNZYGNzoPnbRoGnHl3Rw3TlA2UKSUB7BRHUOxMryHXYa8dnjWZlbRheDZA==} + '@earendil-works/pi-ai@0.99.1': + resolution: {integrity: sha512-4nV9JKc94iPX8bwdGPc2nTuVPKIPsffhnp3WoN9NYCNqbtoOF8LhYcIs/+Sn/alroqJK/5QRu6/Z6Ck+n0hyBA==} engines: {node: '>=22.19.0'} hasBin: true - '@earendil-works/pi-coding-agent@0.87.1': - resolution: {integrity: sha512-m8ArJUtVcQMSe1lLE/Ei7vX/JV7O39sWmWBsXV2NOU70F0qCp8GubA24pT3LnwTmM6LL2xV80/h6sQg85n69ew==} + '@earendil-works/pi-codemode@0.99.1': + resolution: {integrity: sha512-oh8TMsBI3SWTN3xTQtX8u5n+BKhnVXcFagroWumfn6/WWfBnDYL/LmeQtjLb83WRTb9rcu+ZdK8rFa4vggvCJg==} + engines: {node: '>=22.19.0'} + + '@earendil-works/pi-coding-agent@0.99.1': + resolution: {integrity: sha512-cWUrTOqA5M73cOYMgsh9PlhDrsBhavd+n5kVY6F7BGbGl1RjqCteVCoeVMVqhngoGACVDyw1tbLjajL8l9jrHg==} engines: {node: '>=22.19.0'} hasBin: true - '@earendil-works/pi-telemetry@0.87.1': - resolution: {integrity: sha512-MC6TRQH5lgMXpcN+Vku2WMI2T8BsiUPzMQHGo81uqFZD3/9O79WWJAysEDGuzduP6R4tvtgwMLwmqIxynM10JQ==} + '@earendil-works/pi-mcp@0.99.1': + resolution: {integrity: sha512-YCFGPkmDzLwQuIzwfbP6Vuk/g/ukKpZhwTpbcfzomuI1Fkiu6hHRkOGwAqsO3G8cTkZWkM8vmOkFJjStQNC4qA==} engines: {node: '>=22.19.0'} - '@earendil-works/pi-tui@0.87.1': - resolution: {integrity: sha512-YEH2vRyOeiO7hhN6j6AE6YwKSq2Kz2f3XR8bj1TbR+aGE/JsnY1hLPMI2pvaZfRM1n9Y00tejxFQ4zbzvF7nkQ==} + '@earendil-works/pi-telemetry@0.99.1': + resolution: {integrity: sha512-9PBPjGk+TXRtuMianpqBbHBpYpyKusESF6rwdmgD0WTZSTUQXhcKEO0hAINRLuSwy4V7yPvXV+EVV0ONY7mbpQ==} + engines: {node: '>=22.19.0'} + + '@earendil-works/pi-tui@0.99.1': + resolution: {integrity: sha512-gZp0Guat96Fr1AuC/xqVz5B2lulZakp/PxD1lXx3lSgBdjiqmwYhJbcQ0HRrGAfy0WtMGn9b05RJr5qJf7oIuw==} engines: {node: '>=22.19.0'} '@esbuild/aix-ppc64@0.28.2': @@ -824,12 +832,25 @@ packages: oniguruma-to-es@4.3.6: resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} - openai@6.40.0: - resolution: {integrity: sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA==} + openai@7.19.0: + resolution: {integrity: sha512-MX2s3u2L5racTO0CC/SWpCOasJQBCJrqLKXK+l82cAhdeF8mPMBEe/gxMm0ZFa2xpKpOFLRjxv5afYEZbBXmbQ==} + engines: {node: '>=22.0.0'} peerDependencies: - ws: ^8.18.0 + '@aws-sdk/credential-provider-node': '>=3.972.0 <4' + '@smithy/hash-node': '>=4.3.0 <5' + '@smithy/signature-v4': '>=5.4.0 <6' + undici: '>=5 <9' + ws: ^8.21.0 zod: ^3.25 || ^4.0 peerDependenciesMeta: + '@aws-sdk/credential-provider-node': + optional: true + '@smithy/hash-node': + optional: true + '@smithy/signature-v4': + optional: true + undici: + optional: true ws: optional: true zod: @@ -865,6 +886,9 @@ packages: kerberos: optional: true + quickjs-wasi@3.6.2: + resolution: {integrity: sha512-FCqGtGOrMgzUiIrMNMA2YnsOxCNwo31dzqXvclXUC6xeT35NJLKXQJsvbeCTjvoFAwZgEAPg8U6+KAPDGXn8Mg==} + regex-recursion@6.0.2: resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} @@ -1186,55 +1210,69 @@ snapshots: '@babel/runtime@7.29.7': {} - '@earendil-works/chord@0.87.1': + '@earendil-works/chord@0.99.1': dependencies: esbuild: 0.28.2 - '@earendil-works/pi-agent-core@0.87.1(ws@8.21.3)': + '@earendil-works/pi-agent-core@0.99.1(@aws-sdk/credential-provider-node@3.972.82)(@smithy/signature-v4@5.7.3)(undici@8.10.2)(ws@8.21.3)': dependencies: - '@earendil-works/chord': 0.87.1 - '@earendil-works/pi-ai': 0.87.1(ws@8.21.3) - '@earendil-works/pi-telemetry': 0.87.1 + '@earendil-works/chord': 0.99.1 + '@earendil-works/pi-ai': 0.99.1(@aws-sdk/credential-provider-node@3.972.82)(@smithy/signature-v4@5.7.3)(undici@8.10.2)(ws@8.21.3) + '@earendil-works/pi-telemetry': 0.99.1 diff: 8.0.4 ignore: 7.0.8 typebox: 1.3.27 yaml: 2.9.0 transitivePeerDependencies: + - '@aws-sdk/credential-provider-node' - '@modelcontextprotocol/sdk' + - '@smithy/hash-node' + - '@smithy/signature-v4' - bufferutil - kerberos - supports-color + - undici - utf-8-validate - ws - zod - '@earendil-works/pi-ai@0.87.1(ws@8.21.3)': + '@earendil-works/pi-ai@0.99.1(@aws-sdk/credential-provider-node@3.972.82)(@smithy/signature-v4@5.7.3)(undici@8.10.2)(ws@8.21.3)': dependencies: '@anthropic-ai/sdk': 0.124.0 '@aws-sdk/client-bedrock-runtime': 3.1127.0 - '@earendil-works/pi-telemetry': 0.87.1 + '@earendil-works/pi-telemetry': 0.99.1 '@google/genai': 2.21.0 '@smithy/node-http-handler': 4.12.1 http-proxy-agent: 9.1.0 https-proxy-agent: 9.1.0 - openai: 6.40.0(ws@8.21.3) + openai: 7.19.0(@aws-sdk/credential-provider-node@3.972.82)(@smithy/signature-v4@5.7.3)(undici@8.10.2)(ws@8.21.3) partial-json: 0.1.7 typebox: 1.3.27 transitivePeerDependencies: + - '@aws-sdk/credential-provider-node' - '@modelcontextprotocol/sdk' + - '@smithy/hash-node' + - '@smithy/signature-v4' - bufferutil - kerberos - supports-color + - undici - utf-8-validate - ws - zod - '@earendil-works/pi-coding-agent@0.87.1(ws@8.21.3)': + '@earendil-works/pi-codemode@0.99.1': + dependencies: + quickjs-wasi: 3.6.2 + + '@earendil-works/pi-coding-agent@0.99.1(@aws-sdk/credential-provider-node@3.972.82)(@smithy/signature-v4@5.7.3)(ws@8.21.3)': dependencies: - '@earendil-works/chord': 0.87.1 - '@earendil-works/pi-agent-core': 0.87.1(ws@8.21.3) - '@earendil-works/pi-ai': 0.87.1(ws@8.21.3) - '@earendil-works/pi-tui': 0.87.1 + '@earendil-works/chord': 0.99.1 + '@earendil-works/pi-agent-core': 0.99.1(@aws-sdk/credential-provider-node@3.972.82)(@smithy/signature-v4@5.7.3)(undici@8.10.2)(ws@8.21.3) + '@earendil-works/pi-ai': 0.99.1(@aws-sdk/credential-provider-node@3.972.82)(@smithy/signature-v4@5.7.3)(undici@8.10.2)(ws@8.21.3) + '@earendil-works/pi-codemode': 0.99.1 + '@earendil-works/pi-mcp': 0.99.1 + '@earendil-works/pi-tui': 0.99.1 '@silvia-odwyer/photon-node': 0.3.4 chalk: 6.0.0 cross-spawn: 7.0.6 @@ -1246,12 +1284,16 @@ snapshots: jiti: 2.7.0 minimatch: 10.2.6 proper-lockfile: 4.1.2 + quickjs-wasi: 3.6.2 semver: 7.8.5 typebox: 1.3.27 undici: 8.10.2 yaml: 2.9.0 transitivePeerDependencies: + - '@aws-sdk/credential-provider-node' - '@modelcontextprotocol/sdk' + - '@smithy/hash-node' + - '@smithy/signature-v4' - bufferutil - kerberos - supports-color @@ -1259,9 +1301,13 @@ snapshots: - ws - zod - '@earendil-works/pi-telemetry@0.87.1': {} + '@earendil-works/pi-mcp@0.99.1': + dependencies: + cross-spawn: 7.0.6 - '@earendil-works/pi-tui@0.87.1': + '@earendil-works/pi-telemetry@0.99.1': {} + + '@earendil-works/pi-tui@0.99.1': dependencies: get-east-asian-width: 1.6.0 marked: 18.0.11 @@ -1396,11 +1442,11 @@ snapshots: - supports-color - utf-8-validate - '@heyhuynhgiabuu/pi-pretty@0.6.27(@earendil-works/pi-ai@0.87.1(ws@8.21.3))(@earendil-works/pi-coding-agent@0.87.1(ws@8.21.3))(@earendil-works/pi-tui@0.87.1)': + '@heyhuynhgiabuu/pi-pretty@0.6.27(@earendil-works/pi-ai@0.99.1(@aws-sdk/credential-provider-node@3.972.82)(@smithy/signature-v4@5.7.3)(undici@8.10.2)(ws@8.21.3))(@earendil-works/pi-coding-agent@0.99.1(@aws-sdk/credential-provider-node@3.972.82)(@smithy/signature-v4@5.7.3)(ws@8.21.3))(@earendil-works/pi-tui@0.99.1)': dependencies: - '@earendil-works/pi-ai': 0.87.1(ws@8.21.3) - '@earendil-works/pi-coding-agent': 0.87.1(ws@8.21.3) - '@earendil-works/pi-tui': 0.87.1 + '@earendil-works/pi-ai': 0.99.1(@aws-sdk/credential-provider-node@3.972.82)(@smithy/signature-v4@5.7.3)(undici@8.10.2)(ws@8.21.3) + '@earendil-works/pi-coding-agent': 0.99.1(@aws-sdk/credential-provider-node@3.972.82)(@smithy/signature-v4@5.7.3)(ws@8.21.3) + '@earendil-works/pi-tui': 0.99.1 '@ff-labs/fff-node': 0.10.6 '@shikijs/cli': 4.2.0 shiki: 4.0.2 @@ -1879,8 +1925,11 @@ snapshots: regex: 6.1.0 regex-recursion: 6.0.2 - openai@6.40.0(ws@8.21.3): + openai@7.19.0(@aws-sdk/credential-provider-node@3.972.82)(@smithy/signature-v4@5.7.3)(undici@8.10.2)(ws@8.21.3): optionalDependencies: + '@aws-sdk/credential-provider-node': 3.972.82 + '@smithy/signature-v4': 5.7.3 + undici: 8.10.2 ws: 8.21.3 p-retry@4.6.2: @@ -1916,6 +1965,8 @@ snapshots: proxy-agent-negotiate@1.1.0: {} + quickjs-wasi@3.6.2: {} + regex-recursion@6.0.2: dependencies: regex-utilities: 2.3.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0cfd3753e..4f54e5c01 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,15 +5,19 @@ nodeLinker: hoisted # Supply-chain defense โ€” most malicious packages are caught within a few days. minimumReleaseAge: 4320 -# Approved Pi 0.87.1 upgrade; frozen installs also verify release age. +# Approved Pi 0.99.1 upgrade; frozen installs also verify release age. # Keep the exception version-specific, including Pi's required aligned packages. +# The 0.87.1 entries are dropped rather than kept: that release is now far past +# the 3-day window, so an exception for it would no longer authorize anything. minimumReleaseAgeExclude: - - '@earendil-works/pi-coding-agent@0.87.1' - - '@earendil-works/chord@0.87.1' - - '@earendil-works/pi-agent-core@0.87.1' - - '@earendil-works/pi-ai@0.87.1' - - '@earendil-works/pi-tui@0.87.1' - - '@earendil-works/pi-telemetry@0.87.1' + - '@earendil-works/chord@0.99.1' + - '@earendil-works/pi-agent-core@0.99.1' + - '@earendil-works/pi-ai@0.99.1' + - '@earendil-works/pi-codemode@0.99.1' + - '@earendil-works/pi-coding-agent@0.99.1' + - '@earendil-works/pi-mcp@0.99.1' + - '@earendil-works/pi-telemetry@0.99.1' + - '@earendil-works/pi-tui@0.99.1' # Refuse to install a package whose maintainer trust level has decreased # (e.g. previously verified publisher loses verification). diff --git a/tests/gentle-shell.test.ts b/tests/gentle-shell.test.ts index 949433f37..b7ca31e95 100644 --- a/tests/gentle-shell.test.ts +++ b/tests/gentle-shell.test.ts @@ -10,6 +10,13 @@ import installGentleShell, { buildShellBarModel, createActiveProfileReader, chan import { CODEX_USAGE_URL, NAN_QUOTA_URL, USAGE_SOURCE_EVENT, USAGE_SOURCE_SCHEMA } from "../lib/shell-usage.ts"; import { createVimEditorAdapter } from "../lib/vim-editor-adapter.ts"; import { buildCommandPaletteGroups } from "../lib/command-palette-catalog.ts"; + +// The local @earendil-works/pi-tui dev dependency is the fixture running host: +// fixtures that mean "a verified host" must read its version instead of remembering +// a literal, or they silently stop matching whenever the verified set moves. +const LOCAL_TUI_VERSION = (JSON.parse( + readFileSync(new URL("../node_modules/@earendil-works/pi-tui/package.json", import.meta.url), "utf8"), +) as { version: string }).version; import { CHANGE_STATUS } from "../lib/shell-changes.ts"; import { sidebarState, type SidebarRail } from "../lib/shell-sidebar.ts"; import type { ShellBarTheme } from "../lib/shell-bar.ts"; @@ -1647,7 +1654,7 @@ test("focused framed empty logical line keeps hardware cursor at column zero ins editor.setVimPolicy("on"); editor.setText("a\n\nb"); editor.focused = true; - const adapter = createVimEditorAdapter(editor, "0.87.1"); + const adapter = createVimEditorAdapter(editor, LOCAL_TUI_VERSION); adapter.move({ line: 0, col: 0 }); editor.handleInput("\x1b"); editor.handleInput("v"); @@ -1671,7 +1678,7 @@ test("framed scrolled paste marker paints exactly its split visible cells", () = editor.handleInput(`\x1b[200~${"z".repeat(1001)}\x1b[201~`); const pasteEnd = editor.getText().length; editor.insertTextAtCursor("TAIL"); - const adapter = createVimEditorAdapter(editor, "0.87.1"); + const adapter = createVimEditorAdapter(editor, LOCAL_TUI_VERSION); adapter.move({ line: 0, col: 100 }); editor.handleInput("\x1b"); editor.handleInput("v"); @@ -1897,7 +1904,7 @@ test("vim command distinguishes persisted preference from rejected live editor a test("compatible vim command reports live activation and disable returns ordinary editing", async () => { const { pi, handlers, commands } = fakePi(); - gentleShell(pi, { GENTLE_PI_CONFIG_HOME: mkdtempSync(join(tmpdir(), "gentle-vim-compatible-")) }, { vimRuntimeVersion: () => "0.87.1" }); + gentleShell(pi, { GENTLE_PI_CONFIG_HOME: mkdtempSync(join(tmpdir(), "gentle-vim-compatible-")) }, { vimRuntimeVersion: () => LOCAL_TUI_VERSION }); const { ctx, ui } = fakeContext(); const editor = installedPrompt(ctx, ui, handlers); try { @@ -1921,7 +1928,7 @@ test("vim NORMAL slash uses Pi's command and skill completion without displacing matches: (data: string, action: string) => action === "app.interrupt" && data === "\x1b", } as never, { fg: (_color, text) => text, bold: (text) => text, requestRender() {}, pending: () => false, - now: () => 0, doubleEscCancelEnabled: () => false, dispatchQueuedText() {}, tuiVersion: () => "0.87.1", + now: () => 0, doubleEscCancelEnabled: () => false, dispatchQueuedText() {}, tuiVersion: () => LOCAL_TUI_VERSION, }); const entries = ["gentle:vim", "gentle:models", "skill:example"]; const requests: string[] = []; @@ -2037,7 +2044,7 @@ test("vim NORMAL blocks Kitty and emoji text, handles encoded motions and ignore test("vim bracketed paste frames split across editor events never run NORMAL commands", () => { const editor = new GentlePromptEditor(fakeTui as never, editorTheme as never, fakeKeybindings as never, { fg: (_color, text) => text, bold: (text) => text, requestRender() {}, pending: () => false, - now: () => 0, doubleEscCancelEnabled: () => false, dispatchQueuedText() {}, tuiVersion: () => "0.87.1", + now: () => 0, doubleEscCancelEnabled: () => false, dispatchQueuedText() {}, tuiVersion: () => LOCAL_TUI_VERSION, }); try { editor.setVimPolicy("on"); @@ -2060,7 +2067,7 @@ test("bracketed paste replaces native selection atomically while Vim VISUAL over const payload = "z".repeat(1024 * 1024 + 1); const deps = { fg: (_color: string, text: string) => text, bold: (text: string) => text, requestRender() {}, pending: () => false, - now: () => 0, doubleEscCancelEnabled: () => false, dispatchQueuedText() {}, tuiVersion: () => "0.87.1", + now: () => 0, doubleEscCancelEnabled: () => false, dispatchQueuedText() {}, tuiVersion: () => LOCAL_TUI_VERSION, }; const editor = new GentlePromptEditor(fakeTui as never, editorTheme as never, fakeKeybindings as never, deps); try { @@ -2092,7 +2099,7 @@ test("incomplete bracketed paste buffered up to exactly the 16 MiB bound still c const BOUND = 16 * 1024 * 1024; const deps = { fg: (_color: string, text: string) => text, bold: (text: string) => text, requestRender() {}, pending: () => false, - now: () => 0, doubleEscCancelEnabled: () => false, dispatchQueuedText() {}, tuiVersion: () => "0.87.1", + now: () => 0, doubleEscCancelEnabled: () => false, dispatchQueuedText() {}, tuiVersion: () => LOCAL_TUI_VERSION, }; const editor = new GentlePromptEditor(fakeTui as never, editorTheme as never, fakeKeybindings as never, deps); try { @@ -2112,7 +2119,7 @@ test("a complete bracketed paste larger than the 16 MiB incomplete-frame bound, const BOUND = 16 * 1024 * 1024; const deps = { fg: (_color: string, text: string) => text, bold: (text: string) => text, requestRender() {}, pending: () => false, - now: () => 0, doubleEscCancelEnabled: () => false, dispatchQueuedText() {}, tuiVersion: () => "0.87.1", + now: () => 0, doubleEscCancelEnabled: () => false, dispatchQueuedText() {}, tuiVersion: () => LOCAL_TUI_VERSION, }; const editor = new GentlePromptEditor(fakeTui as never, editorTheme as never, fakeKeybindings as never, deps); try { @@ -2130,7 +2137,7 @@ test("an incomplete bracketed paste that crosses the 16 MiB bound without a term const BOUND = 16 * 1024 * 1024; const deps = { fg: (_color: string, text: string) => text, bold: (text: string) => text, requestRender() {}, pending: () => false, - now: () => 0, doubleEscCancelEnabled: () => false, dispatchQueuedText() {}, tuiVersion: () => "0.87.1", + now: () => 0, doubleEscCancelEnabled: () => false, dispatchQueuedText() {}, tuiVersion: () => LOCAL_TUI_VERSION, }; const editor = new GentlePromptEditor(fakeTui as never, editorTheme as never, fakeKeybindings as never, deps); try { @@ -2148,7 +2155,7 @@ test("an incomplete bracketed paste that crosses the 16 MiB bound without a term test("vim paste overflow and policy cancellation discard partial frames without leaking modal commands", () => { const editor = new GentlePromptEditor(fakeTui as never, editorTheme as never, fakeKeybindings as never, { fg: (_color, text) => text, bold: (text) => text, requestRender() {}, pending: () => false, - now: () => 0, doubleEscCancelEnabled: () => false, dispatchQueuedText() {}, tuiVersion: () => "0.87.1", + now: () => 0, doubleEscCancelEnabled: () => false, dispatchQueuedText() {}, tuiVersion: () => LOCAL_TUI_VERSION, }); try { editor.setVimPolicy("on"); @@ -2239,7 +2246,7 @@ test("vim NORMAL motions and insert/open commands use Unicode and multiline curs editor.setVimPolicy("on"); editor.setText("a๐Ÿ‘ฉโ€๐Ÿ’ปz\n snow"); editor.handleInput("\x1b"); - createVimEditorAdapter(editor, "0.87.1").move({ line: 0, col: 0 }); + createVimEditorAdapter(editor, LOCAL_TUI_VERSION).move({ line: 0, col: 0 }); for (const key of ["l", "l", "j", "k", "g", "g", "G", "0", "^", "$"]) editor.handleInput(key); assert.deepEqual(editor.getCursor(), { line: 1, col: 6 }); editor.handleInput("O"); @@ -2278,10 +2285,10 @@ test("NORMAL first non-whitespace motion and insert land after a combining graph editor.setVimPolicy("on"); editor.setText(" \u0301a"); editor.handleInput("\x1b"); - createVimEditorAdapter(editor, "0.87.1").move({ line: 0, col: 0 }); + createVimEditorAdapter(editor, LOCAL_TUI_VERSION).move({ line: 0, col: 0 }); editor.handleInput("^"); assert.deepEqual(editor.getCursor(), { line: 0, col: 2 }); - createVimEditorAdapter(editor, "0.87.1").move({ line: 0, col: 0 }); + createVimEditorAdapter(editor, LOCAL_TUI_VERSION).move({ line: 0, col: 0 }); editor.handleInput("I"); assert.deepEqual(editor.getCursor(), { line: 0, col: 2 }); assert.match(editor.render(30).join("\n"), /INSERT/); @@ -2318,7 +2325,7 @@ test("vim NORMAL counted find and repeats remain Unicode/paste-safe and do not c editor.setVimPolicy("on"); editor.setText("a๐Ÿ‘ฉโ€๐Ÿ’ปx๐Ÿ‘ฉโ€๐Ÿ’ปx\nnext"); editor.handleInput("\x1b"); - createVimEditorAdapter(editor, "0.87.1").move({ line: 0, col: 0 }); + createVimEditorAdapter(editor, LOCAL_TUI_VERSION).move({ line: 0, col: 0 }); for (const key of ["2", "f", "๐Ÿ‘ฉโ€๐Ÿ’ป"]) editor.handleInput(key); assert.deepEqual(editor.getCursor(), { line: 0, col: 7 }); editor.handleInput(","); @@ -2342,7 +2349,7 @@ test("vim operator session edits, cancels, and restores the draft with Pi undo", editor.setVimPolicy("on"); editor.setText("๐Ÿ‘ฉโ€๐Ÿ’ป hello\nnext"); editor.handleInput("\x1b"); - createVimEditorAdapter(editor, "0.87.1").move({ line: 0, col: 0 }); + createVimEditorAdapter(editor, LOCAL_TUI_VERSION).move({ line: 0, col: 0 }); for (const key of ["d", "w"]) editor.handleInput(key); assert.equal(editor.getText(), "hello\nnext"); editor.handleInput("u"); @@ -2364,7 +2371,7 @@ test("vim join and shift are single undo units and Escape cancels pending shift" editor.setVimPolicy("on"); editor.setText("๐Ÿ‘ฉโ€๐Ÿ’ป one\n two\nthird"); editor.handleInput("\x1b"); - createVimEditorAdapter(editor, "0.87.1").move({ line: 0, col: 0 }); + createVimEditorAdapter(editor, LOCAL_TUI_VERSION).move({ line: 0, col: 0 }); editor.handleInput(">"); editor.handleInput("\x1b"); editor.handleInput("J"); @@ -2388,7 +2395,7 @@ test("vim operator survives an unhandled extension shortcut probe", () => { editor.setText("abc def"); editor.onExtensionShortcut = () => false; editor.handleInput("\x1b"); - createVimEditorAdapter(editor, "0.87.1").move({ line: 0, col: 0 }); + createVimEditorAdapter(editor, LOCAL_TUI_VERSION).move({ line: 0, col: 0 }); editor.handleInput("d"); editor.handleInput("w"); assert.equal(editor.getText(), "def"); @@ -2405,20 +2412,20 @@ test("vim final-line yy/P, cc and empty S keep line boundaries and INSERT state" editor.setVimPolicy("on"); editor.setText("one\ntwo"); editor.handleInput("\x1b"); - createVimEditorAdapter(editor, "0.87.1").move({ line: 1, col: 0 }); + createVimEditorAdapter(editor, LOCAL_TUI_VERSION).move({ line: 1, col: 0 }); for (const key of ["y", "y", "P"]) editor.handleInput(key); assert.equal(editor.getText(), "one\ntwo\ntwo"); assert.deepEqual(editor.getCursor(), { line: 1, col: 0 }); editor.handleInput("u"); assert.equal(editor.getText(), "one\ntwo"); - createVimEditorAdapter(editor, "0.87.1").move({ line: 1, col: 0 }); + createVimEditorAdapter(editor, LOCAL_TUI_VERSION).move({ line: 1, col: 0 }); for (const key of ["c", "c"]) editor.handleInput(key); assert.equal(editor.getText(), "one\n"); assert.deepEqual(editor.getCursor(), { line: 1, col: 0 }); assert.match(editor.render(40).join("\n"), /INSERT/); editor.setText("one"); editor.handleInput("\x1b"); - createVimEditorAdapter(editor, "0.87.1").move({ line: 0, col: 0 }); + createVimEditorAdapter(editor, LOCAL_TUI_VERSION).move({ line: 0, col: 0 }); for (const key of ["c", "c"]) editor.handleInput(key); assert.equal(editor.getText(), ""); assert.deepEqual(editor.getCursor(), { line: 0, col: 0 }); @@ -2439,7 +2446,7 @@ test("vim NORMAL Escape cancels pending find without inserting the next characte editor.setVimPolicy("on"); editor.setText("ax"); editor.handleInput("\x1b"); - createVimEditorAdapter(editor, "0.87.1").move({ line: 0, col: 0 }); + createVimEditorAdapter(editor, LOCAL_TUI_VERSION).move({ line: 0, col: 0 }); editor.handleInput("f"); editor.handleInput("\x1b"); editor.handleInput("l"); @@ -2461,7 +2468,7 @@ test("vim NORMAL k at the first visual line does not recall history", () => { editor.addToHistory("previous prompt"); editor.setText("draft\nsecond line"); editor.handleInput("\x1b"); - createVimEditorAdapter(editor, "0.87.1").move({ line: 1, col: 0 }); + createVimEditorAdapter(editor, LOCAL_TUI_VERSION).move({ line: 1, col: 0 }); editor.handleInput("k"); assert.deepEqual(editor.getCursor(), { line: 0, col: 0 }); const before = editor.getText(); @@ -2469,11 +2476,11 @@ test("vim NORMAL k at the first visual line does not recall history", () => { assert.equal(editor.getText(), before, "top-edge k must not replace the draft with history"); assert.deepEqual(editor.getCursor(), { line: 0, col: 0 }); assert.match(editor.render(40).join("\n"), /NORMAL/); - createVimEditorAdapter(editor, "0.87.1").move({ line: 1, col: 0 }); + createVimEditorAdapter(editor, LOCAL_TUI_VERSION).move({ line: 1, col: 0 }); editor.handleInput("j"); assert.equal(editor.getText(), before, "bottom-edge j must not browse history"); assert.deepEqual(editor.getCursor(), { line: 1, col: 0 }); - createVimEditorAdapter(editor, "0.87.1").move({ line: 0, col: 0 }); + createVimEditorAdapter(editor, LOCAL_TUI_VERSION).move({ line: 0, col: 0 }); editor.handleInput("\x1b[A"); assert.equal(editor.getText(), "previous prompt", "explicit Pi history binding still works"); assert.match(editor.render(40).join("\n"), /INSERT/); @@ -2865,7 +2872,7 @@ test("customize History rows refuse to overwrite a malformed preference and repo test("external Vim preference change while customize is open never implies a compatibility failure", async (t) => { const home = scopedDoubleEscCancelConfigHome(t); const { pi, handlers, commands } = fakePi(); - gentleShell(pi, { GENTLE_PI_CONFIG_HOME: home }, { vimRuntimeVersion: () => "0.87.1" }); + gentleShell(pi, { GENTLE_PI_CONFIG_HOME: home }, { vimRuntimeVersion: () => LOCAL_TUI_VERSION }); const { ctx, ui, overlayReady } = fakeContext(); const editor = installedPrompt(ctx, ui, handlers); try { @@ -2885,7 +2892,7 @@ test("external Vim preference change while customize is open never implies a com }); test("customize updates live Vim prompt and reports unsupported effective state without attributing its cause", async (t) => { - for (const version of ["0.87.1", "unsupported"]) { + for (const version of [LOCAL_TUI_VERSION, "unsupported"]) { const home = scopedDoubleEscCancelConfigHome(t); const { pi, handlers, commands } = fakePi(); gentleShell(pi, { GENTLE_PI_CONFIG_HOME: home }, { vimRuntimeVersion: () => version }); diff --git a/tests/package-manifest.test.ts b/tests/package-manifest.test.ts index 0848098eb..d6789247f 100644 --- a/tests/package-manifest.test.ts +++ b/tests/package-manifest.test.ts @@ -123,17 +123,27 @@ test("public docs and metadata advertise ODD and review without retired phase wo test("technical reference declares the tested Pi minimum required for agent_settled", () => { const manifest = readPackageJson(); + // The supported floor stays where it is: bumping the tested host must not silently + // drop older consumers. What moves is the version the repository actually tests. assert.equal(manifest.peerDependencies?.["@earendil-works/pi-coding-agent"], ">=0.85.1"); - assert.equal(manifest.devDependencies?.["@earendil-works/pi-coding-agent"], "0.87.1"); + assert.equal(manifest.devDependencies?.["@earendil-works/pi-coding-agent"], "0.99.1"); const reference = readFileSync(join(PACKAGE_ROOT, "docs", "readme-reference.md"), "utf8"); assert.match(reference, /Pi 0\.85\.1 or newer/); + assert.match(reference, /development tests pin 0\.99\.1/); assert.match(reference, /agent_settled/); assert.match(readFileSync(join(PACKAGE_ROOT, "README.md"), "utf8"), /\]\(docs\/readme-reference\.md(?:#[^)]+)?\)/); }); -test("packed runtime declares its pi-ai compat import as a direct exact dependency", () => { +test("host-provided Pi packages are peer dependencies, never packed copies", () => { const manifest = readPackageJson(); - assert.equal(manifest.dependencies?.["@earendil-works/pi-ai"], "0.87.1"); + // Pi 0.99 rejects an extension that declares a host-provided package as a direct + // dependency: the loader already supplies these modules, so a private copy can only + // create a second runtime instance. The tested version belongs in devDependencies. + for (const name of ["@earendil-works/pi-ai", "@earendil-works/pi-tui"]) { + assert.equal(manifest.peerDependencies?.[name], "*", `${name} must be a wildcard peer`); + assert.equal(manifest.dependencies?.[name], undefined, `${name} must not be a direct dependency`); + assert.equal(manifest.devDependencies?.[name], "0.99.1", `${name} must be tested at the pinned host version`); + } }); test("package manifest has no obsolete native activation build surface", () => { @@ -1626,9 +1636,9 @@ test("pi-pretty wrapper uses cached ESM loading for compiled and pnpm symlink in assert.match(wrapper, /quietToolsEnabled/); }); -test("Gentle Shell v3.7.0 package manifest declares the release version", () => { +test("Gentle Shell v3.7.1 package manifest declares the release version", () => { const packageJson = readPackageJson(); - assert.equal(packageJson.version, "3.7.0", "the release manifest must be explicitly pinned to v3.7.0"); + assert.equal(packageJson.version, "3.7.1", "the release manifest must be explicitly pinned to v3.7.1"); assert.equal(packageJson.scripts?.test, "node scripts/run-test-suite.mjs"); assert.ok(packageJson.files?.includes("assets/")); assert.ok(packageJson.files?.includes("contracts/")); diff --git a/tests/review-agent-end-preflight.test.ts b/tests/review-agent-end-preflight.test.ts index d78cab34f..b81bf8d7a 100644 --- a/tests/review-agent-end-preflight.test.ts +++ b/tests/review-agent-end-preflight.test.ts @@ -6,7 +6,7 @@ import { mkdir, mkdtemp, rename, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; -import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; +import type { ExtensionAPI, ExtensionContext, ExtensionToolContext } from "@earendil-works/pi-coding-agent"; import { createGentleAiExtension } from "../extensions/gentle-ai.ts"; import { NATIVE_REVIEW_ERROR_CODE, NativeReviewCliError, type NativeReviewCli } from "../lib/native-review-cli.ts"; import type { ReviewStatusV3 } from "../lib/review-integration-v2.ts"; @@ -57,13 +57,15 @@ function harness(nativeReviewCli: NativeReviewCli | null, entries: CustomEntry[] return { handlers, sent, tools }; } -function ctx(sessionId: string, hasUI = true, cwd = process.cwd()): ExtensionContext { +// Pi 0.99 gives ExtensionToolContext required members on top of ExtensionContext, so the +// fake host context must be typed as the tool context its consumers require. +function ctx(sessionId: string, hasUI = true, cwd = process.cwd()): ExtensionToolContext { return { cwd, hasUI, ui: { notify() {} }, sessionManager: { getSessionId: () => sessionId }, - } as unknown as ExtensionContext; + } as unknown as ExtensionToolContext; } async function withSessionStartEnv(callback: (cwd: string) => Promise, initializeGit = true): Promise { diff --git a/tests/review-sidebar-state.test.ts b/tests/review-sidebar-state.test.ts index ae7a28a7d..3838f75fd 100644 --- a/tests/review-sidebar-state.test.ts +++ b/tests/review-sidebar-state.test.ts @@ -7,7 +7,7 @@ import { createReviewSidebarPublisher, REVIEW_SCOPE_UNAVAILABLE, REVIEW_SIDEBAR_ import { __testing } from "../extensions/gentle-ai.ts"; import { NATIVE_REVIEW_ERROR_CODE, NATIVE_REVIEW_OPERATION, NativeReviewCliError, type NativeReviewCli } from "../lib/native-review-cli.ts"; import { decodeReviewStatusV3 } from "../lib/review-integration-v2.ts"; -import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; +import type { ExtensionAPI, ExtensionToolContext } from "@earendil-works/pi-coding-agent"; // Display contract only: lifecycle evidence must be normalized by the producer, // never inferred from a successful tool execution by the renderer. @@ -110,7 +110,7 @@ test("RDD completed STATUS evidence does not imply capture execution", () => { test("publisher retains scope only for issued capture bindings and matching closure", async () => { const events: Array<{ snapshot: { state: string; scope: string } }> = []; const publisher = createReviewSidebarPublisher({ events: { emit: (_name: string, event: typeof events[number]) => events.push(event) } } as unknown as ExtensionAPI); - const ctx = { cwd: "/repo", sessionManager: { getSessionId: () => "session" } } as unknown as ExtensionContext; + const ctx = { cwd: "/repo", sessionManager: { getSessionId: () => "session" } } as unknown as ExtensionToolContext; publisher.reset(ctx); const run = (name: string, params: Record, details: unknown) => publisher.tool({ name, label: "Test", description: "Test", parameters: { type: "object" } as never, @@ -156,7 +156,7 @@ test("real facade STATUS binding retains sidebar scope through capture forecast" test("publisher ignores late completions after reset without changing results", async () => { const events: unknown[] = []; const publisher = createReviewSidebarPublisher({ events: { emit: (_name: string, event: unknown) => events.push(event) } } as unknown as ExtensionAPI); - const ctx = { cwd: "/repo", sessionManager: { getSessionId: () => "session" } } as unknown as ExtensionContext; + const ctx = { cwd: "/repo", sessionManager: { getSessionId: () => "session" } } as unknown as ExtensionToolContext; let finish!: () => void; const pending = new Promise((resolve) => { finish = resolve; }); const result = { content: [], details: { status: "blocked" } }; @@ -172,7 +172,7 @@ test("publisher ignores late completions after reset without changing results", function publisherFixture() { const events: Array<{ sessionId: string; snapshot: { state: string; scope: string } }> = []; const publisher = createReviewSidebarPublisher({ events: { emit: (_name: string, event: typeof events[number]) => events.push(event) } } as unknown as ExtensionAPI); - const ctx = { cwd: "/repo", sessionManager: { getSessionId: () => "own" } } as unknown as ExtensionContext; + const ctx = { cwd: "/repo", sessionManager: { getSessionId: () => "own" } } as unknown as ExtensionToolContext; publisher.reset(ctx); const run = (name: string, params: Record, details: unknown | Promise, context = ctx) => publisher.tool({ name, label: "Test", description: "Test", parameters: { type: "object" } as never, @@ -336,7 +336,7 @@ test("foreign sessions, disabled publisher and event failures never change tool const h = publisherFixture(); await h.seed(); const before = h.events.length; - const foreign = { cwd: "/repo", sessionManager: { getSessionId: () => "foreign" } } as unknown as ExtensionContext; + const foreign = { cwd: "/repo", sessionManager: { getSessionId: () => "foreign" } } as unknown as ExtensionToolContext; const result = await h.run("gentle_review", { operation: "status" }, { status: "blocked" }, foreign); assert.deepEqual(result.details, { status: "blocked" }); assert.equal(h.events.length, before); diff --git a/tests/vim-editor-adapter.test.ts b/tests/vim-editor-adapter.test.ts index 7b38063e9..28e052ad1 100644 --- a/tests/vim-editor-adapter.test.ts +++ b/tests/vim-editor-adapter.test.ts @@ -2,10 +2,10 @@ import assert from "node:assert/strict"; import test from "node:test"; import { existsSync, readFileSync, realpathSync } from "node:fs"; import { createRequire } from "node:module"; -import { pathToFileURL } from "node:url"; -import { delimiter, dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { delimiter, dirname, join, resolve, sep } from "node:path"; import { CURSOR_MARKER, Editor, visibleWidth } from "@earendil-works/pi-tui"; -import { createVimEditorAdapter } from "../lib/vim-editor-adapter.ts"; +import { createVimEditorAdapter, SUPPORTED_VERSIONS } from "../lib/vim-editor-adapter.ts"; import { resolveVimRuntime, VIM_AGENT_INDEX_PATTERN, VIM_CLI_ENTRY_PATTERN } from "../extensions/gentle-shell.ts"; import { VimOperatorEngine } from "../lib/vim-operator-engine.ts"; import { VimVisualEngine } from "../lib/vim-visual-engine.ts"; @@ -41,21 +41,37 @@ function selectedColumns(row: string): number[] { } // Resolve the installed PATH Pi through its local pnpm launcher, never a network package. -// The declared @earendil-works/pi-coding-agent 0.87.1 dev dependency -// (see package.json) is the local fixture. A separately linked PATH `pi` -// bundle is optional; its identity tests skip when it is unavailable. +// The declared @earendil-works/pi-coding-agent dev dependency (see package.json) is +// the local fixture, and both it and the optional PATH bundle are admitted only at a +// version this adapter has been verified against (SUPPORTED_VERSIONS), so raising the +// verified host version updates the fixtures and the assertions together. +const LOCAL_TUI_VERSION = (JSON.parse(readFileSync(new URL("../node_modules/@earendil-works/pi-tui/package.json", import.meta.url), "utf8")) as { version: string }).version; +// Pi 0.99.1 exposes neither "./package.json" nor a CJS require condition, so the +// agent version is read from the installed file by URL (the same way this file +// already resolves the local bundle CLI below). +const LOCAL_AGENT_VERSION = (JSON.parse(readFileSync(new URL("../node_modules/@earendil-works/pi-coding-agent/package.json", import.meta.url), "utf8")) as { version: string }).version; +// The development install is not an installed host: under `pnpm test` PATH starts with +// node_modules/.bin, whose `pi` is this package's own dev dependency, and treating it as +// an external host would collapse the PATH host and the declared local pair into one +// module instance (which is exactly what the identity tests must keep apart). +const LOCAL_PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const LOCAL_BIN_ROOT = join(LOCAL_PACKAGE_ROOT, "node_modules") + sep; const target = (process.env.PATH ?? "").split(delimiter).flatMap((dir) => { const launcher = join(dir, "pi"); if (!existsSync(launcher)) return []; try { - const match = readFileSync(launcher, "utf8").match(/cmd-shim-target=(.*\/node_modules\/@earendil-works\/pi-coding-agent\/dist\/bundle\/cli\.js)/)?.[1]; - if (!match) return []; - const cli = realpathSync(match); - const metadata = createRequire(cli)(resolve(dirname(cli), "../../package.json")) as { version: string }; - return metadata.version === "0.87.1" ? [match] : []; + // pnpm installs a shim that records the bundle path; npm installs a symlink + // straight at the bundle CLI. Both are the same host, so both are accepted. + const shim = readFileSync(launcher, "utf8").match(/cmd-shim-target=(.*\/node_modules\/@earendil-works\/pi-coding-agent\/dist\/bundle\/cli\.js)/)?.[1]; + const direct = realpathSync(launcher); + const candidate = shim ? realpathSync(shim) : VIM_CLI_ENTRY_PATTERN.test(direct) ? direct : undefined; + if (!candidate || !/\/node_modules\/@earendil-works\/pi-coding-agent\/dist\/bundle\/cli\.js$/.test(candidate)) return []; + if (candidate.startsWith(LOCAL_BIN_ROOT)) return []; + const metadata = createRequire(candidate)(resolve(dirname(candidate), "../../package.json")) as { version: string }; + return SUPPORTED_VERSIONS.has(metadata.version) ? [candidate] : []; } catch { return []; } })[0]; -const skip0871 = target ? undefined : "installed PATH Pi 0.87.1 bundle unavailable; the declared local 0.87.1 pair remains tested"; +const skipHost = target ? undefined : `installed PATH Pi bundle at a verified version unavailable; the declared local ${LOCAL_AGENT_VERSION} pair remains tested`; let runtimeTui: typeof import("@earendil-works/pi-tui") | undefined; let runtimeAgent: typeof import("@earendil-works/pi-coding-agent") | undefined; @@ -94,19 +110,19 @@ if (target) { virtualTui = virtualModule.VIRTUAL_MODULES["@earendil-works/pi-tui"]; } -test("PATH bundled virtual host is admitted only through its exact bundled class and version", { skip: skip0871 }, () => { - assert.equal(bundledAgent.VERSION, "0.87.1"); +test("PATH bundled virtual host is admitted only through its exact bundled class and version", { skip: skipHost }, () => { + assert.equal(bundledAgent.VERSION, runtimeAgentPackage.version); assert.notEqual(bundledAgent.CustomEditor, runtimeAgent.CustomEditor); assert.notEqual(BundledEditor, runtimeTui.Editor); assert.equal(virtualAgent?.CustomEditor, bundledAgent.CustomEditor); assert.equal(virtualTui?.Editor, BundledEditor); assert.equal(Object.getPrototypeOf(bundledAgent.CustomEditor.prototype), BundledEditor.prototype); - assert.deepEqual(resolveVimRuntime(target, bundledAgent.CustomEditor), { version: "0.87.1", editorClass: BundledEditor }); + assert.deepEqual(resolveVimRuntime(target, bundledAgent.CustomEditor), { version: runtimeAgentPackage.version, editorClass: BundledEditor }); assert.equal(resolveVimRuntime("/nonexistent/cli.js", bundledAgent.CustomEditor), undefined); const e = new bundledAgent.CustomEditor({ terminal: { rows: 6, columns: 30 }, requestRender() {} } as never, { borderColor: (s: string) => s } as never, { matches: () => false } as never); e.setText("alpha beta"); - const adapter = createVimEditorAdapter(e, "0.87.1", BundledEditor, bundledAgent.VERSION); + const adapter = createVimEditorAdapter(e, bundledAgent.VERSION, BundledEditor, bundledAgent.VERSION); adapter.move({ line: 0, col: 6 }); adapter.replace({ line: 0, col: 6 }, { line: 0, col: 10 }, "snow"); assert.equal(e.getText(), "alpha snow"); @@ -119,10 +135,10 @@ test("PATH bundled virtual host is admitted only through its exact bundled class assert.ok(adapter.renderSelection(30, { line: 0, col: 0 }, { line: 0, col: 18 }, rows).some(row => row.includes("\x1b[7m"))); }); -test("bundled 0.87.1 editor accepts visual c/p ranges at Unicode and multiline endpoints", { skip: skip0871 }, () => { +test("bundled host editor accepts visual c/p ranges at Unicode and multiline endpoints", { skip: skipHost }, () => { const e = new bundledAgent.CustomEditor({ terminal: { rows: 6, columns: 30 }, requestRender() {} } as never, { borderColor: (s: string) => s } as never, { matches: () => false } as never); - const adapter = createVimEditorAdapter(e, "0.87.1", BundledEditor, bundledAgent.VERSION); + const adapter = createVimEditorAdapter(e, bundledAgent.VERSION, BundledEditor, bundledAgent.VERSION); const visual = new VimVisualEngine(); const operator = new VimOperatorEngine(); e.setText("๐Ÿ‘ฉโ€๐Ÿ’ปรฉ\n้›ช"); @@ -150,11 +166,11 @@ test("bundled 0.87.1 editor accepts visual c/p ranges at Unicode and multiline e assert.equal(e.getText(), "๐Ÿ‘ฉโ€๐Ÿ’ปรฉ\n"); }); -test("bundled 0.87.1 full-line characterwise visual c/p stages preserve host rows and autocomplete", { skip: skip0871 }, () => { +test("bundled host full-line characterwise visual c/p stages preserve host rows and autocomplete", { skip: skipHost }, () => { for (const command of ["c", "p"] as const) { const e = new bundledAgent.CustomEditor({ terminal: { rows: 6, columns: 30 }, requestRender() {} } as never, { borderColor: (s: string) => s } as never, { matches: () => false } as never); - const adapter = createVimEditorAdapter(e, "0.87.1", BundledEditor, bundledAgent.VERSION); + const adapter = createVimEditorAdapter(e, bundledAgent.VERSION, BundledEditor, bundledAgent.VERSION); const visual = new VimVisualEngine(); const operator = new VimOperatorEngine(); e.setText("asdf asdf asd f"); @@ -178,18 +194,18 @@ test("bundled 0.87.1 full-line characterwise visual c/p stages preserve host row } }); -test("runtime identity resolves only the matching installed coding-agent/TUI pair", { skip: skip0871 }, () => { - assert.deepEqual(resolveVimRuntime(target, runtimeAgent.CustomEditor), { version: "0.87.1", editorClass: runtimeTui.Editor }); - assert.deepEqual(resolveVimRuntime(target), { version: "0.87.1", editorClass: Editor }, "the declared local pair retains its own constructor identity"); +test("runtime identity resolves only the matching installed coding-agent/TUI pair", { skip: skipHost }, () => { + assert.deepEqual(resolveVimRuntime(target, runtimeAgent.CustomEditor), { version: runtimeTuiPackage.version, editorClass: runtimeTui.Editor }); + assert.deepEqual(resolveVimRuntime(target), { version: LOCAL_TUI_VERSION, editorClass: Editor }, "the declared local pair retains its own constructor identity"); // A virtual-module Editor alias can be the host class, so only the // matching installed pair may certify its version. - assert.equal((createRequire(import.meta.url)("@earendil-works/pi-tui/package.json") as { version: string }).version, "0.87.1"); + assert.equal((createRequire(import.meta.url)("@earendil-works/pi-tui/package.json") as { version: string }).version, LOCAL_TUI_VERSION); assert.deepEqual(resolveVimRuntime(target, runtimeAgent.CustomEditor), { version: runtimeTuiPackage.version, editorClass: runtimeTui.Editor }); assert.equal(resolveVimRuntime(target, class Impostor extends runtimeTui.Editor {} as typeof runtimeAgent.CustomEditor), undefined); assert.equal(resolveVimRuntime("/nonexistent/cli.js", runtimeAgent.CustomEditor), undefined); }); -function assertInstalledPiPairBehavior(version: "0.87.1", EditorClass: typeof Editor, CustomClass: { prototype: unknown } | undefined): void { +function assertInstalledPiPairBehavior(version: string, EditorClass: typeof Editor, CustomClass: { prototype: unknown } | undefined): void { assert.equal(CustomClass ? Object.getPrototypeOf(CustomClass.prototype) : EditorClass.prototype, EditorClass.prototype); const e = new EditorClass({ terminal: { rows: 6, columns: 22 }, requestRender() {} } as never, { borderColor: (s: string) => s } as never); e.setText("alpha beta\nthird line"); @@ -223,14 +239,14 @@ function assertInstalledPiPairBehavior(version: "0.87.1", EditorClass: typeof Ed assert.equal(adapter.renderSelection(18, { line: 0, col: 0 }, { line: 0, col: 6 }, scrolled).length, scrolled.length); } -test("installed Pi 0.87.1 local pair proves version, constructor identity, editing, paste, selection, wrap and autocomplete", () => { - assertInstalledPiPairBehavior("0.87.1", Editor, undefined); +test("installed Pi local pair proves version, constructor identity, editing, paste, selection, wrap and autocomplete", () => { + assertInstalledPiPairBehavior(LOCAL_TUI_VERSION, Editor, undefined); }); -test("resolveVimRuntime's default entry resolves the declared local 0.87.1 install without any PATH Pi", () => { - assert.deepEqual(resolveVimRuntime(), { version: "0.87.1", editorClass: Editor }); - assert.deepEqual(resolveVimRuntime("/nonexistent/cli.js"), { version: "0.87.1", editorClass: Editor }); - assert.deepEqual(resolveVimRuntime("C:\\nonexistent\\cli.js"), { version: "0.87.1", editorClass: Editor }); +test("resolveVimRuntime's default entry resolves the declared local install without any PATH Pi", () => { + assert.deepEqual(resolveVimRuntime(), { version: LOCAL_TUI_VERSION, editorClass: Editor }); + assert.deepEqual(resolveVimRuntime("/nonexistent/cli.js"), { version: LOCAL_TUI_VERSION, editorClass: Editor }); + assert.deepEqual(resolveVimRuntime("C:\\nonexistent\\cli.js"), { version: LOCAL_TUI_VERSION, editorClass: Editor }); }); test("resolveVimRuntime path patterns admit Windows and POSIX separators and reject impostors", () => { @@ -254,22 +270,22 @@ test("resolveVimRuntime path patterns admit Windows and POSIX separators and rej test("resolveVimRuntime resolves local bundle cli entrypoint when provided", () => { const localCli = resolve("node_modules/@earendil-works/pi-coding-agent/dist/bundle/cli.js"); if (existsSync(localCli)) { - assert.deepEqual(resolveVimRuntime(localCli), { version: "0.87.1", editorClass: Editor }); + assert.deepEqual(resolveVimRuntime(localCli), { version: LOCAL_TUI_VERSION, editorClass: Editor }); } }); -test("installed Pi 0.87.1 pair proves version, constructor identity, editing, paste, selection, wrap and autocomplete", { skip: skip0871 }, () => { - assert.equal(runtimeTuiPackage.version, "0.87.1"); - assert.equal(runtimeAgentPackage.version, "0.87.1"); - assertInstalledPiPairBehavior("0.87.1", runtimeTui.Editor, runtimeAgent.CustomEditor); +test("installed Pi pair proves version, constructor identity, editing, paste, selection, wrap and autocomplete", { skip: skipHost }, () => { + assert.ok(SUPPORTED_VERSIONS.has(runtimeTuiPackage.version)); + assert.equal(runtimeAgentPackage.version, runtimeTuiPackage.version); + assertInstalledPiPairBehavior(runtimeTuiPackage.version, runtimeTui.Editor, runtimeAgent.CustomEditor); }); -test("0.87.1 CustomEditor subclass admits the same adapter and preserves its own frame", { skip: skip0871 }, () => { +test("PATH host CustomEditor subclass admits the same adapter and preserves its own frame", { skip: skipHost }, () => { class RuntimePrompt extends runtimeAgent.CustomEditor {} const e = new RuntimePrompt({ terminal: { rows: 6, columns: 30 }, requestRender() {} } as never, { borderColor: (s: string) => s } as never, { matches: () => false } as never); e.setText("one two"); - const adapter = createVimEditorAdapter(e, "0.87.1", runtimeTui.Editor); + const adapter = createVimEditorAdapter(e, runtimeTuiPackage.version, runtimeTui.Editor); adapter.move({ line: 0, col: 0 }); adapter.replace({ line: 0, col: 0 }, { line: 0, col: 3 }, "snow"); assert.equal(e.getText(), "snow two"); @@ -279,26 +295,26 @@ test("0.87.1 CustomEditor subclass admits the same adapter and preserves its own assert.equal(adapter.renderSelection(30, { line: 0, col: 0 }, { line: 0, col: 3 }, rows).length, rows.length); }); -test("runtime identity and prototype mismatch reject without mutation", { skip: skip0871 }, () => { +test("runtime identity and prototype mismatch reject without mutation", { skip: skipHost }, () => { const e = new runtimeTui.Editor({ terminal: { rows: 6 }, requestRender() {} } as never, { borderColor: (s: string) => s } as never); e.setText("untouched"); assert.throws(() => createVimEditorAdapter(e, "0.85.1", Editor), /unsupported/i); assert.throws(() => createVimEditorAdapter(e, "0.88.0", runtimeTui.Editor), /unsupported/i); const forged = Object.create(e) as typeof e; - assert.throws(() => createVimEditorAdapter(forged, "0.87.1", runtimeTui.Editor), /unsupported/i); + assert.throws(() => createVimEditorAdapter(forged, runtimeTuiPackage.version, runtimeTui.Editor), /unsupported/i); assert.equal(e.getText(), "untouched"); }); test("rejects unknown editor shape without mutation", () => { const unknown = { getText: () => "untouched" }; - assert.throws(() => createVimEditorAdapter(unknown, "0.87.1"), /unsupported/i); + assert.throws(() => createVimEditorAdapter(unknown, LOCAL_TUI_VERSION), /unsupported/i); assert.equal(unknown.getText(), "untouched"); }); test("Unicode and multiline cursor stays on atomic grapheme boundaries", () => { const e = editor(); e.setText("a๐Ÿ‘ฉโ€๐Ÿ’ปb\nรฉx"); - const adapter = createVimEditorAdapter(e, "0.87.1"); + const adapter = createVimEditorAdapter(e, LOCAL_TUI_VERSION); adapter.move({ line: 0, col: 1 }); adapter.moveByGraphemes(1); assert.deepEqual(e.getCursor(), { line: 0, col: 6 }); @@ -314,7 +330,7 @@ test("duplicate literal registered paste markers reject private Vim operations w e.insertTextAtCursor(` ${markerText}`); const before = e.getText(); const expanded = e.getExpandedText(); - assert.throws(() => createVimEditorAdapter(e, "0.87.1"), /duplicate.*paste marker/i); + assert.throws(() => createVimEditorAdapter(e, LOCAL_TUI_VERSION), /duplicate.*paste marker/i); assert.equal(e.getText(), before); assert.equal(e.getExpandedText(), expanded); }); @@ -324,19 +340,19 @@ test("motion boundaries treat registered collapsed paste as one unit, not marker e.setText("a"); e.handleInput(`\x1b[200~${"z".repeat(1001)}\x1b[201~`); const end = e.getText().length; - const adapter = createVimEditorAdapter(e, "0.87.1"); + const adapter = createVimEditorAdapter(e, LOCAL_TUI_VERSION); assert.deepEqual(adapter.motionBoundaries()[0], [0, 1, end]); adapter.move({ line: 0, col: 1 }); assert.throws(() => adapter.move({ line: 0, col: 2 }), /boundary/i); const plain = editor(); plain.setText("a[paste #1 1001 chars]b"); - assert.ok(createVimEditorAdapter(plain, "0.87.1").motionBoundaries()[0]!.includes(2)); + assert.ok(createVimEditorAdapter(plain, LOCAL_TUI_VERSION).motionBoundaries()[0]!.includes(2)); }); test("a complete insert session undoes in one unit without crossing an earlier edit", () => { const e = editor(); e.setText("base"); - const adapter = createVimEditorAdapter(e, "0.87.1"); + const adapter = createVimEditorAdapter(e, LOCAL_TUI_VERSION); adapter.replace({ line: 0, col: 4 }, { line: 0, col: 4 }, "!"); adapter.beginInsertSession(); e.handleInput("a"); e.handleInput(" "); e.handleInput("b"); @@ -353,7 +369,7 @@ test("change operators followed immediately by Escape preserve deletion as one u for (const keys of [["c", "w"], ["s"], ["S"]]) { const e = editor(); e.setText("alpha beta"); - const adapter = createVimEditorAdapter(e, "0.87.1"); + const adapter = createVimEditorAdapter(e, LOCAL_TUI_VERSION); const operator = new VimOperatorEngine(); let edit; for (const key of keys) edit = operator.input(key, e.getText(), { line: 0, col: 0 }, adapter.motionBoundaries())?.edit ?? edit; @@ -373,7 +389,7 @@ test("change operators followed immediately by Escape preserve deletion as one u test("insert capture records only anchored semantic text, not edits elsewhere or paste markers", () => { const e = editor(); e.setText("ab"); - const adapter = createVimEditorAdapter(e, "0.87.1"); + const adapter = createVimEditorAdapter(e, LOCAL_TUI_VERSION); adapter.move({ line: 0, col: 1 }); adapter.beginInsertSession(); e.handleInput("๐Ÿ‘ฉโ€๐Ÿ’ป"); e.handleInput("รฉ"); assert.equal(adapter.endInsertSession(), "๐Ÿ‘ฉโ€๐Ÿ’ปรฉ"); @@ -383,7 +399,7 @@ test("insert capture records only anchored semantic text, not edits elsewhere or adapter.beginInsertSession(); assert.equal(adapter.endInsertSession(), undefined); const paste = editor(); - const guarded = createVimEditorAdapter(paste, "0.87.1"); + const guarded = createVimEditorAdapter(paste, LOCAL_TUI_VERSION); guarded.beginInsertSession(); paste.handleInput(`\x1b[200~${"z".repeat(1001)}\x1b[201~`); assert.equal(guarded.endInsertSession(), undefined); @@ -394,7 +410,7 @@ test("rejected duplicate marker closes insert session without touching undo, all const e = editor(); e.handleInput(`\x1b[200~${"z".repeat(1001)}\x1b[201~`); const markerText = e.getText(); - const adapter = createVimEditorAdapter(e, "0.87.1"); + const adapter = createVimEditorAdapter(e, LOCAL_TUI_VERSION); adapter.beginInsertSession(); e.insertTextAtCursor(markerText); const undo = (e as unknown as { undoStack: { stack: unknown[] } }).undoStack.stack.slice(); @@ -413,7 +429,7 @@ test("rejected duplicate marker closes insert session without touching undo, all test("insert session does not resurrect an undone pre-session snapshot", () => { const e = editor(); e.setText("base"); - const adapter = createVimEditorAdapter(e, "0.87.1"); + const adapter = createVimEditorAdapter(e, LOCAL_TUI_VERSION); adapter.beginInsertSession(); adapter.undo(); assert.equal(e.getText(), ""); @@ -427,7 +443,7 @@ test("insert session does not resurrect an undone pre-session snapshot", () => { test("empty insert session does not add an undo unit or lose paste registration", () => { const e = editor(); e.handleInput(`\x1b[200~${"z".repeat(1001)}\x1b[201~`); - const adapter = createVimEditorAdapter(e, "0.87.1"); + const adapter = createVimEditorAdapter(e, LOCAL_TUI_VERSION); adapter.beginInsertSession(); adapter.endInsertSession(); assert.equal(e.getExpandedText(), "z".repeat(1001)); @@ -438,7 +454,7 @@ test("empty insert session does not add an undo unit or lose paste registration" test("equal-text range replacement does not hide an earlier undo", () => { const e = editor(); e.setText("abc"); - const adapter = createVimEditorAdapter(e, "0.87.1"); + const adapter = createVimEditorAdapter(e, LOCAL_TUI_VERSION); adapter.replace({ line: 0, col: 0 }, { line: 0, col: 1 }, ""); assert.equal(e.getText(), "bc"); adapter.replace({ line: 0, col: 0 }, { line: 0, col: 2 }, "bc"); @@ -453,7 +469,7 @@ test("one range edit is one undo; history and collapsed paste registry survive", e.insertTextAtCursor("๐Ÿ‘ฉโ€๐Ÿ’ป\ntail"); const before = e.getText(); const expanded = e.getExpandedText(); - const adapter = createVimEditorAdapter(e, "0.87.1"); + const adapter = createVimEditorAdapter(e, LOCAL_TUI_VERSION); adapter.replace({ line: 1, col: 0 }, { line: 1, col: 4 }, "new"); assert.equal(e.getText(), before.replace("tail", "new")); assert.equal(e.getExpandedText(), expanded.replace("tail", "new")); @@ -469,12 +485,12 @@ test("visual register reads reject registered paste markers without mutating the const e = editor(); e.handleInput(`\x1b[200~${"z".repeat(1001)}\x1b[201~`); const markerText = e.getText(); - const adapter = createVimEditorAdapter(e, "0.87.1"); + const adapter = createVimEditorAdapter(e, LOCAL_TUI_VERSION); assert.throws(() => adapter.readRange({ line: 0, col: 0 }, { line: 0, col: markerText.length }), /paste marker/i); assert.equal(e.getText(), markerText); assert.equal(e.getExpandedText(), "z".repeat(1001)); const plain = editor(); plain.setText("[paste #1 1001 chars]"); - assert.equal(createVimEditorAdapter(plain, "0.87.1").readRange({ line: 0, col: 0 }, { line: 0, col: plain.getText().length }), plain.getText()); + assert.equal(createVimEditorAdapter(plain, LOCAL_TUI_VERSION).readRange({ line: 0, col: 0 }, { line: 0, col: plain.getText().length }), plain.getText()); }); test("real Pi editor shifts tab-indented lines in both directions with one undo per edit", () => { @@ -484,7 +500,7 @@ test("real Pi editor shifts tab-indented lines in both directions with one undo // to exercise the adapter's version-gated handling of legacy literal tabs. e.setText(" ๐Ÿ‘ฉโ€๐Ÿ’ป alpha\nnext"); (e as unknown as { state: { lines: string[] } }).state.lines[0] = "\t๐Ÿ‘ฉโ€๐Ÿ’ป alpha"; - const adapter = createVimEditorAdapter(e, "0.87.1"); + const adapter = createVimEditorAdapter(e, LOCAL_TUI_VERSION); const operator = new VimOperatorEngine(); let result; for (const stroke of [key, key]) result = operator.input(stroke, e.getText(), { line: 0, col: 0 }, adapter.motionBoundaries()); @@ -502,7 +518,7 @@ test("tab-bearing replacement cannot duplicate a registered paste marker", () => e.handleInput(`\x1b[200~${"z".repeat(1001)}\x1b[201~`); const original = e.getText(); const expanded = e.getExpandedText(); - const adapter = createVimEditorAdapter(e, "0.87.1"); + const adapter = createVimEditorAdapter(e, LOCAL_TUI_VERSION); assert.throws(() => adapter.replace({ line: 0, col: 0 }, { line: 0, col: 0 }, `\t${original}`), /duplicate.*paste marker/i); assert.equal(e.getText(), original); assert.equal(e.getExpandedText(), expanded); @@ -517,7 +533,7 @@ test("tab-bearing replacement cannot duplicate a registered paste marker", () => test("operator insertion and registered marker guards keep one undo snapshot", () => { const e = editor(); e.setText("a\nb"); - const adapter = createVimEditorAdapter(e, "0.87.1"); + const adapter = createVimEditorAdapter(e, LOCAL_TUI_VERSION); adapter.replace({ line: 0, col: 1 }, { line: 0, col: 1 }, "๐Ÿ‘ฉโ€๐Ÿ’ป\n"); assert.equal(e.getText(), "a๐Ÿ‘ฉโ€๐Ÿ’ป\n\nb"); e.handleInput("\x1f"); @@ -525,7 +541,7 @@ test("operator insertion and registered marker guards keep one undo snapshot", ( const paste = editor(); paste.handleInput(`\x1b[200~${"z".repeat(1001)}\x1b[201~`); const end = paste.getText().length; - assert.throws(() => createVimEditorAdapter(paste, "0.87.1").replace( + assert.throws(() => createVimEditorAdapter(paste, LOCAL_TUI_VERSION).replace( { line: 0, col: 0 }, { line: 0, col: end }, ""), /paste marker/i); assert.equal(paste.getExpandedText(), "z".repeat(1001)); }); @@ -533,8 +549,8 @@ test("operator insertion and registered marker guards keep one undo snapshot", ( test("selection survives fake cursor reset and retains surrounding color", () => { const e = editor(); e.setText("abcd"); - createVimEditorAdapter(e, "0.87.1").move({ line: 0, col: 1 }); - const frame = createVimEditorAdapter(e, "0.87.1").renderSelection(20, { line: 0, col: 0 }, { line: 0, col: 4 }); + createVimEditorAdapter(e, LOCAL_TUI_VERSION).move({ line: 0, col: 1 }); + const frame = createVimEditorAdapter(e, LOCAL_TUI_VERSION).renderSelection(20, { line: 0, col: 0 }, { line: 0, col: 4 }); const row = frame.find((line) => line.includes("a")) ?? frame.join(""); let reverse = false; const selected: string[] = []; @@ -553,7 +569,7 @@ test("selection survives fake cursor reset and retains surrounding color", () => test("truecolor channels do not change inverse state across selected and unselected cells", () => { const e = editor(); e.setText("ab"); - const adapter = createVimEditorAdapter(e, "0.87.1"); + const adapter = createVimEditorAdapter(e, LOCAL_TUI_VERSION); const rows = e.render(20); rows[1] = rows[1]!.replace("ab", "a\x1b[38;2;7;0;27mb"); const row = adapter.renderSelection(20, { line: 0, col: 0 }, { line: 0, col: 1 }, rows)[1]!; @@ -577,7 +593,7 @@ test("truecolor channels do not change inverse state across selected and unselec test("focused cursor marker survives highlighting while unfocused rows remain valid", () => { const e = editor(); e.setText("abcd"); - const adapter = createVimEditorAdapter(e, "0.87.1"); + const adapter = createVimEditorAdapter(e, LOCAL_TUI_VERSION); adapter.move({ line: 0, col: 2 }); e.focused = true; const source = e.render(20); @@ -591,7 +607,7 @@ test("focused cursor marker survives highlighting while unfocused rows remain va test("selection ending at the software cursor retains its inverse cell", () => { const e = editor(); e.setText("abcd"); - const adapter = createVimEditorAdapter(e, "0.87.1"); + const adapter = createVimEditorAdapter(e, LOCAL_TUI_VERSION); adapter.move({ line: 0, col: 2 }); e.focused = false; const row = adapter.renderSelection(20, { line: 0, col: 0 }, { line: 0, col: 2 }).find((line) => line.includes("a"))!; @@ -602,7 +618,7 @@ test("selection ending at the software cursor retains its inverse cell", () => { test("scrolled identical wraps paint exactly the selected visible cells, not earlier copies", () => { const e = editor(); e.setText("a".repeat(80)); - const adapter = createVimEditorAdapter(e, "0.87.1"); + const adapter = createVimEditorAdapter(e, LOCAL_TUI_VERSION); adapter.move({ line: 0, col: 80 }); const rows = adapter.renderSelection(6, { line: 0, col: 55 }, { line: 0, col: 67 }); assert.equal((e as unknown as { scrollOffset: number }).scrollOffset, 9); @@ -616,7 +632,7 @@ test("focused marker remains at its original visual column through selection", ( const e = editor(); e.setText("abcd"); e.focused = true; - const adapter = createVimEditorAdapter(e, "0.87.1"); + const adapter = createVimEditorAdapter(e, LOCAL_TUI_VERSION); adapter.move({ line: 0, col: 2 }); const before = e.render(12); const after = adapter.renderSelection(12, { line: 0, col: 0 }, { line: 0, col: 3 }, before); @@ -631,7 +647,7 @@ test("empty logical line column zero keeps the focused cursor and paints neighbo const e = editor(); e.setText("a\n\nb"); e.focused = true; - const adapter = createVimEditorAdapter(e, "0.87.1"); + const adapter = createVimEditorAdapter(e, LOCAL_TUI_VERSION); adapter.move({ line: 1, col: 0 }); const rows = adapter.renderSelection(12, { line: 0, col: 0 }, { line: 2, col: 1 }); assert.equal(rows[2]!.indexOf(CURSOR_MARKER), 0); @@ -645,7 +661,7 @@ test("split paste marker paints its visible cells across scroll without selectin e.handleInput(`\x1b[200~${"z".repeat(1001)}\x1b[201~`); const pasteEnd = e.getText().length; e.insertTextAtCursor("TAIL"); - const adapter = createVimEditorAdapter(e, "0.87.1"); + const adapter = createVimEditorAdapter(e, LOCAL_TUI_VERSION); adapter.move({ line: 0, col: pasteEnd }); const rows = adapter.renderSelection(8, { line: 0, col: 35 }, { line: 0, col: pasteEnd }); assert.ok((e as unknown as { scrollOffset: number }).scrollOffset > 0); @@ -681,7 +697,7 @@ test("installed-version mismatch fails closed without changing the rendered fram test("empty logical line accepts cursor column zero", () => { const e = editor(); e.setText("a\n\nb"); - createVimEditorAdapter(e, "0.87.1").move({ line: 1, col: 0 }); + createVimEditorAdapter(e, LOCAL_TUI_VERSION).move({ line: 1, col: 0 }); assert.deepEqual(e.getCursor(), { line: 1, col: 0 }); }); @@ -689,7 +705,7 @@ test("visual highlight spans wraps and treats collapsed paste as one selection u const e = editor(); e.handleInput(`\x1b[200~${"z".repeat(1001)}\x1b[201~`); e.insertTextAtCursor(" following long words"); - const adapter = createVimEditorAdapter(e, "0.87.1"); + const adapter = createVimEditorAdapter(e, LOCAL_TUI_VERSION); const lines = adapter.renderSelection(12, { line: 0, col: 0 }, { line: 0, col: e.getText().length }); assert.ok(lines.filter((line) => line.includes("\x1b[7m")).length > 1); assert.equal(e.getExpandedText(), "z".repeat(1001) + " following long words");