diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c17eb4a..3fb81985 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -142,6 +142,9 @@ jobs: continue-on-error: true - name: Build and smoke-test npm artifact run: npm run build --prefix "${{ matrix.package }}" + - if: matrix.package == 'pi-slate' + name: Real interactive compatibility with latest Pi and packed Slate + run: npm run test:compat:latest --prefix pi-slate - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: npm-${{ matrix.package }} diff --git a/.github/workflows/slate-compat.yml b/.github/workflows/slate-compat.yml new file mode 100644 index 00000000..0bebc0a3 --- /dev/null +++ b/.github/workflows/slate-compat.yml @@ -0,0 +1,34 @@ +name: Slate upstream compatibility + +on: + schedule: + - cron: '19 6 * * *' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: slate-upstream-compatibility + cancel-in-progress: false + +jobs: + compatibility: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: '24' + cache: npm + cache-dependency-path: pi-slate/package-lock.json + - run: npm ci --prefix pi-slate --ignore-scripts --no-audit --no-fund + - name: Real interactive baseline + run: npm run test:compat --prefix pi-slate + - name: Build and smoke-test artifact + run: npm run build --prefix pi-slate + - name: Real interactive latest Pi with packed Slate + run: npm run test:compat:latest --prefix pi-slate diff --git a/pi-ask/package-lock.json b/pi-ask/package-lock.json index ce63b6e1..85fb49b2 100644 --- a/pi-ask/package-lock.json +++ b/pi-ask/package-lock.json @@ -1945,9 +1945,9 @@ } }, "node_modules/undici": { - "version": "8.12.2", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.2.tgz", - "integrity": "sha512-XtuDWVdqTLr3NAxm2ly9/hU6kUsJOgGh1RASzMBbsw6aWCOaQzHdW+QQDfdERjfJ2V5vL5pRPa4RetO9UfivDg==", + "version": "8.12.4", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.4.tgz", + "integrity": "sha512-8Mx+uNKKER2bA4AdtedkqRbwUCIJFr2nJckXRe5yrDqzePqwLP7VNcctN8/XdmDSbF7mS+jJKIszlL+vbam1VA==", "dev": true, "license": "MIT", "engines": { diff --git a/pi-ask/package.json b/pi-ask/package.json index 6ea8874f..c8be6d4d 100644 --- a/pi-ask/package.json +++ b/pi-ask/package.json @@ -50,7 +50,7 @@ "typebox": "*" }, "overrides": { - "undici": "8.12.2" + "undici": "8.12.4" }, "devDependencies": { "@earendil-works/pi-ai": "^1.4.0", diff --git a/pi-automode/package-lock.json b/pi-automode/package-lock.json index 9d982ff6..979f224e 100644 --- a/pi-automode/package-lock.json +++ b/pi-automode/package-lock.json @@ -1853,9 +1853,9 @@ } }, "node_modules/undici": { - "version": "8.12.2", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.2.tgz", - "integrity": "sha512-XtuDWVdqTLr3NAxm2ly9/hU6kUsJOgGh1RASzMBbsw6aWCOaQzHdW+QQDfdERjfJ2V5vL5pRPa4RetO9UfivDg==", + "version": "8.12.4", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.4.tgz", + "integrity": "sha512-8Mx+uNKKER2bA4AdtedkqRbwUCIJFr2nJckXRe5yrDqzePqwLP7VNcctN8/XdmDSbF7mS+jJKIszlL+vbam1VA==", "devOptional": true, "license": "MIT", "engines": { diff --git a/pi-automode/package.json b/pi-automode/package.json index 166d138d..d340264d 100644 --- a/pi-automode/package.json +++ b/pi-automode/package.json @@ -72,7 +72,7 @@ "typebox": "*" }, "overrides": { - "undici": "8.12.2" + "undici": "8.12.4" }, "devDependencies": { "@earendil-works/pi-coding-agent": "1.3.0", diff --git a/pi-slate/CHANGELOG.md b/pi-slate/CHANGELOG.md index a9035e2d..8ac235a5 100644 --- a/pi-slate/CHANGELOG.md +++ b/pi-slate/CHANGELOG.md @@ -2,6 +2,23 @@ ## Unreleased +### Changed + +- MCP is now configuration-only: removed private Pi imports, version gates, and duplicated server validation. Pi's `/mcp` owns schema validity, authentication, and connection health. Slate still reports malformed/unreadable files and displays unknown enabled state neutrally. +- Optional UI capability loss, styling ownership conflicts, and adapter faults use quiet native fallbacks with `/slate diagnostics` details instead of startup warnings. Faulted adapters are isolated and retried on session replacement or reload; real settings and user-action failures remain visible. +- One chat-surface owner projects the visible messages, then paints grouped or native rows. Tool grouping no longer patches the host or negotiates with a second wrapper; faults retain the window in the same frame. Canonical messages and successor render/mouse owners remain intact. +- Removed rendered-text filtering of native new-session notices so genuine conversation content cannot be mistaken for a banner and discarded. + +### Fixed + +- Sidebar layout now uses root-identity leases without intercepting `setLayoutRoot`, including Pi's forwarding TUI proxy and successor layouts. +- Editor adapters unwind in reverse order and retained disposed image/selection wrappers delegate to native behavior. Paste reveals require undo support, handle blank-ending bodies, and cannot collapse stale ranges over edited prompt content. + +### Development + +- Added real regular/fullscreen startup, mount, render, session replacement, and shutdown checks for default/no/full surfaces against the pinned baseline and a freshly resolved latest Pi running the packed Slate artifact. Cases isolate HOME/settings and prohibit execution-time networking. +- Added a daily upstream compatibility canary, latest-host PR gating, architecture guards against private/computed host imports, and production-wiring tests for quiet fallbacks, fault isolation, and secret-free diagnostics. + ## 0.2.0 Four Catppuccin flavors behind a single /theme picker, and edits and writes that keep their diffs visible in collapsed tool cards. diff --git a/pi-slate/README.md b/pi-slate/README.md index 98e77cd9..52d2ced4 100644 --- a/pi-slate/README.md +++ b/pi-slate/README.md @@ -60,6 +60,8 @@ The sidebar keeps session facts visible without sending them to a model: a live ![Slate session dashboard](https://raw.githubusercontent.com/GaganSD/coding-agent-toolkit/1e84be304270c8d859e04e7005dc3cf674fbd574/pi-slate/assets/slate-session.png) +MCP indicators show **requested configuration**, not schema validity or connection health. Slate reads enabled flags without importing Pi's MCP internals, validating transports/authentication, running credential commands, or connecting to servers. Unreadable/malformed files remain visible; unknown enabled state is a neutral `—`. Use `/mcp` for authoritative validation and live status. + ### Background Tasks & Subagents Other extensions can publish detached terminals or jobs to the sidebar's task shelf through the `pi:background-tasks` event on Pi's event bus; snapshots are display-only and cleared with their session. With `editor` and a fullscreen `sidebar` selected, Slate can also lend the sidebar's chat slot to a pi-subagents worker conversation — a UI-only handshake with no new model tools — and restores the same surfaces on return. Without a compatible slot, pi-subagents uses its own fallback. @@ -123,6 +125,7 @@ These work when `editor` is selected. No terminal configuration. | Working indicator | `/slate progress [waves\|dna-helix\|snake\|cylon-scanner]` | Animate the composer's working-status slot. `waves` is the default. | | Sidebar width | `/slate width [default\|narrow\|medium\|wide\|]` | Choose the sidebar width and return to standard mode. `default` is 20%. | | Session dashboard | `/slate session [mcp\|skills\|commands\|tasks\|image]` | Inspect facts, expand/collapse resources, insert commands, or manage the image shelf. | +| Diagnostics | `/slate diagnostics` | Inspect optional feature availability, native fallbacks, and next steps without changing preferences or adding model context. | | Message length | `/slate message-length [default\|all\|]` | How many chat messages stay on screen. `default` is 100. | | Density | `/slate density [comfortable\|compact]` | Comfortable shows a › prompt in the composer; compact is tighter. | | Composer metadata | `/slate footer [standard\|minimal]` | Legacy command alias: standard shows model and thinking on the composer; minimal hides them. When the dashboard is hidden, thinking follows context/MCP on the top edge and the model stays below. Not footer-slot ownership. | @@ -145,6 +148,32 @@ Changes are saved and require `/reload`; appearance settings stay live. A fresh | `tool-cards` | Highlighted edit/write cards and grouped reads | | `transcript` | Visible-message window | +## Compatibility and diagnostics + +Slate treats an unavailable optional presentation feature as a **fallback**, not a server or application failure. Regular terminal mode, a missing renderer hook, or a later extension taking a layout slot no longer generates a startup warning. Real settings/file problems and failed copy/save/open actions remain visible. + +Run `/slate diagnostics` for feature-level `active` / `fallback` details. Adapter exceptions are isolated and quarantined for that session; `/reload` retries them. Reports retain fixed, actionable reasons—not exception text, credentials, raw configuration, or conversation contents. Fallbacks do not enable fullscreen, rewrite preferences, or take back another extension's slots. + +Compatibility boundaries: + +- Production imports only public Pi package entrypoints; an architecture test rejects private/computed host imports. +- MCP is a read-only configuration projection. Pi owns server validation, authentication, and connections. +- Sidebar layout uses root-identity leases, not intercepted setters or function-identity assumptions. +- One chat-surface owner copies the visible message list and paints grouped or native rows without removing host components. Active work stays visible; grouping faults retain the window in the same frame. Unknown render/mouse owners are preserved, and cleanup is not dependent on wrapper order. +- Editor adapters unwind in reverse installation order. Retained disposed wrappers become native delegates. Paste reveals require undo support and never collapse a buffer edited since the reveal. +- Native notices and user/assistant messages are never discarded by matching their rendered text. + +Development checks retain the locked baseline host and separately exercise the **packed artifact with freshly resolved latest stable Pi**: + +```sh +npm run test:unit +npm run test:integration # includes real baseline-host startup/rendering +npm run build +npm run test:compat:latest # temporary latest host + packed Slate consumer +``` + +The real-host probe covers regular/fullscreen mode, default/no/full surfaces, initialization, rendering, session replacement, and shutdown. Each case runs in a fresh process with isolated settings/HOME, no provider credentials, and execution-time networking blocked. The latest-host canary runs in Slate's PR validation and a dedicated daily workflow. Optional UI tree/editor adapters still require contract coverage; these checks detect upstream drift rather than promising compatibility with every future host. + ## License [MIT](LICENSE) © [Gagan Devagiri](https://github.com/GaganSD) diff --git a/pi-slate/extensions/pi-slate/chat-surface.ts b/pi-slate/extensions/pi-slate/chat-surface.ts new file mode 100644 index 00000000..34aed9d9 --- /dev/null +++ b/pi-slate/extensions/pi-slate/chat-surface.ts @@ -0,0 +1,108 @@ +import { Container, ScrollView, isViewportTUI, type Component, type TUI } from "@earendil-works/pi-tui"; +import type { ToolGroups } from "./tool-groups.ts"; + +export function childComponents(component: Component): Component[] { + const stack = component as { entries?: Array<{ component?: Component }> }; + if (Array.isArray(stack.entries) && stack.entries.some((entry) => entry?.component)) { + return stack.entries.flatMap((entry) => (entry.component ? [entry.component] : [])); + } + const container = component as Container; + return Array.isArray(container.children) ? container.children : []; +} + +export function findPrimaryScrollView(root: Component): ScrollView | undefined { + const seen = new Set(); + const queue = [root]; + while (queue.length > 0) { + const node = queue.shift(); + if (!node || seen.has(node)) continue; + seen.add(node); + if (node instanceof ScrollView && node.primary) return node; + queue.push(...childComponents(node)); + } + return undefined; +} + +export function findChatContainer(root: Component | undefined): Container | undefined { + if (!root) return undefined; + const scroll = findPrimaryScrollView(root); + if (!scroll) return undefined; + const content = childComponents(scroll)[0]; + if (!content || typeof (content as Container).addChild !== "function") return undefined; + const nested = childComponents(content).filter((child) => ( + child instanceof Container && !(child instanceof ScrollView) + )); + const chat = nested.at(-1) ?? content; + return typeof (chat as Container).addChild === "function" ? chat as Container : undefined; +} + +export function layoutRootOf(tui: TUI | undefined): Component | undefined { + if (!tui || !isViewportTUI(tui)) return undefined; + return (tui as TUI & { layoutRoot?: Component }).layoutRoot; +} + +export type ChatSurfaceOptions = { + limit?: number; + groups?: ToolGroups; + active?: () => boolean; + onGroupingFault?: () => void; +}; + +/** One owner for the display pipeline: project first, then paint grouped or native rows. */ +export class ChatSurface { + readonly container: Container; + private options: ChatSurfaceOptions; + private readonly originalRender: Container["render"]; + private readonly originalMouse: Container["handleMouse"]; + private readonly installedRender: Container["render"]; + private readonly installedMouse: Container["handleMouse"]; + private readonly display = new Container(); + private paintedGroups: ToolGroups | undefined; + private disposed = false; + + constructor(container: Container, options: ChatSurfaceOptions) { + this.container = container; this.options = { ...options }; + if (container.render !== Container.prototype.render || container.handleMouse !== Container.prototype.handleMouse) { + throw new Error("Unrecognized chat owner; native transcript retained"); + } + this.originalRender = container.render; + this.originalMouse = container.handleMouse; + this.installedRender = width => { + if (this.disposed) return this.originalRender.call(container, width); + const limit = this.options.limit ?? Number.POSITIVE_INFINITY; + const start = !this.options.active?.() && Number.isFinite(limit) ? -Math.max(1, Math.floor(limit)) : 0; + this.display.children = container.children.slice(start); + this.paintedGroups = undefined; + if (this.options.groups) { + try { + const rows = this.options.groups.render(this.display.children, width); + this.paintedGroups = this.options.groups; + return rows; + } catch { + this.options.groups = undefined; + this.options.onGroupingFault?.(); + } + } + return this.display.render(width); + }; + this.installedMouse = event => { + if (this.disposed) return this.originalMouse.call(container, event); + return this.paintedGroups ? this.paintedGroups.handleMouse(event) : this.display.handleMouse(event); + }; + try { + container.render = this.installedRender; + container.handleMouse = this.installedMouse; + } catch (error) { this.dispose(); throw error; } + } + + update(options: ChatSurfaceOptions): void { if (!this.disposed) this.options = { ...options }; } + dispose(): void { + if (this.disposed) return; + this.disposed = true; + if (this.container.render === this.installedRender) this.container.render = this.originalRender; + if (this.container.handleMouse === this.installedMouse) this.container.handleMouse = this.originalMouse; + this.display.children = []; + this.paintedGroups = undefined; + this.options = {}; + } +} diff --git a/pi-slate/extensions/pi-slate/composer-selection.ts b/pi-slate/extensions/pi-slate/composer-selection.ts index 845cfdfc..5171c8ff 100644 --- a/pi-slate/extensions/pi-slate/composer-selection.ts +++ b/pi-slate/extensions/pi-slate/composer-selection.ts @@ -25,6 +25,7 @@ export type ComposerSelectionOptions = { copy?(text: string): void | Promise; requestRender?(): void; onCopyError?(error: unknown): void; + onPasteUnavailable?(): void; }; type PasteableEditor = ComposerSelectionEditor & { @@ -48,6 +49,7 @@ type InstalledEditor = { render: ComposerSelectionEditor["render"]; originalFocus?: PropertyDescriptor; focusGetter?: () => boolean; + deactivate(): void; }; function isSelectAll(data: string): boolean { @@ -106,6 +108,8 @@ type EditorPasteState = { exitHistoryBrowsing?: () => void; onChange?: (text: string) => void; invalidate?: () => void; + pushUndoSnapshot?: () => void; + lastAction?: unknown; }; type RevealedPaste = { @@ -126,11 +130,15 @@ function replaceRange( ): boolean { const internals = editor as unknown as Partial; const state = internals.state; - if (!state || !Array.isArray(state.lines) || startLine < 0 || endLine >= state.lines.length) return false; + if (!state || !Array.isArray(state.lines) || startLine < 0 || endLine >= state.lines.length + || typeof internals.pushUndoSnapshot !== "function") return false; const startLineText = state.lines[startLine]; const endLineText = state.lines[endLine]; - if (!startLineText || !endLineText || startCol < 0 || endCol > endLineText.length) return false; + if (typeof startLineText !== "string" || typeof endLineText !== "string" || startCol < 0 || endCol > endLineText.length) return false; + // The private range adapter is optional: do not rewrite without native undo support. + internals.pushUndoSnapshot.call(editor); + internals.lastAction = null; internals.cancelAutocomplete?.call(editor); internals.exitHistoryBrowsing?.call(editor); @@ -173,6 +181,7 @@ export class ComposerSelectionController { private escapeArmedAt = 0; private revision = 0; private revealed: RevealedPaste[] = []; + private revealedText?: string; private readonly now: () => number; constructor(now: () => number = Date.now) { @@ -189,6 +198,7 @@ export class ComposerSelectionController { const originalInsertTextAtCursor = pasteable.insertTextAtCursor; const originalSetText = editor.setText; const originalRender = editor.render; + let pasteUnavailable = false, disposed = false; const setSelected = (on: boolean): void => { this.selected = on; @@ -202,6 +212,7 @@ export class ComposerSelectionController { }; const clearRevealed = (): void => { this.revealed = []; + this.revealedText = undefined; }; const clearInteraction = (): void => { collapse(); @@ -220,12 +231,14 @@ export class ComposerSelectionController { } }; const togglePasteAtCursor = (): boolean => { + // Ranges are only valid for the exact buffer that produced them. Never collapse edited text. + if (this.revealedText !== editor.getText()) clearRevealed(); const cursor = editor.getCursor(); const open = [...this.revealed].reverse().find((item) => cursorInRevealed(item, cursor)); if (open) { const { endLine, endCol } = revealedRange(open); - if (!replaceRange(editor, open.startLine, open.startCol, endLine, endCol, open.token)) return false; - this.revealed = this.revealed.filter((item) => item !== open); + if (!replaceRange(editor, open.startLine, open.startCol, endLine, endCol, open.token)) throw new Error("Paste range adapter unavailable"); + clearRevealed(); return true; } @@ -235,18 +248,22 @@ export class ComposerSelectionController { const id = Number(token.number); const body = internals.pastes instanceof Map ? internals.pastes.get(id) : undefined; if (typeof body !== "string") return false; - if (!replaceRange(editor, cursor.line, token.start, cursor.line, token.end, body)) return false; - this.revealed.push({ id, token: token.token, body, startLine: cursor.line, startCol: token.start }); + if (!replaceRange(editor, cursor.line, token.start, cursor.line, token.end, body)) throw new Error("Paste range adapter unavailable"); + // A second reveal leaves the previous body expanded rather than retaining stale offsets. + this.revealed = [{ id, token: token.token, body, startLine: cursor.line, startCol: token.start }]; + this.revealedText = editor.getText(); return true; }; const setText = (text: string): void => { + if (disposed) { originalSetText.call(editor, text); return; } clearInteraction(); clearRevealed(); originalSetText.call(editor, text); }; const handleInput = (data: string): void => { + if (disposed) { originalHandleInput.call(editor, data); return; } if (matchesKey(data, "escape")) { if (isKeyRelease(data)) return; if (isKeyRepeat(data)) { @@ -312,16 +329,25 @@ export class ComposerSelectionController { }; const handleMouse = (event: TuiMouseEvent): TuiMouseEventResult | undefined => { + if (disposed) return originalHandleMouse.call(editor, event); // Leave press/drag/release unhandled so Pi's screen-level selection can run. if (event.type !== "wheel" && event.type !== "move" && event.type !== "release") { clearInteraction(); } const result = originalHandleMouse.call(editor, event); - if (isPasteClick(event) && togglePasteAtCursor()) options.requestRender?.(); + if (!pasteUnavailable && isPasteClick(event)) { + try { if (togglePasteAtCursor()) options.requestRender?.(); } + catch { + pasteUnavailable = true; + clearRevealed(); + options.onPasteUnavailable?.(); + } + } return result; }; const render = (width: number): string[] => { + if (disposed) return originalRender.call(editor, width); if (editor.focused === false) { collapse(); disarmEscape(); @@ -338,6 +364,7 @@ export class ComposerSelectionController { const handlePaste = originalHandlePaste ? (text: string): void => { + if (disposed) { originalHandlePaste.call(editor, text); return; } this.revision += 1; disarmEscape(); clearRevealed(); @@ -351,6 +378,7 @@ export class ComposerSelectionController { const insertTextAtCursor = originalInsertTextAtCursor ? (text: string): void => { + if (disposed) { originalInsertTextAtCursor.call(editor, text); return; } this.revision += 1; disarmEscape(); if (this.selected) { @@ -386,7 +414,7 @@ export class ComposerSelectionController { set: (value: boolean) => { if (focused === value) return; focused = value; - if (!value) clearInteraction(); + if (!disposed && !value) clearInteraction(); }, }); } @@ -407,12 +435,14 @@ export class ComposerSelectionController { render, originalFocus, focusGetter, + deactivate: () => { disposed = true; }, }; } dispose(): void { const installed = this.installed; if (installed) { + installed.deactivate(); if (installed.editor.handleInput === installed.handleInput) installed.editor.handleInput = installed.originalHandleInput; if (installed.editor.handleMouse === installed.handleMouse) installed.editor.handleMouse = installed.originalHandleMouse; if (installed.editor.render === installed.render) installed.editor.render = installed.originalRender; @@ -438,6 +468,7 @@ export class ComposerSelectionController { this.selected = false; this.escapeArmedText = undefined; this.revealed = []; + this.revealedText = undefined; this.revision += 1; } } diff --git a/pi-slate/extensions/pi-slate/diagnostics.ts b/pi-slate/extensions/pi-slate/diagnostics.ts new file mode 100644 index 00000000..b5791314 --- /dev/null +++ b/pi-slate/extensions/pi-slate/diagnostics.ts @@ -0,0 +1,47 @@ +export type SlateFeature = + | "tool-style" | "tool-ownership" | "tool-groups" | "sidebar" | "transcript" + | "surface-host" | "session-notices" | "command-catalog" | "mcp-catalog" | "dashboard" | "paste-preview" | "configuration"; +export type SlateDiagnostic = { feature: SlateFeature; state: "active" | "fallback"; detail: string }; + +/** Presentation diagnostics are not application/server health. Never notify or retain thrown errors here. */ +export class SlateDiagnostics { + private readonly entries = new Map(); + private readonly failed = new Set(); + + active(feature: SlateFeature, detail: string): void { + if (!this.failed.has(feature)) this.entries.set(feature, { feature, state: "active", detail }); + } + fallback(feature: SlateFeature, detail: string): void { + if (!this.failed.has(feature)) this.entries.set(feature, { feature, state: "fallback", detail }); + } + fault(feature: SlateFeature, detail: string): void { + this.failed.add(feature); + this.entries.set(feature, { feature, state: "fallback", detail }); + } + /** Synchronous optional adapters only. A throwing adapter is quarantined until the next session/reload. */ + attempt(feature: SlateFeature, detail: string, operation: () => T, fallback: () => T): T { + if (this.failed.has(feature)) return fallback(); + try { return operation(); } + catch { + this.fault(feature, detail); + return fallback(); + } + } + reset(): void { this.entries.clear(); this.failed.clear(); } + snapshot(): SlateDiagnostic[] { + return [...this.entries.values()].map(entry => ({ ...entry })).sort((a, b) => a.feature.localeCompare(b.feature)); + } + report(slateVersion: string, piVersion: string, surfaces: readonly string[]): string { + return [ + `Slate ${slateVersion} · Pi ${piVersion}`, + `Selected surfaces: ${surfaces.join(", ") || "none"}`, + "", + "Optional presentation features (not MCP/server health):", + ...this.snapshot().map(entry => `${entry.feature}: ${entry.state} — ${entry.detail}`), + ...(this.entries.size ? [] : ["No optional integrations selected or probed."]), + "", + "Fallbacks preserve native behavior; no preferences are changed. /reload retries failed adapters.", + "MCP is configuration-only in Slate. Use /mcp for validation and live connection status.", + ].join("\n"); + } +} diff --git a/pi-slate/extensions/pi-slate/image-peek.ts b/pi-slate/extensions/pi-slate/image-peek.ts index e48d8e05..a33fe2bb 100644 --- a/pi-slate/extensions/pi-slate/image-peek.ts +++ b/pi-slate/extensions/pi-slate/image-peek.ts @@ -13,6 +13,7 @@ import type { Sidebar } from "./sidebar.ts"; export class ImagePeek { private shownId?: string; + private disposed = false; private rewriteTimer?: ReturnType; private readonly originalHandleMouse: CustomEditor["handleMouse"]; private readonly originalHandleInput: CustomEditor["handleInput"]; @@ -36,12 +37,14 @@ export class ImagePeek { this.originalHandleMouse = this.editor.handleMouse; this.originalHandleInput = this.editor.handleInput; this.handleMouse = (event: TuiMouseEvent): TuiMouseEventResult | undefined => { + if (this.disposed) return this.originalHandleMouse.call(this.editor, event); clearTimeout(this.rewriteTimer); const result = this.originalHandleMouse.call(this.editor, event); this.update(); return result; }; this.handleInput = (data: string): void => { + if (this.disposed) { this.originalHandleInput.call(this.editor, data); return; } const before = this.editor.getText(); this.originalHandleInput.call(this.editor, data); clearTimeout(this.rewriteTimer); @@ -59,6 +62,7 @@ export class ImagePeek { } rewrite(): void { + if (this.disposed) return; const text = this.editor.getText(); const lines = text.split("\n"); const cursor = this.editor.getCursor(); @@ -74,6 +78,7 @@ export class ImagePeek { } update(): void { + if (this.disposed) return; if (!this.workspace?.splitActive || this.workspace.isImagePinned()) return; const number = imageTokenAtCursor(this.editor.getText(), this.editor.getCursor()); const filePath = number ? this.store.get(number) : undefined; @@ -110,6 +115,7 @@ export class ImagePeek { } dispose(): void { + this.disposed = true; clearTimeout(this.rewriteTimer); if (this.editor.handleMouse === this.handleMouse) this.editor.handleMouse = this.originalHandleMouse; if (this.editor.handleInput === this.handleInput) this.editor.handleInput = this.originalHandleInput; diff --git a/pi-slate/extensions/pi-slate/image-placeholders.ts b/pi-slate/extensions/pi-slate/image-placeholders.ts index dfeea3d6..44e96884 100644 --- a/pi-slate/extensions/pi-slate/image-placeholders.ts +++ b/pi-slate/extensions/pi-slate/image-placeholders.ts @@ -149,17 +149,21 @@ export function installEditorPatch( const originalPaste = instance.handlePaste; const originalHandleInput = instance.handleInput; const originalHandleMouse = instance.handleMouse; + let disposed = false; const rewrite = (editor: CustomEditor, text: string) => rewriteClipboardPaths(text, nextImageNumber(editor.getText()), store); function insertPatch(this: CustomEditor, text: string) { + if (disposed) return originalInsert.call(this, text); const result = originalInsert.call(this, rewrite(this, text)); onTokensChanged(); return result; } function pastePatch(this: CustomEditor, text: string) { + if (disposed) { originalPaste.call(this, text); return; } originalPaste.call(this, rewrite(this, text)); onTokensChanged(); } function handleInputPatch(this: CustomEditor, data: string) { + if (disposed) return originalHandleInput.call(this, data); if (!instance.isInPaste) { // Snap before the keystroke so a printable char cannot land inside a token // and break atomic delete. @@ -201,6 +205,7 @@ export function installEditorPatch( return result; } function handleMousePatch(this: CustomEditor, event: TuiMouseEvent): TuiMouseEventResult | undefined { + if (disposed) return originalHandleMouse.call(this, event); const result = originalHandleMouse.call(this, event); // A click can land anywhere in the text; never inside a token. snapCaretOutOfToken(this, originalHandleInput); @@ -211,6 +216,7 @@ export function installEditorPatch( instance.handleInput = handleInputPatch; instance.handleMouse = handleMousePatch; return () => { + disposed = true; if (instance.insertTextAtCursor === insertPatch) instance.insertTextAtCursor = originalInsert; if (instance.handlePaste === pastePatch) instance.handlePaste = originalPaste; if (instance.handleInput === handleInputPatch) instance.handleInput = originalHandleInput; diff --git a/pi-slate/extensions/pi-slate/index.ts b/pi-slate/extensions/pi-slate/index.ts index 5916c828..49b856bf 100644 --- a/pi-slate/extensions/pi-slate/index.ts +++ b/pi-slate/extensions/pi-slate/index.ts @@ -4,7 +4,6 @@ import { fileURLToPath } from "node:url"; import { copyToClipboard, getAgentDir, - getPackageDir, VERSION, type ExtensionAPI, type ExtensionContext, @@ -34,7 +33,8 @@ import { BackgroundTasks, BACKGROUND_TASKS_EVENT, BACKGROUND_TASKS_REQUEST } fro import { commandResources, parseSidebarFolds, sidebarMessageCounts, sidebarText, sidebarUsage, SidebarMcpFiles, SidebarSkills, type SidebarFolds, type SidebarResources } from "./sidebar-data.ts"; import { inspectSidebarText } from "./sidebar-inspector.ts"; import { pickSidebarItem } from "./sidebar-picker.ts"; -import { loadSidebarMcpHost } from "./sidebar-mcp.ts"; +import { mcpDetails } from "./sidebar-mcp.ts"; +import { SlateDiagnostics } from "./diagnostics.ts"; import { TurnCompletion } from "./turn-completion.ts"; import { resolveContextTokens } from "./context-usage.ts"; import { estimateAssistantTokens, TokenRateTracker } from "./token-rate.ts"; @@ -77,8 +77,7 @@ import { openExternalArgs, SLATE_ISSUES_URL, } from "./bug.ts"; -import { findChatContainer, layoutRootOf, syncMessageWindow, type MessageWindow } from "./message-window.ts"; -import { installNewSessionNoticeFilter } from "./stock-notices.ts"; +import { ChatSurface, findChatContainer, layoutRootOf } from "./chat-surface.ts"; import { persistTheme, SLATE_NATIVE_THEME } from "./install-defaults.ts"; import { selectSlateTheme } from "./theme-selector.ts"; import { loadShortcuts, promptShortcutKey, type SlateShortcuts } from "./shortcuts.ts"; @@ -206,12 +205,15 @@ export default function piSlate(pi: ExtensionAPI): void { // Runtime ownership is fixed until /reload, even when a command saves a new set. const selected = [...config.surfaces]; const owns = (surface: typeof selected[number]): boolean => has(selected, surface); + const diagnostics = new SlateDiagnostics(); const requestedClaudeTools = owns("tool-cards") && config.toolStyle !== "default"; - const claudeTools = requestedClaudeTools && typeof pi.registerToolRenderer === "function"; + let claudeTools = false; if (owns("tool-cards")) { installDiff(pi); - if (claudeTools) installToolStyle(pi); - else installRead(pi); + if (requestedClaudeTools && typeof pi.registerToolRenderer === "function") { + claudeTools = diagnostics.attempt("tool-style", "Slate default cards retained; the optional renderer hook failed.", () => { installToolStyle(pi); return true; }, () => false); + } + if (!claudeTools) installRead(pi); } if (owns("editor")) installPromptPicker(pi, promptShortcutKey(config.shortcuts)); installExitCommand(pi); @@ -220,7 +222,6 @@ export default function piSlate(pi: ExtensionAPI): void { const selection = new ComposerSelectionController(); const skills = new SidebarSkills(); const mcpFiles = new SidebarMcpFiles(); - const mcpHost = loadSidebarMcpHost(getPackageDir(), VERSION); const background = new BackgroundTasks(); let resources: SidebarResources = { skills: [], commands: [], prompts: [], mcp: [] }; let sessionStartedAt = 0; @@ -229,8 +230,6 @@ export default function piSlate(pi: ExtensionAPI): void { let dashboardClock: ReturnType | undefined; const dashboardSubscriptions: Array<() => void> = []; let branchFacts: { leaf?: string | null; counts: ReturnType; usage: ReturnType } | undefined; - let noticeFilterDispose: (() => void) | undefined; - let noticeFilterActive = false; let inspectingSidebar = false; let sidebarYielded = false; let sessionEpoch = 0; @@ -263,15 +262,8 @@ export default function piSlate(pi: ExtensionAPI): void { && (typeof ctx.ui.getEditorComponent !== "function" || ctx.ui.getEditorComponent() === editorFactory); }; let conversations: ReturnType | undefined; - let messageWindow: MessageWindow | undefined; - let toolGroups: ToolGroups | undefined; - const syncToolGroups = (): void => { - if (!claudeTools || liveContext()?.mode !== "tui") return; - const chat = findChatContainer(layoutRootOf(activeTui)); - if (!chat || toolGroups?.container === chat) return; - toolGroups?.dispose(); - toolGroups = new ToolGroups(chat, liveTheme); - }; + let chatSurface: ChatSurface | undefined; + let groupPainter: ToolGroups | undefined; const tokenRate = new TokenRateTracker(); const thinkingFold = new ThinkingFoldTracker({ onChange: () => syncThinkingStatus() }); const branch = new GitBranchPoller(pi, (next) => { gitBranch = next; requestRender(); }); @@ -282,9 +274,35 @@ export default function piSlate(pi: ExtensionAPI): void { let requestRender = (_force = false) => {}; const syncVisibleMessages = (): void => { - syncToolGroups(); - if (!owns("transcript") || liveContext()?.mode !== "tui") return; - messageWindow = syncMessageWindow(messageWindow, activeTui, resolveMessageLength(config.messageLength)); + if ((!owns("transcript") && !claudeTools) || liveContext()?.mode !== "tui") return; + const feature = owns("transcript") ? "transcript" : "tool-groups"; + diagnostics.attempt(feature, "Native chat retained; the optional chat adapter failed.", () => { + const chat = findChatContainer(layoutRootOf(activeTui)); + if (chatSurface?.container !== chat) { chatSurface?.dispose(); chatSurface = undefined; } + if (!chat) return; + const options = { + limit: owns("transcript") ? resolveMessageLength(config.messageLength) : Number.POSITIVE_INFINITY, + groups: groupPainter, + active: () => { + const ctx = liveContext(); + return Boolean(ctx && typeof ctx.isIdle === "function" && !ctx.isIdle()); + }, + onGroupingFault: () => { + groupPainter = undefined; + diagnostics.fault("tool-groups", "Native tool rendering restored after the optional grouping painter failed."); + }, + }; + if (chatSurface) chatSurface.update(options); + else chatSurface = new ChatSurface(chat, options); + }, () => { chatSurface?.dispose(); chatSurface = undefined; }); + if (owns("transcript")) { + if (chatSurface) diagnostics.active("transcript", "Display-only message window mounted."); + else diagnostics.fallback("transcript", "Native transcript retained; no compatible fullscreen chat surface."); + } + if (claudeTools) { + if (chatSurface && groupPainter) diagnostics.active("tool-groups", "Consecutive tool cards can be grouped."); + else diagnostics.fallback("tool-groups", "Ungrouped cards retained; no compatible chat surface."); + } }; // A selected split allocates header width; unavailable sidebar hosts stay full-width. @@ -331,12 +349,21 @@ export default function piSlate(pi: ExtensionAPI): void { }; const refreshResources = (ctx: ExtensionContext): void => { if (ctx.mode !== "tui" || !ctx.sessionManager || (!owns("sidebar") && !owns("editor")) || (sidebarYielded && !editorActive())) return; - const commands = pi.getCommands(); - skills.discoverCommands(commands); - const registered = typeof pi.getMcpServers === "function" ? pi.getMcpServers() : []; - const catalog = commandResources(commands); - resources = { commands: catalog.commands, prompts: catalog.prompts, skills: skills.snapshot(), - ...mcpFiles.snapshot(getAgentDir(), ctx.cwd, typeof ctx.isProjectTrusted === "function" && ctx.isProjectTrusted(), registered) }; + const catalog = diagnostics.attempt("command-catalog", "Previous command catalog retained; the host catalog could not be read.", () => { + const commands = pi.getCommands(); + const catalog = commandResources(commands); + skills.discoverCommands(commands); + diagnostics.active("command-catalog", "Public command and skill catalog available."); + return catalog; + }, () => resources); + const mcp = diagnostics.attempt("mcp-catalog", "Previous MCP declarations retained; the catalog could not be read. Use /mcp for live status.", () => { + const registered = typeof pi.getMcpServers === "function" ? pi.getMcpServers() : []; + const snapshot = mcpFiles.snapshot(getAgentDir(), ctx.cwd, typeof ctx.isProjectTrusted === "function" && ctx.isProjectTrusted(), registered); + if (snapshot.mcpError) diagnostics.fallback("mcp-catalog", `${snapshot.mcpError}. Check global mcp.json and trusted .pi/mcp.json; use /mcp for validation.`); + else diagnostics.active("mcp-catalog", "Read-only configured state; Pi owns validation and connections."); + return snapshot; + }, () => ({ mcp: resources.mcp, ...(resources.mcpError ? { mcpError: resources.mcpError } : {}) })); + resources = { commands: catalog.commands, prompts: catalog.prompts, skills: skills.snapshot(), ...mcp }; if (owns("sidebar") && !sidebarYielded) sidebar.setResources(resources); }; const stopDashboard = (): void => { @@ -356,13 +383,14 @@ export default function piSlate(pi: ExtensionAPI): void { } const install = (ctx: ExtensionContext): void => { - if (ctx.mode === "tui" && requestedClaudeTools && !claudeTools) { - ctx.ui.notify("Slate Claude Code tool style requires a host with registerToolRenderer; keeping default cards", "warning"); + chatSurface?.dispose(); chatSurface = undefined; + groupPainter = claudeTools ? new ToolGroups(liveTheme) : undefined; + diagnostics.reset(); + if (requestedClaudeTools) { + if (claudeTools) diagnostics.active("tool-style", "Public tool renderer hook installed."); + else diagnostics.fallback("tool-style", "Slate default cards retained; the optional renderer hook is unavailable."); } sessionEpoch += 1; sidebarYielded = false; - if (!owns("header")) { - noticeFilterDispose?.(); noticeFilterDispose = undefined; noticeFilterActive = false; - } const mountedEpoch = sessionEpoch; currentContext = ctx; if (ctx.mode === "tui") cachedTheme = ctx.ui.theme; @@ -384,22 +412,26 @@ export default function piSlate(pi: ExtensionAPI): void { tokenRate.dispose(); workingWord = undefined; stopWave(); - if (loaded.error) ctx.ui.notify(loaded.error, "warning"); + if (loaded.error) { + diagnostics.fallback("configuration", "Slate surfaces disabled because pi-slate.json could not be read or parsed. File preserved; repair it and /reload."); + ctx.ui.notify(loaded.error, "warning"); + } if (ctx.mode !== "tui") return; if (owns("tool-cards")) { // Only check tools Slate registers. Claude mode styles the native read // renderer without owning its registration, so native read is not a loss. - try { + diagnostics.attempt("tool-ownership", "Host registrations retained; tool ownership could not be inspected.", () => { const registeredCards = claudeTools ? ["edit", "write"] : ["edit", "write", "read"]; const lost = pi.getAllTools().filter((tool) => registeredCards.includes(tool.name) && resolve(tool.sourceInfo.path) !== fileURLToPath(import.meta.url)); - if (lost.length) ctx.ui.notify(`Slate tool-cards unavailable for: ${lost.map((tool) => tool.name).join(", ")} (first registration wins)`, "warning"); - } catch { /* Older hosts cannot expose tool ownership. */ } + if (lost.length) diagnostics.fallback("tool-ownership", `Host registrations retained for ${lost.map((tool) => tool.name).join(", ")}; first registration wins.`); + else diagnostics.active("tool-ownership", "Selected tool registrations are available."); + }, () => {}); } if (owns("editor")) branch.start(ctx.cwd); sessionStartedAt = Date.now(); turnStartedAt = undefined; lastTurnMs = null; branchFacts = undefined; if (owns("editor") || owns("sidebar")) { - skills.reset(pi.getCommands()); + skills.reset([]); skills.restore(ctx.sessionManager.getBranch(), ctx.cwd); refreshResources(ctx); tokenRate.setOnChange(() => { @@ -481,48 +513,40 @@ export default function piSlate(pi: ExtensionAPI): void { // belowEditor has no host leading spacer. This factory renders exactly zero rows. // It is a TUI handle, not a chrome owner or application bootstrap header. if (owns("sidebar") || owns("transcript") || claudeTools) { - ctx.ui.setWidget("pi-slate:surface-host", (tui, theme) => { + diagnostics.attempt("surface-host", "Native UI retained; the optional surface handle could not be mounted.", () => ctx.ui.setWidget("pi-slate:surface-host", (tui, theme) => { activeTui = tui; requestRender = (force = false) => tui.requestRender(force); - queueMicrotask(syncToolGroups); + queueMicrotask(() => { if (mountedContext()) syncVisibleMessages(); }); if (owns("sidebar")) { - sidebar.attach(tui, theme, () => { sidebarYielded = true; stopDashboard(); }); - if (sidebar.splitActive && !dashboardClock) { + const attached = diagnostics.attempt("sidebar", "Native layout retained; the optional sidebar adapter failed.", () => { + sidebar.attach(tui, theme, () => { + sidebarYielded = true; stopDashboard(); + diagnostics.fallback("sidebar", "Native layout retained after a later layout owner replaced Slate."); + }); + return sidebar.splitActive; + }, () => { sidebar.dispose(); return false; }); + if (attached) diagnostics.active("sidebar", "Sidebar mounted; visibility follows preferences and terminal size."); + else diagnostics.fallback("sidebar", "Native layout retained; sidebar needs a compatible fullscreen viewport."); + if (attached && !dashboardClock) { let ticks = 0; dashboardClock = setInterval(() => { const ctx = liveContext(); if (!ctx || ctx.mode !== "tui" || !sidebar.splitActive) return; - sidebar.tick(); - if (++ticks % 5 === 0) { refreshResources(ctx); syncSidebar(ctx); } + diagnostics.attempt("dashboard", "Last dashboard snapshot retained; a background refresh failed.", () => { + sidebar.tick(); + if (++ticks % 5 === 0) { refreshResources(ctx); syncSidebar(ctx); } + }, () => {}); }, 1000); dashboardClock.unref?.(); } - if (!sidebar.splitActive && config.focused !== true) { - ctx.ui.notify("Slate sidebar unavailable: a compatible fullscreen host is required", "warning"); - } } - if (owns("transcript")) queueMicrotask(() => { - if (!mountedContext()) return; - syncVisibleMessages(); - if (!messageWindow) notify("Slate transcript unavailable: a compatible message-window host is required", "warning"); - }); return { render: () => [], invalidate() {} }; - }, { placement: "belowEditor" }); + }, { placement: "belowEditor" }), () => {}); } if (owns("header")) { ctx.ui.setHeader((tui, theme) => { requestRender = (force = false) => tui.requestRender(force); - // Header factory runs inside setHeader, before /new appends the stock chat copy. - const attachFilter = (chat: ReturnType): void => { - if (!chat) return; - noticeFilterDispose?.(); - noticeFilterDispose = installNewSessionNoticeFilter(chat); - noticeFilterActive = true; - }; - attachFilter(findChatContainer(layoutRootOf(tui))); - if (!noticeFilterActive) queueMicrotask(() => { - if (mountedContext()) attachFilter(findChatContainer(layoutRootOf(tui))); - }); + diagnostics.active("session-notices", "Native notices and conversation content are retained without text-based filtering."); return new SlateHeader(theme, liveContext, columnWidth, () => { const ctx = liveContext(); return { @@ -540,8 +564,8 @@ export default function piSlate(pi: ExtensionAPI): void { if (tuiChanged) conversations?.close(); const previousDown = tuiChanged ? undefined : activeEditor?.onDownBoundary; activeTui = tui; - images?.detachEditor(); selection.dispose(); + images?.detachEditor(); const minimalEditorTheme: EditorTheme = { ...editorTheme, borderColor: chromePaint(ctx.ui.theme), @@ -581,6 +605,7 @@ export default function piSlate(pi: ExtensionAPI): void { selection.attach(activeEditor, { copy: (text) => mountedContext() ? copyWithFeedback(tui, notify, text) : Promise.resolve(), requestRender: () => tui.requestRender(), + onPasteUnavailable: () => diagnostics.fault("paste-preview", "Native paste tokens retained; the undo-aware range adapter is unavailable."), }); activeTui = tui; requestRender = (force = false) => tui.requestRender(force); @@ -638,7 +663,7 @@ export default function piSlate(pi: ExtensionAPI): void { } }; - pi.on("session_start", async (_event, ctx) => { mcpFiles.setHost(await mcpHost); install(ctx); }); + pi.on("session_start", (_event, ctx) => { install(ctx); }); pi.on("agent_start", (_event, ctx) => { currentContext = ctx; if (ctx.mode === "tui" && (owns("sidebar") || editorActive() || owns("transcript"))) { @@ -761,17 +786,15 @@ export default function piSlate(pi: ExtensionAPI): void { stopWave(); thinkingFold.stop(); tokenRate.dispose(); - images?.dispose(); selection.dispose(); + images?.dispose(); stopDashboard(); mcpFiles.clear(); background.reset(""); branch.dispose(); - toolGroups?.dispose(); - toolGroups = undefined; - messageWindow?.dispose(); - messageWindow = undefined; - noticeFilterDispose?.(); noticeFilterDispose = undefined; noticeFilterActive = false; + chatSurface?.dispose(); + chatSurface = undefined; + groupPainter = undefined; conversations?.dispose(); conversations = undefined; activeTui = undefined; @@ -1011,7 +1034,7 @@ export default function piSlate(pi: ExtensionAPI): void { const index = labels.indexOf(choice); if (section === "mcp") { const item = mcp[index]; - if (item) inspect(ctx, item.name, `${item.enabled === null ? "—" : item.enabled ? "enabled" : "disabled"} (configuration, not connection status)\nSource: ${item.source}\nUse /mcp to manage this server.`); + if (item) inspect(ctx, item.name, mcpDetails(item)); } else if (section === "skills") { const item = skills[index]; if (item) inspect(ctx, item.name, `${item.loaded ? "Instructions observed loaded on this branch" : "Available; not observed loaded"}\n${item.path}`); @@ -1082,7 +1105,7 @@ export default function piSlate(pi: ExtensionAPI): void { }); pi.registerCommand("slate", { - description: "Session dashboard, surfaces, density, composer metadata, sidebar, focused mode, PID display, effort border color, progress indicator, message length, theme, or file a bug", + description: "Session dashboard, diagnostics, surfaces, density, composer metadata, sidebar, focused mode, PID display, effort border color, progress indicator, message length, theme, or file a bug", getArgumentCompletions: slateArgumentCompletions, handler: async (args, ctx) => { const parsed = parseSlateArgs(args); @@ -1093,7 +1116,7 @@ export default function piSlate(pi: ExtensionAPI): void { let kind = parsed.kind; if (kind === "menu") { - const setting = await ctx.ui.select("Slate", ["Session dashboard", "Surfaces", "Density", "Composer metadata", "Sidebar width", "Focused", "PID display", "Effort border color", "Progress indicator", "Message length", "Theme", "File a bug"]); + const setting = await ctx.ui.select("Slate", ["Session dashboard", "Surfaces", "Density", "Composer metadata", "Sidebar width", "Focused", "PID display", "Effort border color", "Progress indicator", "Message length", "Theme", "Diagnostics", "File a bug"]); if (setting === "Session dashboard") { await sessionMenu(ctx); return; } if (setting === "Surfaces") { const names = await ctx.ui.input("Surfaces (names separated by spaces, full, or none; reload required)", formatSurfaces(config.surfaces)); @@ -1112,11 +1135,18 @@ export default function piSlate(pi: ExtensionAPI): void { else if (setting === "Progress indicator") kind = "progress"; else if (setting === "Message length") kind = "message-length-menu"; else if (setting === "Theme") kind = "theme-menu"; + else if (setting === "Diagnostics") kind = "diagnostics"; else if (setting === "File a bug") kind = "bug-menu"; else return; } if (parsed.kind === "session") { await sessionMenu(ctx, parsed.section); return; } + if (kind === "diagnostics") { + const report = diagnostics.report(SLATE_VERSION, VERSION, selected); + if (ctx.mode === "tui") inspect(ctx, "Slate diagnostics", report); + else ctx.ui.notify(report, "info"); + return; + } if (parsed.kind === "surfaces") { if (parsed.value === undefined) { diff --git a/pi-slate/extensions/pi-slate/layout.ts b/pi-slate/extensions/pi-slate/layout.ts index 8da01ab9..803c0904 100644 --- a/pi-slate/extensions/pi-slate/layout.ts +++ b/pi-slate/extensions/pi-slate/layout.ts @@ -209,7 +209,7 @@ export const SLATE_VERSION = JSON.parse( ).version as string; export const SLATE_USAGE = - "Usage: /slate session [mcp|skills|commands|tasks|image] | surfaces [set |full|none] | density [comfortable|compact] | footer [standard|minimal] | width [default|narrow|medium|wide|] | focused [on|off] | pid [on|off] | effort-border [on|off] | progress [waves|dna-helix|snake|cylon-scanner] | message-length [default|all|] | theme | bug [file|open]"; + "Usage: /slate session [mcp|skills|commands|tasks|image] | diagnostics | surfaces [set |full|none] | density [comfortable|compact] | footer [standard|minimal] | width [default|narrow|medium|wide|] | focused [on|off] | pid [on|off] | effort-border [on|off] | progress [waves|dna-helix|snake|cylon-scanner] | message-length [default|all|] | theme | bug [file|open]"; export const LOGO_USAGE = "Usage: /logo [original|minimal]"; @@ -223,6 +223,7 @@ export function withoutCurrent(label: string): string { const SLATE_COMPLETIONS = [ "session", "session mcp", "session skills", "session prompts", "session commands", "session tasks", "session image", + "diagnostics", "surfaces", "surfaces set", ...SURFACES.map((name) => `surfaces set ${name}`), @@ -264,6 +265,7 @@ const SLATE_COMPLETIONS = [ export type SlateArgs = | { ok: true; kind: "menu" } + | { ok: true; kind: "diagnostics" } | { ok: true; kind: "session"; section?: "mcp" | "skills" | "prompts" | "commands" | "tasks" | "image" } | { ok: true; kind: "surfaces"; value?: Surface[] } | { ok: true; kind: "density"; value?: "comfortable" | "compact" } @@ -285,6 +287,7 @@ export function parseSlateArgs(raw: string): SlateArgs { const words = raw.trim().toLowerCase().split(/\s+/).filter(Boolean); if (words.length === 0) return { ok: true, kind: "menu" }; const [head, tail] = words; + if (head === "diagnostics") return words.length === 1 ? { ok: true, kind: "diagnostics" } : { ok: false }; if (head === "session") { if (words.length === 1) return { ok: true, kind: "session" }; if (words.length === 2 && (tail === "mcp" || tail === "skills" || tail === "prompts" || tail === "commands" || tail === "tasks" || tail === "image")) return { ok: true, kind: "session", section: tail }; diff --git a/pi-slate/extensions/pi-slate/message-window.ts b/pi-slate/extensions/pi-slate/message-window.ts deleted file mode 100644 index ff7eba64..00000000 --- a/pi-slate/extensions/pi-slate/message-window.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { Container, ScrollView, isViewportTUI, type Component, type TUI } from "@earendil-works/pi-tui"; - -export function childComponents(component: Component): Component[] { - const stack = component as { entries?: Array<{ component?: Component }> }; - if (Array.isArray(stack.entries) && stack.entries.some((entry) => entry?.component)) { - return stack.entries.flatMap((entry) => (entry.component ? [entry.component] : [])); - } - const container = component as Container; - return Array.isArray(container.children) ? container.children : []; -} - -export function findPrimaryScrollView(root: Component): ScrollView | undefined { - const seen = new Set(); - const queue = [root]; - while (queue.length > 0) { - const node = queue.shift(); - if (!node || seen.has(node)) continue; - seen.add(node); - if (node instanceof ScrollView && node.primary) return node; - queue.push(...childComponents(node)); - } - return undefined; -} - -export function findChatContainer(root: Component | undefined): Container | undefined { - if (!root) return undefined; - const scroll = findPrimaryScrollView(root); - if (!scroll) return undefined; - const content = childComponents(scroll)[0]; - if (!content || typeof (content as Container).addChild !== "function") return undefined; - const nested = childComponents(content).filter((child) => ( - child instanceof Container && !(child instanceof ScrollView) - )); - const chat = nested.at(-1) ?? content; - return typeof (chat as Container).addChild === "function" ? chat as Container : undefined; -} - -export function layoutRootOf(tui: TUI | undefined): Component | undefined { - if (!tui || !isViewportTUI(tui)) return undefined; - return (tui as TUI & { layoutRoot?: Component }).layoutRoot; -} - -export class MessageWindow { - readonly container: Container; - private readonly addChild: (component: Component) => void; - private readonly removeChild: (component: Component) => void; - private readonly clear: () => void; - private readonly installedAdd: Container["addChild"]; - private readonly installedRemove: Container["removeChild"]; - private readonly installedClear: Container["clear"]; - private hidden: Component[] = []; - private pinned?: Component; - private limit: number; - private disposed = false; - - constructor(container: Container, limit: number) { - this.container = container; - this.limit = limit; - this.addChild = container.addChild.bind(container); - this.removeChild = container.removeChild.bind(container); - this.clear = container.clear.bind(container); - this.installedAdd = (component) => { - if (this.disposed) { this.addChild(component); return; } - this.pinned = this.container.children.at(-1); - this.addChild(component); - this.trim(); - }; - this.installedRemove = (component) => { - if (this.disposed) { this.removeChild(component); return; } - const index = this.hidden.indexOf(component); - if (index >= 0) this.hidden.splice(index, 1); - if (this.pinned === component) this.pinned = undefined; - this.removeChild(component); - }; - this.installedClear = () => { - if (this.disposed) { this.clear(); return; } - this.hidden = []; - this.pinned = undefined; - this.clear(); - }; - container.addChild = this.installedAdd; - container.removeChild = this.installedRemove; - container.clear = this.installedClear; - this.trim(); - } - - setLimit(limit: number): void { - if (this.disposed || this.limit === limit) return; - this.limit = limit; - this.reveal(); - this.trim(); - } - - dispose(): void { - if (this.disposed) return; - this.disposed = true; - this.reveal(); - if (this.container.addChild === this.installedAdd) this.container.addChild = this.addChild; - if (this.container.removeChild === this.installedRemove) this.container.removeChild = this.removeChild; - if (this.container.clear === this.installedClear) this.container.clear = this.clear; - } - - private reveal(): void { - if (this.hidden.length === 0) return; - this.container.children.unshift(...this.hidden); - this.hidden = []; - } - - private trim(): void { - if (!Number.isFinite(this.limit)) return; - const keep = Math.max(0, Math.floor(this.limit)); - while (this.container.children.length > keep) { - const oldest = this.container.children[0]; - if (!oldest || oldest === this.pinned) return; - this.removeChild(oldest); - this.hidden.push(oldest); - } - } -} - -export function syncMessageWindow( - current: MessageWindow | undefined, - tui: TUI | undefined, - limit: number, -): MessageWindow | undefined { - const chat = findChatContainer(layoutRootOf(tui)); - if (!chat) return current; - if (current?.container === chat) { - current.setLimit(limit); - return current; - } - current?.dispose(); - return new MessageWindow(chat, limit); -} diff --git a/pi-slate/extensions/pi-slate/sidebar-data.ts b/pi-slate/extensions/pi-slate/sidebar-data.ts index 8d54a963..884e78fd 100644 --- a/pi-slate/extensions/pi-slate/sidebar-data.ts +++ b/pi-slate/extensions/pi-slate/sidebar-data.ts @@ -1,9 +1,11 @@ -import { readFileSync, statSync } from "node:fs"; import { homedir } from "node:os"; -import { basename, dirname, join, resolve } from "node:path"; +import { basename, dirname, resolve } from "node:path"; import type { SessionEntry, SlashCommandInfo, Skill } from "@earendil-works/pi-coding-agent"; import { fileURLToPath } from "node:url"; -import type { SidebarMcpHost } from "./sidebar-mcp.ts"; +import type { SidebarMcpResult } from "./sidebar-mcp.ts"; +import { record, sidebarText } from "./sidebar-text.ts"; +export { record, sidebarText } from "./sidebar-text.ts"; +export { parseMcpFile, sidebarMcp, SidebarMcpFiles, type SidebarMcp, type SidebarMcpResult } from "./sidebar-mcp.ts"; export type SidebarUsage = { input: number | null; output: number | null; cacheRead: number | null; @@ -12,8 +14,6 @@ export type SidebarUsage = { export type SidebarSkill = { name: string; path: string; source: string; loaded: boolean }; export type SidebarCommand = { name: string; source: string; description?: string }; export type SidebarPrompt = { name: string; source: string; description?: string }; -export type SidebarMcp = { name: string; enabled: boolean | null; source: string }; -export type SidebarMcpResult = { mcp: SidebarMcp[]; mcpError?: string; mcpWarning?: string }; export type SidebarResources = { skills: SidebarSkill[]; commands: SidebarCommand[]; prompts: SidebarPrompt[] } & SidebarMcpResult; export type SidebarSession = { id: string; name?: string; pid: number; cwd: string; model: string; thinking: string; @@ -24,19 +24,10 @@ export type SidebarSession = { export type SidebarFolds = { mcp: boolean; skills: boolean; prompts: boolean }; export const DEFAULT_SIDEBAR_FOLDS: SidebarFolds = { mcp: false, skills: false, prompts: false }; -/** Display-only strings must never inject terminal commands or extra rows. */ -export function sidebarText(value: unknown, max = 512): string { - if (typeof value !== "string") return ""; - return value.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "") - .replace(/[\x00-\x1f\x7f-\x9f]/g, " ").replace(/\s+/g, " ").trim().slice(0, max); -} export function parseSidebarFolds(value: unknown): SidebarFolds { const raw = record(value); return { mcp: raw?.mcp === true, skills: raw?.skills === true, prompts: raw?.prompts === true }; } -export function record(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) ? value as Record : undefined; -} function nonnegative(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null; } @@ -190,93 +181,3 @@ export class SidebarSkills { return skills.sort((a, b) => Number(b.loaded) - Number(a.loaded) || a.name.localeCompare(b.name)); } } - -type McpFile = { servers: Record; error?: string }; -export function parseMcpFile(text: string | undefined, stripBom = false): McpFile { - if (text === undefined) return { servers: {} }; - try { - // Match the verified host's file parser; older Pi versions reject a BOM. - const raw = record(JSON.parse(stripBom && text.startsWith("\uFEFF") ? text.slice(1) : text)); - const servers = record(raw?.mcpServers); - if (!raw || (raw.mcpServers !== undefined && !servers)) return { servers: {}, error: "MCP config invalid" }; - return { servers: servers ?? {} }; - } catch { return { servers: {}, error: "MCP config unreadable" }; } -} - -export function sidebarMcp( - registered: readonly { name: string; config: unknown }[], global: McpFile, project?: McpFile, host?: SidebarMcpHost, -): SidebarMcp[] { return resolveMcp(registered, global, project, host).mcp; } - -function resolveMcp( - registered: readonly { name: string; config: unknown }[], global: McpFile, project: McpFile | undefined, host: SidebarMcpHost | undefined, -): SidebarMcpResult { - const files = new Map; source: string }>(); - const invalid = new Map(); - let rejected = false; - for (const [source, entries] of [["global", global.servers], ["project", project?.servers ?? {}]] as const) { - for (const [name, raw] of Object.entries(entries)) { - let value = raw; - const entry = record(raw); - let reject = false; - if (host?.projectOverrides && source === "project" && entry - && entry.command === undefined && entry.url === undefined && entry.type === undefined) { - const base = files.get(name); // Only an exact-name global file entry, never a registration. - reject = !base || Object.keys(entry).some(key => !["enabled", "exposure", "toolExposure"].includes(key)); - if (base) value = { ...base.config, ...entry }; - } - const config = host && !reject ? record(host.validate(name, value)) : undefined; - if (config && host) { - reject = (host.projectOverrides && [...files.keys()].some(other => other !== name && host.namespace(other) === host.namespace(name))) - || (host.projectOverrides && source === "project" && "url" in config && Boolean(config.auth)); - } - if (!config || reject) { - rejected ||= Boolean(host); - invalid.set(name, { name: sidebarText(name), source, enabled: null }); - } else files.set(name, { config, source }); - } - } - const rows = new Map(); - for (const server of registered) { - if (host && [...files.keys()].some(name => host.namespace(name) === host.namespace(server.name))) continue; - const config = host ? record(host.validate(server.name, server.config)) : undefined; - rows.set(server.name, { name: sidebarText(server.name), source: "extension", enabled: config ? config.enabled !== false : null }); - } - for (const [name, server] of files) rows.set(name, { name: sidebarText(name), source: server.source, enabled: server.config.enabled !== false }); - for (const [name, row] of invalid) if (!rows.has(name)) rows.set(name, row); - // File errors take precedence; a missing adapter may still warn alongside them. - const mcpError = project?.error ?? global.error ?? (rejected ? "MCP configuration has rejected entries" : undefined); - const mcpWarning = !host && rows.size > 0 ? "MCP configuration validation unavailable" : undefined; - return { mcp: [...rows.values()].sort((a, b) => a.name.localeCompare(b.name)), - ...(mcpError ? { mcpError } : {}), ...(mcpWarning ? { mcpWarning } : {}) }; -} - -/** File reads are bounded and cached outside render; never evaluate env or credential commands. */ -export class SidebarMcpFiles { - private cached = new Map(); - private host?: SidebarMcpHost; - setHost(host: SidebarMcpHost | undefined): void { - if (Boolean(this.host?.stripBom) !== Boolean(host?.stripBom)) this.clear(); - this.host = host; - } - read(path: string): McpFile { - try { - const stat = statSync(path); - if (!stat.isFile() || stat.size > 1024 * 1024) return { servers: {}, error: "MCP config unreadable" }; - const key = `${stat.mtimeMs}:${stat.ctimeMs}:${stat.size}`; - const old = this.cached.get(path); - if (old?.key === key) return old.value; - const value = parseMcpFile(readFileSync(path, "utf8"), this.host?.stripBom); - this.cached.set(path, { key, value }); - return value; - } catch (error) { - this.cached.delete(path); - return (error as NodeJS.ErrnoException).code === "ENOENT" ? { servers: {} } : { servers: {}, error: "MCP config unreadable" }; - } - } - snapshot(agentDir: string, cwd: string, trusted: boolean, registered: readonly { name: string; config: unknown }[]): SidebarMcpResult { - const global = this.read(join(agentDir, "mcp.json")); - const project = trusted ? this.read(join(cwd, ".pi", "mcp.json")) : undefined; - return resolveMcp(registered, global, project, this.host); - } - clear(): void { this.cached.clear(); } -} diff --git a/pi-slate/extensions/pi-slate/sidebar-mcp.ts b/pi-slate/extensions/pi-slate/sidebar-mcp.ts index dbb70cfb..bbfe8c15 100644 --- a/pi-slate/extensions/pi-slate/sidebar-mcp.ts +++ b/pi-slate/extensions/pi-slate/sidebar-mcp.ts @@ -1,35 +1,86 @@ +import { readFileSync, statSync } from "node:fs"; import { join } from "node:path"; -import { pathToFileURL } from "node:url"; +import { record, sidebarText } from "./sidebar-text.ts"; -/** Isolated read-only host adapter. Never imports the MCP client or resolves credentials. */ -export type SidebarMcpHost = { - validate(name: string, value: unknown): unknown; - namespace(name: string): string; - projectOverrides: boolean; - stripBom: boolean; -}; +export type SidebarMcp = { name: string; enabled: boolean | null; source: string }; +export type SidebarMcpResult = { mcp: SidebarMcp[]; mcpError?: string }; +type McpFile = { servers: Record; error?: string }; -/** Version only selects legacy parsing exceptions; capabilities determine availability. */ -function hostParsing(version: string): { projectOverrides: boolean; stripBom: boolean } { - if (version === "0.99.0" || version === "1.0.0") return { projectOverrides: false, stripBom: false }; - const release = /^1\.(\d+)\.(\d+)$/.exec(version); - const minor = release ? Number(release[1]) : Number.NaN; - const patch = release ? Number(release[2]) : Number.NaN; - // 1.0.4+ and the released 1.1–1.3 minors merge project overrides and reject a BOM. 1.4+ strips it. - if ((minor === 0 && patch >= 4) || (minor >= 1 && minor < 4)) return { projectOverrides: true, stripBom: false }; - return { projectOverrides: true, stripBom: true }; +/** Read declarations, not Pi's server schema. Never import a client/validator or evaluate credentials. */ +export function parseMcpFile(text: string | undefined): McpFile { + if (text === undefined) return { servers: {} }; + try { + const raw = record(JSON.parse(text.startsWith("\uFEFF") ? text.slice(1) : text)); + const servers = record(raw?.mcpServers); + if (!raw || (raw.mcpServers !== undefined && !servers)) return { servers: {}, error: "MCP config invalid" }; + return { servers: servers ?? {} }; + } catch { return { servers: {}, error: "MCP config unreadable" }; } } -export async function loadSidebarMcpHost(packageDir: string, version: string): Promise { - try { - const { projectOverrides, stripBom } = hostParsing(version); - const native = await import(pathToFileURL(join(packageDir, "dist/core/mcp-servers.js")).href) as { - validateMcpServerConfig?: SidebarMcpHost["validate"]; - mcpNamespace?: SidebarMcpHost["namespace"]; - }; - if (typeof native.validateMcpServerConfig !== "function") return undefined; - if (projectOverrides && typeof native.mcpNamespace !== "function") return undefined; - return { validate: native.validateMcpServerConfig, - namespace: projectOverrides ? native.mcpNamespace! : name => name, projectOverrides, stripBom }; - } catch { return undefined; } +function configuredEnabled(value: unknown): boolean | null { + const config = record(value); + if (!config || (config.enabled !== undefined && typeof config.enabled !== "boolean")) return null; + return config.enabled !== false; +} +const namespace = (name: string): string => name.replace(/-/g, "_"); + +/** Desired configuration only. Pi owns schema validation, rejection, authentication and live health. */ +export function sidebarMcp( + registered: readonly { name: string; config: unknown }[], global: McpFile, project?: McpFile, +): SidebarMcp[] { + const files = new Map(); + for (const [source, entries] of [["global", global.servers], ["project", project?.servers ?? {}]] as const) { + for (const [name, raw] of Object.entries(entries)) { + const entry = record(raw); + const base = record(files.get(name)?.config); + // A flag-only project declaration inherits the exact-name global declaration. + // Do not validate other fields or decide which entries Pi will accept. + const config = source === "project" && base && entry + && entry.command === undefined && entry.url === undefined && entry.type === undefined + ? { ...base, ...entry } : raw; + files.set(name, { config, source }); + } + } + const rows = new Map(); + const fileNamespaces = new Set([...files.keys()].map(namespace)); + for (const server of registered) { + if (!fileNamespaces.has(namespace(server.name))) { + rows.set(server.name, { name: sidebarText(server.name), source: "extension", enabled: configuredEnabled(server.config) }); + } + } + for (const [name, server] of files) rows.set(name, { name: sidebarText(name), source: server.source, enabled: configuredEnabled(server.config) }); + return [...rows.values()].sort((a, b) => a.name.localeCompare(b.name)); +} + +export function mcpDetails(server: SidebarMcp): string { + return `${server.enabled === null ? "—" : server.enabled ? "enabled" : "disabled"} (configuration, not connection status)\nSource: ${server.source}\n` + + (server.enabled === null ? "Enabled state could not be read from this declaration.\n" : "") + + "Slate does not validate MCP server configurations. Use /mcp for validation and live status."; +} + +/** Bounded, cached file reads outside render. No host versions, private imports, connections or commands. */ +export class SidebarMcpFiles { + private readonly cached = new Map(); + read(path: string): McpFile { + try { + const stat = statSync(path); + if (!stat.isFile() || stat.size > 1024 * 1024) return { servers: {}, error: "MCP config unreadable" }; + const key = `${stat.mtimeMs}:${stat.ctimeMs}:${stat.size}`; + const old = this.cached.get(path); + if (old?.key === key) return old.value; + const value = parseMcpFile(readFileSync(path, "utf8")); + this.cached.set(path, { key, value }); + return value; + } catch (error) { + this.cached.delete(path); + return (error as NodeJS.ErrnoException).code === "ENOENT" ? { servers: {} } : { servers: {}, error: "MCP config unreadable" }; + } + } + snapshot(agentDir: string, cwd: string, trusted: boolean, registered: readonly { name: string; config: unknown }[]): SidebarMcpResult { + const global = this.read(join(agentDir, "mcp.json")); + const project = trusted ? this.read(join(cwd, ".pi", "mcp.json")) : undefined; + const mcpError = project?.error ?? global.error; + return { mcp: sidebarMcp(registered, global, project), ...(mcpError ? { mcpError } : {}) }; + } + clear(): void { this.cached.clear(); } } diff --git a/pi-slate/extensions/pi-slate/sidebar-split.ts b/pi-slate/extensions/pi-slate/sidebar-split.ts index 281f6ebe..a9bfcce6 100644 --- a/pi-slate/extensions/pi-slate/sidebar-split.ts +++ b/pi-slate/extensions/pi-slate/sidebar-split.ts @@ -12,47 +12,41 @@ import { } from "./layout.ts"; const SIDEBAR_SPLIT = Symbol.for("pi-slate.sidebar-split"); -const ORIGINAL_SET_LAYOUT_ROOT = Symbol.for("pi-slate.setLayoutRoot"); const SPLIT_OWNER = Symbol.for("pi-slate.sidebar-split-owner"); type SplitHost = { layoutRoot?: T; setLayoutRoot(component: T | undefined): void; - [ORIGINAL_SET_LAYOUT_ROOT]?: (component: T | undefined) => void; [SPLIT_OWNER]?: object; }; +type LayoutLease = (() => void) & { active(): boolean }; + +/** Root-identity lease, never a setter monkeypatch. Host facades need not preserve method identity. */ export function bindSplitHost( host: SplitHost, wrap: (component: T | undefined) => T | undefined, unwrap: (component: T | undefined) => T | undefined, onYield?: () => void, -): () => void { - const originalSet = host[ORIGINAL_SET_LAYOUT_ROOT] ?? host.setLayoutRoot.bind(host); +): LayoutLease { const owner = {}; - host[ORIGINAL_SET_LAYOUT_ROOT] = originalSet; - host[SPLIT_OWNER] = owner; const mounted = wrap(host.layoutRoot); - const yieldRoot = (component: T | undefined): void => { - // A later writer wins. Stop intercepting, rather than wrapping its root forever. - if (host[SPLIT_OWNER] === owner) { - if (host.setLayoutRoot === yieldRoot) host.setLayoutRoot = originalSet; - delete host[ORIGINAL_SET_LAYOUT_ROOT]; - delete host[SPLIT_OWNER]; - onYield?.(); - } - originalSet(component); + let disposed = false, yielded = false; + host[SPLIT_OWNER] = owner; + if (host.layoutRoot !== undefined) host.setLayoutRoot(mounted); + const active = (): boolean => { + if (disposed) return false; + const own = host[SPLIT_OWNER] === owner && host.layoutRoot === mounted; + if (!own && !yielded) { yielded = true; onYield?.(); } + return own; }; - host.setLayoutRoot = yieldRoot; - if (host.layoutRoot !== undefined) originalSet(mounted); - - return () => { - if (host[SPLIT_OWNER] !== owner) return; - if (host.setLayoutRoot === yieldRoot) host.setLayoutRoot = originalSet; - delete host[ORIGINAL_SET_LAYOUT_ROOT]; - delete host[SPLIT_OWNER]; - if (host.layoutRoot === mounted) originalSet(unwrap(mounted)); + const dispose = (): void => { + const own = active(); + disposed = true; + if (host[SPLIT_OWNER] === owner) delete host[SPLIT_OWNER]; + if (own) host.setLayoutRoot(unwrap(mounted)); }; + return Object.assign(dispose, { active }); } type SplitMousePane = Component & { @@ -130,7 +124,7 @@ export function splitChat(component: Component | undefined): Component | undefin return component.chat(); } -export type SidebarSplitLease = (() => void) & { replaceChat(view: Component): (() => void) | undefined; ownsFocus(component: Component): boolean }; +export type SidebarSplitLease = LayoutLease & { replaceChat(view: Component): (() => void) | undefined; ownsFocus(component: Component): boolean }; export function installSidebarSplit( tui: TUI, @@ -152,10 +146,10 @@ export function installSidebarSplit( onYield, ); return Object.assign(dispose, { - ownsFocus(component: Component) { return component === split && (tui as TUI & { layoutRoot?: Component }).layoutRoot === split; }, + ownsFocus(component: Component) { return dispose.active() && component === split; }, replaceChat(view: Component) { // A later layout owner wins; never resurrect a stale slot. - if (!split || (tui as TUI & { layoutRoot?: Component }).layoutRoot !== split) return undefined; + if (!split || !dispose.active()) return undefined; return split.replaceChat(view); }, }); diff --git a/pi-slate/extensions/pi-slate/sidebar-text.ts b/pi-slate/extensions/pi-slate/sidebar-text.ts new file mode 100644 index 00000000..2592c80d --- /dev/null +++ b/pi-slate/extensions/pi-slate/sidebar-text.ts @@ -0,0 +1,9 @@ +/** Display-only strings must never inject terminal commands or extra rows. */ +export function sidebarText(value: unknown, max = 512): string { + if (typeof value !== "string") return ""; + return value.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "") + .replace(/[\x00-\x1f\x7f-\x9f]/g, " ").replace(/\s+/g, " ").trim().slice(0, max); +} +export function record(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : undefined; +} diff --git a/pi-slate/extensions/pi-slate/sidebar.ts b/pi-slate/extensions/pi-slate/sidebar.ts index 6b50d1fa..74d69fdb 100644 --- a/pi-slate/extensions/pi-slate/sidebar.ts +++ b/pi-slate/extensions/pi-slate/sidebar.ts @@ -2,6 +2,7 @@ import type { Theme } from "@earendil-works/pi-coding-agent"; import { truncateToWidth, visibleWidth, type Component, type OverlayHandle, type OverlayOptions, type TUI, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui"; import { clampSidebarColumns, dashboardColumnWidth, formatCompactTokenCount, isSidebarResizeHandle, parseSidebarPercent, sidebarHandleColumn, sidebarPercentFromColumns, SIDEBAR_HIDDEN } from "./layout.ts"; import { installSidebarSplit, type SidebarSplitLease } from "./sidebar-split.ts"; +import { mcpDetails } from "./sidebar-mcp.ts"; import type { WorkspaceView } from "./workspace.ts"; import { chromePaint } from "./composer.ts"; import { DEFAULT_SIDEBAR_FOLDS, sidebarText, type SidebarFolds, type SidebarResources, type SidebarSession } from "./sidebar-data.ts"; @@ -41,7 +42,7 @@ export class Sidebar implements Component { private resizeStartWidth = 0; private _preferredWidth?: number; hidden = false; - splitActive = false; + get splitActive(): boolean { return this.splitDispose?.active() === true; } private session: SidebarSession = EMPTY_SESSION; private resources: SidebarResources = EMPTY_RESOURCES; private tasks: BackgroundTask[] = []; @@ -74,13 +75,12 @@ export class Sidebar implements Component { setThemeProvider(provider: () => Theme | undefined): void { this.themeProvider = provider; this.invalidate(); } attach(tui: TUI, theme: Theme, onYield?: () => void): void { this.tui = tui; this.theme = theme; - if (this.splitDispose) return; + if (this.splitActive) return; this.splitDispose = installSidebarSplit(tui, this, () => this.hidden ? SIDEBAR_HIDDEN : this._preferredWidth, () => { - this.splitActive = false; this.splitDispose = undefined; + this.splitDispose = undefined; this.hideResizeGuide(); this.resizing = false; this.hits = []; this.listRows = []; onYield?.(); }); - this.splitActive = Boolean(this.splitDispose); this.invalidate(); } isVisible(columns: number, rows: number): boolean { return this.splitActive && !this.hidden && dashboardColumnWidth(columns, rows, this._preferredWidth) > 0; } @@ -276,18 +276,14 @@ export class Sidebar implements Component { private middle(): Row[] { const enabled = this.resources.mcp.filter(x => x.enabled === true).length; const unknownCount = this.resources.mcp.filter(x => x.enabled === null).length; - const errors = this.resources.mcpError - ? (this.resources.mcpError === "MCP configuration has rejected entries" && unknownCount > 0 ? unknownCount : 1) - : 0; + const errors = this.resources.mcpError ? 1 : 0; const allUnknown = unknownCount > 0 && unknownCount === this.resources.mcp.length; - const warning = !errors && Boolean(this.resources.mcpWarning); const loaded = this.resources.skills.filter(x => x.loaded).length; - const resourceHeading = (label: string, count: string, failed = 0, warned = false): string => + const resourceHeading = (label: string, count: string, failed = 0): string => this.heading(label) + this.paint("muted", ` · ${count}`) - + (failed ? this.paint("error", ` · ${failed} ${failed === 1 ? "Error" : "Errors"}`) : "") - + (warned ? this.paint("warning", " · 1 Warning") : ""); + + (failed ? this.paint("error", ` · ${failed} ${failed === 1 ? "Error" : "Errors"}`) : ""); const rows: Row[] = [{ text: "" }, { - text: resourceHeading(`${this.folds.mcp ? "⌄" : "›"} MCP`, allUnknown || (!errors && unknownCount > 0) ? "—" : `${enabled} Enabled`, errors, warning), + text: resourceHeading(`${this.folds.mcp ? "⌄" : "›"} MCP`, allUnknown || (!errors && unknownCount > 0) ? "—" : `${enabled} Enabled`, errors), action: () => this.toggleSection("mcp"), }]; if (this.folds.mcp) rows.push(...this.resourceRows("mcp")); @@ -307,10 +303,8 @@ export class Sidebar implements Component { for (let i = start; i < Math.min(items.length, start + visible); i++) { if (list === "mcp") { const server = this.resources.mcp[i]!; - const state = server.enabled === null ? "—" : server.enabled ? "enabled" : "disabled"; - const validationWarning = !this.resources.mcpError && server.enabled === null ? this.resources.mcpWarning : undefined; - rows.push({ text: this.paint(server.enabled === null ? "warning" : server.enabled ? "muted" : "dim", `${server.enabled === null ? "—" : server.enabled ? "●" : "○"} ${sidebarText(server.name)}`), list, item: i, - action: () => this.actions?.inspect?.(server.name, `${state} (configuration, not connection status)\nSource: ${server.source}${validationWarning ? `\n${validationWarning}` : ""}\nUse /mcp to manage this server.`) }); + rows.push({ text: this.paint(server.enabled === true ? "muted" : "dim", `${server.enabled === null ? "—" : server.enabled ? "●" : "○"} ${sidebarText(server.name)}`), list, item: i, + action: () => this.actions?.inspect?.(server.name, mcpDetails(server)) }); } else if (list === "skills") { const skill = this.resources.skills[i]!; rows.push({ text: this.paint(skill.loaded ? "muted" : "dim", `${skill.loaded ? "●" : "○"} ${sidebarText(skill.name)}`), list, item: i, @@ -391,7 +385,7 @@ export class Sidebar implements Component { } dispose(): void { this.hideResizeGuide(); this.resizing = false; - this.splitDispose?.(); this.splitDispose = undefined; this.splitActive = false; + this.splitDispose?.(); this.splitDispose = undefined; this.reset(); this.tui = undefined; this.theme = undefined; this.themeProvider = undefined; this.actions = undefined; } diff --git a/pi-slate/extensions/pi-slate/stock-notices.ts b/pi-slate/extensions/pi-slate/stock-notices.ts deleted file mode 100644 index 9f52e485..00000000 --- a/pi-slate/extensions/pi-slate/stock-notices.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { stripVTControlCharacters } from "node:util"; -import type { Component } from "@earendil-works/pi-tui"; - -const SAMPLE_WIDTH = 80; - -function renderedPlain(component: Component, width = SAMPLE_WIDTH): string { - try { - return component.render(width).map((line) => stripVTControlCharacters(line)).join("\n"); - } catch { - return ""; - } -} - -function isBlankSpacer(component: Component): boolean { - const plain = renderedPlain(component, 10); - return plain.trim() === ""; -} - -const STOCK_NEW_SESSION_NOTICE = "✓ New session started"; - -export function isStockNewSessionNotice(component: Component): boolean { - let lines: string[]; - try { lines = component.render(SAMPLE_WIDTH).map((line) => stripVTControlCharacters(line)); } - catch { return false; } - const body = lines.map((line) => line.trim()).filter(Boolean); - // Core ThemedText uses padY=1, so the stock banner is more than one painted line. - return body.length === 1 && body[0] === STOCK_NEW_SESSION_NOTICE && lines.length > 1; -} - -export type ChatContainer = { - children: Component[]; - removeChild(component: Component): void; - addChild(component: Component): void; -}; - -const FILTERED = Symbol("pi-slate.new-session-notice-filter"); - -type FilteredAdd = ChatContainer["addChild"] & { [FILTERED]?: true; dispose?: () => void }; - -/** - * Keeps the stock chat copy of "✓ New session started" out of the transcript. - * Also drops the blank spacer core adds right - * before the notice. The wrap binds the addChild present at install time, so it - * composes with MessageWindow in either order. If a later wrap replaces addChild, - * calling this again re-wraps the current function. dispose() stops filtering - * even if addChild was wrapped again later. - */ -export function installNewSessionNoticeFilter(container: ChatContainer): () => void { - const current = container.addChild as FilteredAdd; - if (current[FILTERED]) return current.dispose ?? (() => {}); - let disposed = false; - const addChild = container.addChild.bind(container); - const wrapped: FilteredAdd = (component: Component): void => { - if (disposed || !isStockNewSessionNotice(component)) { - addChild(component); - return; - } - const previous = container.children.at(-1); - if (previous && isBlankSpacer(previous)) container.removeChild(previous); - }; - const dispose = (): void => { - disposed = true; - if (container.addChild === wrapped) container.addChild = addChild; - }; - wrapped[FILTERED] = true; - wrapped.dispose = dispose; - container.addChild = wrapped; - return dispose; -} diff --git a/pi-slate/extensions/pi-slate/tool-groups.ts b/pi-slate/extensions/pi-slate/tool-groups.ts index 0f2d88f5..a2d629bc 100644 --- a/pi-slate/extensions/pi-slate/tool-groups.ts +++ b/pi-slate/extensions/pi-slate/tool-groups.ts @@ -1,6 +1,5 @@ import { keyHint, type Theme } from "@earendil-works/pi-coding-agent"; import { truncateToWidth, type Component, type Container, type TuiMouseEvent } from "@earendil-works/pi-tui"; - import { TOOL_STYLE_NAMES } from "./tool-style.ts"; const names = new Set(TOOL_STYLE_NAMES); @@ -32,96 +31,101 @@ function thoughts(component: Component): number | undefined { } type RowOwner = { component: Component; y: number; height: number; inset: number }; -/** Instance-scoped adapter: children stay intact for host expansion, history and windowing. - * No prototype mutations, timers, transcript replay, or retained historical output. */ +type Segment = + | { kind: "native"; component: Component } + | { kind: "group"; tools: Tool[]; thoughtCount: number; failed: number; running: boolean }; +/** Paints an explicit display list and maps its mouse rows. Never patches a host component. */ export class ToolGroups { - private readonly originalRender: Container["render"]; - private readonly originalMouse: Container["handleMouse"]; - private readonly renderWrapper: Container["render"]; - private readonly mouseWrapper: Container["handleMouse"]; private owners: Array = []; - readonly container: Container; - constructor(container: Container, theme: () => Theme | undefined, hint = () => keyHint("app.tools.expand", "to expand")) { - this.container = container; - this.originalRender = container.render; - this.originalMouse = container.handleMouse; - this.renderWrapper = (width) => { - const lines: string[] = [], owners: Array = []; - const append = (component: Component, rows: string[]): void => { - for (let y = 0; y < rows.length; y++) { lines.push(rows[y]!); owners.push({ component, y, height: rows.length, inset: 0 }); } - }; - const children = container.children; - for (let i = 0; i < children.length;) { - let end = i, thoughtCount = 0, failed = 0, running = false, expanded = false; - const tools: Tool[] = []; - while (end < children.length) { - const child = children[end]!; - if (tool(child)) { - tools.push(child); expanded ||= child.expanded; - running ||= !child.result || child.isPartial; - if (child.result?.isError) failed++; - } else { - const count = thoughts(child); - if (count === undefined) break; - thoughtCount += count; - } - end++; - } - if (tools.length < 2 || expanded || width < 12) { - const stop = Math.max(i + 1, end); - for (; i < stop; i++) append(children[i]!, children[i]!.render(width)); - continue; - } - const paint = theme(); - const parts = [`${tools.length} tool calls`]; - if (thoughtCount) parts.push(`${thoughtCount} thought${thoughtCount === 1 ? "" : "s"}`); - if (failed) parts.push(`${failed} failed`); - const mark = running ? "◌" : failed ? "✗" : "✓"; - const summary = paint - ? `${paint.fg(failed ? "error" : running ? "accent" : "success", mark)} ${paint.bold(running ? "Running" : "Done")} ${paint.fg("muted", `(${parts.join(" · ")})`)} ${hint()}` - : `${mark} ${running ? "Running" : "Done"} (${parts.join(" · ")}) ${hint()}`; - lines.push("", truncateToWidth(summary, width)); - owners.push(undefined, undefined); - for (let index = 0; index < tools.length; index++) { - const member = tools[index]!; - // Preserve local row coordinates for the host's existing click-to-expand region. - const rows = member.render(Math.max(1, width - 3)); - const last = index === tools.length - 1; - let first = true; - for (let y = 0; y < rows.length; y++) { - const row = rows[y]!; - if (!row.trim()) continue; - const guide = first ? last ? "└─ " : "├─ " : last ? " " : "│ "; - first = false; - lines.push(truncateToWidth((paint ? paint.fg("dim", guide) : guide) + row, width)); - owners.push({ component: member, y, height: rows.length, inset: 3 }); - } + private readonly theme: () => Theme | undefined; + private readonly hint: () => string; + constructor(theme: () => Theme | undefined, hint = () => keyHint("app.tools.expand", "to expand")) { + this.theme = theme; this.hint = hint; + } + + /** Read all grouping metadata before painting so a fault cannot duplicate earlier renders. */ + private plan(children: readonly Component[], width: number): Segment[] { + const segments: Segment[] = []; + for (let i = 0; i < children.length;) { + let end = i, thoughtCount = 0, failed = 0, running = false, expanded = false; + const tools: Tool[] = []; + while (end < children.length) { + const child = children[end]!; + if (tool(child)) { + tools.push(child); expanded ||= child.expanded; + running ||= !child.result || child.isPartial; + if (child.result?.isError) failed++; + } else { + const count = thoughts(child); + if (count === undefined) break; + thoughtCount += count; } - i = end; + end++; } - this.owners = owners; - return lines; - }; - this.mouseWrapper = (event: TuiMouseEvent) => { - const owner = this.owners[event.y]; - if (!owner) return undefined; - const local = { ...event, x: event.x - owner.inset, y: owner.y, width: Math.max(1, event.width - owner.inset), height: owner.height }; - if (local.x < 0) return undefined; - const result = owner.component.handleMouse?.(local); - if (!result || !(result.handled || result.capture || result.focus)) return undefined; - // Nested host containers already carry exact capture/focus coordinates. - if ("target" in result) return result as NonNullable>; - return { ...result, handled: true, target: { - component: owner.component, originX: event.screenX - local.x, - originY: event.screenY - local.y, width: local.width, height: local.height, - }, ...(result.focus ? { focusTarget: owner.component } : {}) }; + if (tools.length < 2 || expanded || width < 12) { + const stop = Math.max(i + 1, end); + for (; i < stop; i++) segments.push({ kind: "native", component: children[i]! }); + continue; + } + segments.push({ kind: "group", tools, thoughtCount, failed, running }); + i = end; + } + return segments; + } + + render(children: readonly Component[], width: number): string[] { + const lines: string[] = [], owners: Array = []; + const append = (component: Component, rows: string[]): void => { + for (let y = 0; y < rows.length; y++) { lines.push(rows[y]!); owners.push({ component, y, height: rows.length, inset: 0 }); } }; - container.render = this.renderWrapper; - container.handleMouse = this.mouseWrapper; + for (const segment of this.plan(children, width)) { + if (segment.kind === "native") { + append(segment.component, segment.component.render(width)); + continue; + } + const { tools, thoughtCount, failed, running } = segment; + const paint = this.theme(); + const parts = [`${tools.length} tool calls`]; + if (thoughtCount) parts.push(`${thoughtCount} thought${thoughtCount === 1 ? "" : "s"}`); + if (failed) parts.push(`${failed} failed`); + const mark = running ? "◌" : failed ? "✗" : "✓"; + const summary = paint + ? `${paint.fg(failed ? "error" : running ? "accent" : "success", mark)} ${paint.bold(running ? "Running" : "Done")} ${paint.fg("muted", `(${parts.join(" · ")})`)} ${this.hint()}` + : `${mark} ${running ? "Running" : "Done"} (${parts.join(" · ")}) ${this.hint()}`; + lines.push("", truncateToWidth(summary, width)); + owners.push(undefined, undefined); + for (let index = 0; index < tools.length; index++) { + const member = tools[index]!; + // Preserve local row coordinates for the host's existing click-to-expand region. + const rows = member.render(Math.max(1, width - 3)); + const last = index === tools.length - 1; + let first = true; + for (let y = 0; y < rows.length; y++) { + const row = rows[y]!; + if (!row.trim()) continue; + const guide = first ? last ? "└─ " : "├─ " : last ? " " : "│ "; + first = false; + lines.push(truncateToWidth((paint ? paint.fg("dim", guide) : guide) + row, width)); + owners.push({ component: member, y, height: rows.length, inset: 3 }); + } + } + } + this.owners = owners; + return lines; } - dispose(): void { - if (this.container.render === this.renderWrapper) this.container.render = this.originalRender; - if (this.container.handleMouse === this.mouseWrapper) this.container.handleMouse = this.originalMouse; - this.owners = []; + + handleMouse(event: TuiMouseEvent): ReturnType { + const owner = this.owners[event.y]; + if (!owner) return undefined; + const local = { ...event, x: event.x - owner.inset, y: owner.y, width: Math.max(1, event.width - owner.inset), height: owner.height }; + if (local.x < 0) return undefined; + const result = owner.component.handleMouse?.(local); + if (!result || !(result.handled || result.capture || result.focus)) return undefined; + // Nested host containers already carry exact capture/focus coordinates. + if ("target" in result) return result as NonNullable>; + return { ...result, handled: true, target: { + component: owner.component, originX: event.screenX - local.x, + originY: event.screenY - local.y, width: local.width, height: local.height, + }, ...(result.focus ? { focusTarget: owner.component } : {}) }; } } diff --git a/pi-slate/llms.txt b/pi-slate/llms.txt index 88b3f399..82c1a11c 100644 --- a/pi-slate/llms.txt +++ b/pi-slate/llms.txt @@ -10,13 +10,13 @@ Select independent surfaces from the closed set: header, footer, editor, sidebar Theme and fullscreen remain user preferences, never automatic load actions. `/theme` (or `/slate theme`) opens the Light/Dark picker: Catppuccin Latte and Frappe under Light, Macchiato and Mocha under Dark, each with 15 palettes (rosewater, flamingo, pink, mauve, red, maroon, peach, yellow, green, teal, sky, sapphire, blue, lavender, default). The picker's System default row restores Pi's system theme; Tokyo Night and Black Metal sit under Dark. Installation preserves an existing selection. `/slate theme` takes no arguments and only opens that picker; `/slate style` no longer exists. Saved names like `catppuccin-latte-mauve` and the legacy Mocha Quiet palette stay valid and are still settable through Pi's `/settings`. `composerMetadata` replaces the in-memory footer setting; old `footer` files and `/slate footer` remain compatible (both keys are written). The footer surface hides Pi's footer using a zero-row component; composer git branch comes from an editor-only service. -Image handling patches only Slate's editor instance, never Editor.prototype or foreign editors. `/prompts` registers only with editor and has no default shortcut; `shortcuts.prompts` in pi-slate.json binds a chord (`false`/`"none"` for none) and needs `/reload`. `/exit` always. Tool-cards preserves native execution outside TUI and native model-facing results; grouped reads and highlighted diffs are rendering only. No stock-notice deletion. +MCP is a version-independent, read-only projection of requested configuration from the public registration API and trusted config files. Slate never imports Pi MCP internals, validates server schemas, connects, or evaluates credentials; /mcp owns validity and health. Unknown flags are neutral, file parse/read errors remain visible, and BOM normalization never depends on host exports. Image handling patches only Slate's editor instance, never Editor.prototype or foreign editors. `/prompts` registers only with editor and has no default shortcut; `shortcuts.prompts` in pi-slate.json binds a chord (`false`/`"none"` for none) and needs `/reload`. `/exit` always. Tool-cards preserves native execution outside TUI and native model-facing results; grouped reads and highlighted diffs are rendering only. No stock-notice deletion. -Chrome is last-writer-wins; tools are first-registration-wins, with losses reported and no repair engine. Sidebar/transcript use a namespaced zero-row below-editor TUI handle, need a compatible fullscreen host, and warn rather than forcing fullscreen or overlaying. The sidebar yields to a replaced root. Shutdown disposes local resources without clearing successor slots; `/reload` resets chrome because header/footer ownership getters are unavailable. +Chrome is last-writer-wins; tools are first-registration-wins, with losses recorded in /slate diagnostics and no repair engine. Missing optional UI capabilities and adapter faults use quiet native fallbacks, never server-health/startup warnings. Real config/file failures and failed user actions remain visible. Sidebar/transcript use a namespaced zero-row below-editor TUI handle and need a compatible fullscreen host without forcing fullscreen or overlaying. The sidebar uses a root-identity lease, never intercepts setLayoutRoot, and yields to a replaced root. One chat-surface owner handles render/mouse routing: it copies the visible message list, then uses a grouping painter or native rows. Canonical host children stay intact, active work is not windowed, and grouping faults retain the window in the same frame. Unknown render/mouse owners are preserved; disposed wrappers delegate. Shutdown releases only owned slots; /reload retries faulted capabilities. Observed TUI thinking adds elapsed seconds and available token rate to the composer-edge working word (`Pondering · 7s · ↑↓42`). Native thinking visibility, streaming labels, and toggles remain unchanged; per-message settled durations await a host API. -Claude Code-style tool cards are the default with tool-cards selected; `toolStyle: "default"` in pi-slate.json plus /reload restores Slate's original cards. Uses Pi registerToolRenderer (tested 1.3.0), compact status/args/result/duration/timeout/diff rows and scoped consecutive-tool trees on compatible fullscreen hosts. No tool re-registration for styling, global prototype patches, session replay or extra timers. Legacy read grouper is disabled in this mode. Expanded original renderers/state, native thinking visibility and schemas remain intact; images, assistant text, unsupported tools and visible thoughts break groups. Older hosts keep default cards with a warning. +Claude Code-style tool cards are the default with tool-cards selected; `toolStyle: "default"` in pi-slate.json plus /reload restores Slate's original cards. Uses Pi registerToolRenderer (tested 1.3.0), compact status/args/result/duration/timeout/diff rows and scoped consecutive-tool trees on compatible fullscreen hosts. No tool re-registration for styling, global prototype patches, session replay or extra timers. Legacy read grouper is disabled in this mode. Expanded original renderers/state, native thinking visibility and schemas remain intact; images, assistant text, unsupported tools and visible thoughts break groups. Missing renderer hooks keep Slate default cards with an on-demand diagnostic, not a startup warning. Highlighted diffs use responsive split edits, unified writes, and character-level emphasis. They follow the active Slate theme. Native model-facing results are preserved; bounded write patches are stored in TUI result details for session restore. @@ -30,7 +30,8 @@ Header uses the stock Pi mark plus version, model, and session dir as ~/. - [Diff](extensions/pi-slate/diff.ts): edit/write rendering - [Read groups](extensions/pi-slate/read-group.ts): consecutive-read compaction - [Tool style](extensions/pi-slate/tool-style.ts): presentation-only compact cards -- [Tool groups](extensions/pi-slate/tool-groups.ts): scoped chat-instance trees and native mouse routing +- [Chat surface](extensions/pi-slate/chat-surface.ts): one display-projection and mouse-routing owner +- [Tool groups](extensions/pi-slate/tool-groups.ts): grouping painter for an explicit display list - [Thinking status](extensions/pi-slate/thinking-fold.ts): live composer timing, native transcript fallback - [Tokyo Night theme](themes/tokyo-night.json): bundled explicit theme choice - [Black Metal theme](themes/black-metal.json): optional theme, not an install default; selectable from the `/theme` picker under Dark diff --git a/pi-slate/package-lock.json b/pi-slate/package-lock.json index 2d8dd4d0..cedc8e9a 100644 --- a/pi-slate/package-lock.json +++ b/pi-slate/package-lock.json @@ -2392,9 +2392,9 @@ } }, "node_modules/undici": { - "version": "8.12.2", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.2.tgz", - "integrity": "sha512-XtuDWVdqTLr3NAxm2ly9/hU6kUsJOgGh1RASzMBbsw6aWCOaQzHdW+QQDfdERjfJ2V5vL5pRPa4RetO9UfivDg==", + "version": "8.12.4", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.4.tgz", + "integrity": "sha512-8Mx+uNKKER2bA4AdtedkqRbwUCIJFr2nJckXRe5yrDqzePqwLP7VNcctN8/XdmDSbF7mS+jJKIszlL+vbam1VA==", "dev": true, "license": "MIT", "engines": { diff --git a/pi-slate/package.json b/pi-slate/package.json index 482d7df7..2e13608d 100644 --- a/pi-slate/package.json +++ b/pi-slate/package.json @@ -40,6 +40,8 @@ "lint": "node ../node_modules/eslint/bin/eslint.js . --max-warnings=0", "test:unit": "node ../scripts/test-package.mjs pi-slate unit", "test:integration": "node ../scripts/test-package.mjs pi-slate integration", + "test:compat": "node --experimental-strip-types --test tests/host-compat.test.mjs", + "test:compat:latest": "node scripts/latest-host.mjs", "build": "node ../scripts/package.mjs pi-slate", "audit:prod": "npm audit --omit=dev --audit-level=high", "pack:check": "npm pack --dry-run", @@ -59,7 +61,7 @@ } }, "overrides": { - "undici": "8.12.2" + "undici": "8.12.4" }, "devDependencies": { "@earendil-works/pi-coding-agent": "1.4.0", diff --git a/pi-slate/scripts/host-smoke.mjs b/pi-slate/scripts/host-smoke.mjs new file mode 100644 index 00000000..2f0be14d --- /dev/null +++ b/pi-slate/scripts/host-smoke.mjs @@ -0,0 +1,112 @@ +// Real-host integration probe. Invoked in a fresh, isolated subprocess by host-compat.test.mjs. +import assert from "node:assert/strict"; +import { realpath, writeFile } from "node:fs/promises"; +import { createRequire, syncBuiltinESMExports } from "node:module"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { stripVTControlCharacters } from "node:util"; +import http from "node:http"; +import https from "node:https"; +import net from "node:net"; +import tls from "node:tls"; + +const root = process.env.PI_CODING_AGENT_DIR; +assert(root && process.env.PI_OFFLINE === "1", "host smoke requires an isolated, offline agent directory"); +let networkRequests = 0; +const denyNetwork = () => { networkRequests++; throw new Error("Network is forbidden during host execution"); }; +globalThis.fetch = async () => denyNetwork(); +http.request = http.get = https.request = https.get = denyNetwork; +net.connect = net.createConnection = net.Socket.prototype.connect = tls.connect = denyNetwork; +syncBuiltinESMExports(); +const hostEntry = process.env.SLATE_COMPAT_HOST_ENTRY; +assert(hostEntry, "Expected an explicit host entrypoint"); +const host = await import(pathToFileURL(hostEntry).href); +const tui = await import(pathToFileURL(createRequire(hostEntry).resolve("@earendil-works/pi-tui")).href); +assert.equal(host.VERSION, process.env.SLATE_COMPAT_VERSION, "the selected host must actually be the one executing Slate"); +assert.equal(await realpath(host.getPackageDir()), await realpath(process.env.SLATE_COMPAT_HOST_DIR)); +const surfaces = process.env.SLATE_COMPAT_SURFACES; +const selected = surfaces === "fresh" ? ["editor", "sidebar", "tool-cards"] : JSON.parse(surfaces); +const configPath = join(root, "pi-slate.json"); +const config = surfaces === "fresh" ? undefined : JSON.stringify({ version: 1, surfaces: selected, focused: false }); +if (config !== undefined) await writeFile(configPath, config); +await writeFile(join(root, "mcp.json"), '\uFEFF{"mcpServers":{"fixture":{"type":"future-transport","enabled":true}}}'); +const modeName = process.env.SLATE_COMPAT_MODE; +const fault = process.env.SLATE_COMPAT_FAULT; +const settingsManager = host.SettingsManager.inMemory({ quietStartup: true, tuiMode: modeName, theme: "dark" }); +const model = { id: "compatibility-only", name: "Compatibility only", provider: "compatibility", api: "openai-responses", baseUrl: "http://127.0.0.1:9", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 100000, maxTokens: 1000 }; +const chunks = [], errors = []; +let mode, runtime; +const terminal = { + columns: 140, rows: 40, kittyProtocolActive: false, + start() { + if (fault === "transcript") { + // A foreign render owner must cause a quiet fallback, but must not fool the positive canary. + const chat = Reflect.get(mode, "chatContainer"), render = chat.render; + chat.render = width => render.call(chat, width); + } + }, + stop() {}, async drainInput() {}, write(text) { chunks.push(text); }, + moveBy() {}, hideCursor() {}, showCursor() {}, clearLine() {}, clearFromCursor() {}, clearScreen() {}, + setTitle() {}, setProgress() {}, setProgramStatus() {}, +}; +try { + const createRuntime = async ({ cwd, sessionManager, sessionStartEvent }) => { + const services = await host.createAgentSessionServices({ cwd, agentDir: root, settingsManager, + resourceLoaderOptions: { + additionalExtensionPaths: [join(process.env.SLATE_COMPAT_PACKAGE, "extensions/pi-slate/index.ts")], noSkills: true, noPromptTemplates: true, + extensionFactories: fault === "quit-shutdown" ? [pi => pi.on("session_shutdown", event => { if (event.reason === "quit") throw new Error("Injected quit-only shutdown fault"); })] : [], + }, + }); + const loaded = services.resourceLoader.getExtensions(); + assert(loaded.extensions.some(extension => extension.path.endsWith("extensions/pi-slate/index.ts")), "the probe must actually load Slate, not boot an empty native host"); + assert.deepEqual(loaded.errors, []); assert.deepEqual(loaded.warnings ?? [], []); + const result = await host.createAgentSessionFromServices({ services, sessionManager, sessionStartEvent, model }); + result.session.extensionRunner.onError(error => errors.push(error)); + return { ...result, services, diagnostics: services.diagnostics }; + }; + runtime = await host.createAgentSessionRuntime(createRuntime, { cwd: root, agentDir: root, sessionManager: host.SessionManager.inMemory(root) }); + mode = new host.InteractiveMode(runtime, { terminal, tuiMode: modeName }); + await mode.init(); + const assertMounted = phase => { + // Test-only observation of the genuine host tree; production never imports interactive internals. + const ui = Reflect.get(mode, "ui"); + const render = () => (ui.layoutRoot ? ui.layoutRoot.render(140) : ui.render(140)).map(stripVTControlCharacters).join("\n"); + const frame = render(); + assert.deepEqual(errors, []); assert.deepEqual(runtime.diagnostics, []); + assert(runtime.session.extensionRunner.getRegisteredCommands().some(command => command.name === "slate")); + if (modeName === "fullscreen" && selected.includes("sidebar")) { + assert.equal(ui.layoutRoot.constructor.name, "SidebarSplit", `${phase}: sidebar lease must actually mount`); + if (surfaces !== "fresh") assert.match(frame, /MCP · 1 Enabled/); + } + const editor = Reflect.get(mode, "editor"); + if (selected.includes("editor")) { + assert.equal(editor.constructor.name, "ComposerEditor", `${phase}: editor factory must actually mount`); + editor.setText("draft preserved through rendering"); render(); + assert.equal(editor.getText(), "draft preserved through rendering"); + } else assert.notEqual(editor.constructor.name, "ComposerEditor"); + if (modeName === "fullscreen" && selected.includes("transcript")) { + const chat = Reflect.get(mode, "chatContainer"); + const markers = Array.from({ length: 105 }, (_, i) => new tui.Text(`${phase}-compatibility-message-${String(i).padStart(3, "0")}`, 0, 0)); + markers.forEach(marker => chat.addChild(marker)); + const painted = chat.render(140).map(stripVTControlCharacters).join("\n"); + assert(!painted.includes(`${phase}-compatibility-message-000`), `${phase}: transcript window did not mount`); + assert(painted.includes(`${phase}-compatibility-message-104`)); + assert(markers.every(marker => chat.children.includes(marker)), "windowing must preserve all canonical host children"); + } + assert.doesNotMatch(chunks.join("") + frame, /Slate (?:sidebar|transcript|Claude Code).*unavailable|MCP configuration validation unavailable|1 Warning|Extension error/i); + }; + assertMounted("startup"); + await runtime.newSession(); + assertMounted("replacement"); + mode.stop(); mode = undefined; + await runtime.dispose(); runtime = undefined; + assert.deepEqual(errors, [], "final shutdown handlers must also complete without extension errors"); + assert.equal(networkRequests, 0); + const { readFile } = await import("node:fs/promises"); + if (config === undefined) await assert.rejects(readFile(configPath), { code: "ENOENT" }); + else assert.equal(await readFile(configPath, "utf8"), config, "fallbacks and startup must not rewrite preferences"); + console.log(JSON.stringify({ host: host.VERSION, mode: modeName, surfaces, mounted: true, rendered: true, replaced: true, shutdown: true, networkRequests })); +} finally { + mode?.stop(); + await runtime?.dispose(); +} diff --git a/pi-slate/scripts/latest-host.mjs b/pi-slate/scripts/latest-host.mjs new file mode 100644 index 00000000..2024856f --- /dev/null +++ b/pi-slate/scripts/latest-host.mjs @@ -0,0 +1,26 @@ +// Upgrade canary: install the built artifact and a freshly resolved latest Pi in a temporary consumer. +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { isolatedEnvironment, npm, run } from "../../scripts/lib.mjs"; + +const packageDir = fileURLToPath(new URL("../", import.meta.url)); +const artifacts = fileURLToPath(new URL("../../.artifacts/pi-slate/", import.meta.url)); +const metadata = JSON.parse(readFileSync(join(artifacts, "manifest.json"), "utf8")); +assert.equal(metadata.name, "pi-slate"); +assert.equal(metadata.filename, `pi-slate-${metadata.version}.tgz`); +const temporary = mkdtempSync(join(tmpdir(), "slate-latest-host-")); +try { + const env = isolatedEnvironment(temporary); + console.log("Installing the packed Slate artifact and resolving the latest stable Pi host (setup network only)."); + npm(["install", "--prefix", temporary, "--ignore-scripts", "--no-audit", "--no-fund", "--save-exact", "--package-lock=false", + "@earendil-works/pi-coding-agent@latest", join(artifacts, metadata.filename)], { cwd: temporary, env, stdio: "inherit" }); + const host = join(temporary, "node_modules/@earendil-works/pi-coding-agent"); + const { version } = JSON.parse(readFileSync(join(host, "package.json"), "utf8")); + console.log(`Latest-host canary: Pi ${version}, packed Slate ${metadata.version}`); + run(process.execPath, ["--experimental-strip-types", "--test", "tests/host-compat.test.mjs"], { + cwd: packageDir, env: { ...env, SLATE_COMPAT_HOST: host, SLATE_COMPAT_PACKAGE: join(temporary, "node_modules/pi-slate") }, stdio: "inherit", + }); +} finally { rmSync(temporary, { recursive: true, force: true }); } diff --git a/pi-slate/tests/architecture.test.mjs b/pi-slate/tests/architecture.test.mjs new file mode 100644 index 00000000..228cc2ae --- /dev/null +++ b/pi-slate/tests/architecture.test.mjs @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import { readdir, readFile } from "node:fs/promises"; +import test from "node:test"; + +const directory = new URL("../extensions/pi-slate/", import.meta.url); +const hostPackage = /^@(earendil-works|mariozechner)\/pi-(coding-agent|tui|ai|agent-core)(?:\/|$)/; +const publicEntrypoint = /^@(earendil-works|mariozechner)\/pi-(coding-agent|tui|ai|agent-core)$/; + +test("production host imports use public package entrypoints, never computed private module paths", async () => { + for (const file of await readdir(directory)) { + if (!file.endsWith(".ts")) continue; + const text = await readFile(new URL(file, directory), "utf8"); + // All static module specifiers are string literals. Also reject computed dynamic imports, + // which were how the private MCP path bypassed normal public-package import review. + for (const [, specifier] of text.matchAll(/["']([^"'\r\n]*)["']/g)) { + if (hostPackage.test(specifier)) assert(publicEntrypoint.test(specifier), `${file}: private host import ${specifier}`); + assert(!/^(?:file:|\/).*\/pi-(?:coding-agent|tui)\//.test(specifier), `${file}: absolute private host import`); + } + for (const [, argument] of text.matchAll(/\bimport\s*\(\s*([^)]*)\)/g)) { + assert(/^["'][^"']+["']\s*$/.test(argument), `${file}: dynamic imports must be literal, review host coupling before adding one`); + } + } +}); diff --git a/pi-slate/tests/chat-surface.test.ts b/pi-slate/tests/chat-surface.test.ts new file mode 100644 index 00000000..f4a3067a --- /dev/null +++ b/pi-slate/tests/chat-surface.test.ts @@ -0,0 +1,120 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { Container, ScrollView, Text, VStack } from "@earendil-works/pi-tui"; +import { MESSAGE_LENGTH_DEFAULT } from "../extensions/pi-slate/layout.ts"; +import { ChatSurface, findChatContainer } from "../extensions/pi-slate/chat-surface.ts"; +import { ToolGroups } from "../extensions/pi-slate/tool-groups.ts"; + +const rows = (chat: Container) => chat.render(80).map(row => row.trimEnd()); + +test("message window projects display only, never the host's authoritative children or mutation methods", () => { + const chat = new Container(); + const messages = Array.from({ length: 5 }, (_, i) => new Text(`m${i}`, 0, 0)); + messages.forEach(message => chat.addChild(message)); + const add = chat.addChild, remove = chat.removeChild, clear = chat.clear; + const window = new ChatSurface(chat, { limit: 3 }); + assert.deepEqual(chat.children, messages); + assert.deepEqual(rows(chat), ["m2", "m3", "m4"]); + assert.equal(chat.addChild, add); assert.equal(chat.removeChild, remove); assert.equal(chat.clear, clear); + messages[0]!.setText("updated by host while hidden"); + window.update({ limit: 5 }); + assert.match(rows(chat).join("\n"), /updated by host while hidden/); + chat.addChild(new Text("m5", 0, 0)); + assert.equal(chat.children.length, 6); + assert.equal(rows(chat).length, 5); + window.update({ limit: Number.POSITIVE_INFINITY }); + assert.equal(rows(chat).length, 6); + window.dispose(); + assert.equal(chat.render, Container.prototype.render); +}); + +test("finder uses the last nested container inside the primary scroll view", () => { + const header = new Container(), chat = new Container(), document = new Container(); + chat.addChild(new Text("hello", 0, 0)); document.addChild(header); document.addChild(chat); + const root = new VStack([{ component: new ScrollView(document, { primary: true }), grow: 1 }, new Text("editor", 0, 0)]); + assert.equal(findChatContainer(root), chat); + assert.equal(findChatContainer(new VStack([new Container(), new Text("footer", 0, 0)])), undefined); +}); + +test("active work does not hide a live assistant when multiple tools are appended", () => { + const chat = new Container(); let active = true; + const assistant = new Text("assistant", 0, 0), a = new Text("tool a", 0, 0), b = new Text("tool b", 0, 0); + chat.addChild(assistant); + const window = new ChatSurface(chat, { limit: 1, active: () => active }); + chat.addChild(a); chat.addChild(b); + assert.deepEqual(rows(chat), ["assistant", "tool a", "tool b"]); + active = false; + assert.deepEqual(rows(chat), ["tool b"]); + assert.deepEqual(chat.children, [assistant, a, b]); + window.dispose(); + assert.deepEqual(rows(chat), ["assistant", "tool a", "tool b"]); +}); + +test("default visible length stays synced at 100", () => { assert.equal(MESSAGE_LENGTH_DEFAULT, 100); }); + +test("disposed display wrappers retained by successors delegate to native rendering", () => { + const chat = new Container(); + for (let i = 0; i < 4; i++) chat.addChild(new Text(`m${i}`, 0, 0)); + const window = new ChatSurface(chat, { limit: 1 }); + const render = chat.render; + const successor = (width: number) => render.call(chat, width); + chat.render = successor; + assert.equal(rows(chat).length, 1); + window.dispose(); window.dispose(); window.update({ limit: 0 }); + assert.equal(chat.render, successor); + assert.equal(rows(chat).length, 4); + chat.clear(); assert.deepEqual(rows(chat), []); +}); + +test("unknown render owners are preserved rather than bypassed", () => { + const chat = new Container(); + const custom = () => ["native warning or another extension's rendering"]; + chat.render = custom; + assert.throws(() => new ChatSurface(chat, { limit: 1 }), /native transcript retained/); + assert.equal(chat.render, custom); + assert.deepEqual(rows(chat), ["native warning or another extension's rendering"]); +}); + +test("unknown mouse owners are preserved without installing a partial renderer", () => { + const chat = new Container(); + const mouse = () => undefined; + chat.handleMouse = mouse; + assert.throws(() => new ChatSurface(chat, { limit: 1 }), /native transcript retained/); + assert.equal(chat.render, Container.prototype.render); + assert.equal(chat.handleMouse, mouse); +}); + +test("a failed mouse-slot installation rolls back the render slot", () => { + const chat = new Container(); + Object.defineProperty(chat, "handleMouse", { value: Container.prototype.handleMouse, writable: false }); + assert.throws(() => new ChatSurface(chat, { limit: 1 }), TypeError); + assert.equal(chat.render, Container.prototype.render); + assert.equal(chat.handleMouse, Container.prototype.handleMouse); +}); + +test("a successor retaining both disposed wrappers gets native render and mouse behavior", () => { + const chat = new Container(); let clicks = 0; + chat.addChild({ render: () => ["native"], invalidate() {}, handleMouse() { clicks++; return { handled: true }; } }); + const surface = new ChatSurface(chat, { limit: 1 }); + const render = chat.render, mouse = chat.handleMouse; + const successorRender: typeof render = width => render.call(chat, width); + const successorMouse: typeof mouse = event => mouse.call(chat, event); + chat.render = successorRender; chat.handleMouse = successorMouse; + surface.dispose(); + assert.equal(chat.render, successorRender); assert.equal(chat.handleMouse, successorMouse); + assert.deepEqual(rows(chat), ["native"]); + assert.equal(chat.handleMouse({ type: "click", button: "left", x: 0, y: 0, screenX: 0, screenY: 0, width: 80, height: 1, shift: false, alt: false, ctrl: false })?.handled, true); + assert.equal(clicks, 1); +}); + +test("even unbounded and active projections give painters a copy of the host array", () => { + const chat = new Container(); chat.addChild(new Text("native", 0, 0)); + const groups = new ToolGroups(() => undefined); + const render = groups.render.bind(groups); + groups.render = (children, width) => { assert.notEqual(children, chat.children); return render(children, width); }; + const surface = new ChatSurface(chat, { groups }); + assert.deepEqual(rows(chat), ["native"]); + surface.update({ groups, limit: 1, active: () => true }); + assert.deepEqual(rows(chat), ["native"]); + surface.dispose(); +}); diff --git a/pi-slate/tests/composer-selection.test.ts b/pi-slate/tests/composer-selection.test.ts index 27f0aeff..152fc433 100644 --- a/pi-slate/tests/composer-selection.test.ts +++ b/pi-slate/tests/composer-selection.test.ts @@ -27,6 +27,8 @@ class FakeEditor implements ComposerSelectionEditor { pasteCalls: string[] = []; insertCalls: string[] = []; mouseCalls = 0; + undoSnapshots = 0; + pushUndoSnapshot(): void { this.undoSnapshots++; } cursor?: { line: number; col: number }; renderedLines = ["TOP", "prompt", "BOTTOM"]; autocomplete = false; @@ -419,3 +421,48 @@ test("press and drag stay unhandled so Pi can select screen text", () => { assert.deepEqual(copied, []); selection.dispose(); }); + + +test("editing a revealed paste invalidates collapse ranges rather than overwriting prompt edits", () => { + for (const edit of [(text: string) => text + " edited", (text: string) => "prefix " + text, (text: string) => text.replace("one", "changed")]) { + const editor = new FakeEditor("see [paste #1 +2 lines] tail"); + editor.pastes.set(1, "one\ntwo"); editor.cursor = { line: 0, col: 10 }; + attach(editor); editor.handleMouse({ type: "click", button: "left" } as TuiMouseEvent); + assert.equal(editor.undoSnapshots, 1); + editor.text = edit(editor.text); + editor.cursor = { line: 1, col: 1 }; + const edited = editor.text; + editor.handleMouse({ type: "click", button: "left" } as TuiMouseEvent); + assert.equal(editor.text, edited); + } +}); + +test("paste reveals require native undo support and support bodies ending with blank lines", () => { + const editor = new FakeEditor("[paste #1 +2 lines]"); + editor.pastes.set(1, "one\n"); editor.cursor = { line: 0, col: 5 }; + attach(editor); + editor.handleMouse({ type: "click", button: "left" } as TuiMouseEvent); + assert.equal(editor.text, "one\n"); + editor.handleMouse({ type: "click", button: "left" } as TuiMouseEvent); + assert.equal(editor.text, "[paste #1 +2 lines]"); + assert.equal(editor.undoSnapshots, 2); + Reflect.set(editor, "pushUndoSnapshot", undefined); + editor.handleMouse({ type: "click", button: "left" } as TuiMouseEvent); + assert.equal(editor.text, "[paste #1 +2 lines]", "unsupported private range editing stays native"); +}); + +test("real editor paste reveal is undoable without orphaning native paste bodies", t => { + const { editor } = createIntegratedEditor(t); + const selection = new ComposerSelectionController(); selection.attach(editor); + t.after(() => selection.dispose()); + const body = "x".repeat(1001) + "\n"; + editor.insertTextAtCursor(body); + const token = editor.getText(); + assert.match(token, /^\[paste #1/); + editor.render(80); + editor.handleMouse({ type: "click", button: "left", x: 5, y: 1, screenX: 5, screenY: 1, width: 80, height: 4 } as TuiMouseEvent); + assert.equal(editor.getText(), body); + editor.handleInput("\x1b[45;5u"); + assert.equal(editor.getText(), token); + assert.equal(editor.getExpandedText(), body); +}); diff --git a/pi-slate/tests/diagnostics.test.ts b/pi-slate/tests/diagnostics.test.ts new file mode 100644 index 00000000..17027577 --- /dev/null +++ b/pi-slate/tests/diagnostics.test.ts @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { SlateDiagnostics } from "../extensions/pi-slate/diagnostics.ts"; + +test("optional adapter failures are isolated, quarantined, and do not retain exception secrets", () => { + const diagnostics = new SlateDiagnostics(); + let calls = 0; + const fail = () => { calls++; throw new Error("SECRET credential and host internals"); }; + assert.equal(diagnostics.attempt("sidebar", "Native layout retained; split adapter failed.", fail, () => false), false); + assert.equal(diagnostics.attempt("sidebar", "Native layout retained; split adapter failed.", fail, () => false), false); + assert.equal(calls, 1); + diagnostics.active("sidebar", "must not erase a quarantined failure"); + assert.equal(diagnostics.attempt("transcript", "Unused", () => 42, () => 0), 42, "unrelated features still work"); + const report = diagnostics.report("0.2.0", "99.0.0", ["sidebar", "editor"]); + assert.match(report, /sidebar: fallback/); + assert.match(report, /Native layout retained/); + assert.doesNotMatch(report, /SECRET|credential|host internals|Warning|Error/); + diagnostics.reset(); + assert.equal(diagnostics.attempt("sidebar", "Unused", () => true, () => false), true); + assert.deepEqual(diagnostics.snapshot(), []); +}); + +test("expected missing capabilities remain recoverable and snapshots cannot mutate diagnostics", () => { + const diagnostics = new SlateDiagnostics(); + diagnostics.fallback("transcript", "Native transcript retained; no compatible chat container."); + diagnostics.active("transcript", "Message window mounted."); + const snapshot = diagnostics.snapshot(); + snapshot[0]!.detail = "mutated"; + assert.equal(diagnostics.snapshot()[0]!.detail, "Message window mounted."); + assert.match(diagnostics.report("0.2.0", "1.5.2", []), /Selected surfaces: none/); +}); diff --git a/pi-slate/tests/host-compat.test.mjs b/pi-slate/tests/host-compat.test.mjs new file mode 100644 index 00000000..f5ca577d --- /dev/null +++ b/pi-slate/tests/host-compat.test.mjs @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { SURFACES } from "../extensions/pi-slate/surfaces.ts"; + +const entry = process.env.SLATE_COMPAT_HOST + ? join(await realpath(process.env.SLATE_COMPAT_HOST), "dist/index.js") + : fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent")); +const hostDir = dirname(dirname(entry)); +const { version } = JSON.parse(await readFile(join(hostDir, "package.json"), "utf8")); +const packageDir = await realpath(process.env.SLATE_COMPAT_PACKAGE ?? fileURLToPath(new URL("../", import.meta.url))); +const probe = fileURLToPath(new URL("../scripts/host-smoke.mjs", import.meta.url)); + +async function runProbe(mode, surfaces, fault) { + const root = await mkdtemp(join(tmpdir(), "slate-host-compat-")); + try { + // Deliberately do not inherit provider keys, credentials, project settings or operator HOME. + const env = { + PATH: process.env.PATH, HOME: root, USERPROFILE: root, XDG_CONFIG_HOME: root, + SYSTEMROOT: process.env.SYSTEMROOT, WINDIR: process.env.WINDIR, + PI_CODING_AGENT_DIR: root, PI_OFFLINE: "1", TERM: "xterm-256color", NO_COLOR: "1", + SLATE_COMPAT_HOST_ENTRY: entry, SLATE_COMPAT_HOST_DIR: hostDir, SLATE_COMPAT_VERSION: version, + SLATE_COMPAT_PACKAGE: packageDir, SLATE_COMPAT_MODE: mode, SLATE_COMPAT_SURFACES: surfaces, SLATE_COMPAT_FAULT: fault, + }; + const result = spawnSync(process.execPath, [probe], { env, cwd: root, encoding: "utf8", timeout: 60000, maxBuffer: 2 * 1024 * 1024 }); + assert.ifError(result.error); + return result; + } finally { await rm(root, { recursive: true, force: true }); } +} + +for (const mode of ["regular", "fullscreen"]) { + for (const [label, surfaces] of [["defaults", "fresh"], ["none", "[]"], ["full", JSON.stringify(SURFACES)]]) { + test(`real Pi ${version}: ${mode}, ${label} — init, mount, render, replace, shutdown`, async () => { + const result = await runProbe(mode, surfaces); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + const receipt = JSON.parse(result.stdout.trim()); + assert.equal(receipt.host, version); assert.equal(receipt.rendered, true); + assert.equal(receipt.replaced, true); assert.equal(receipt.shutdown, true); assert.equal(receipt.networkRequests, 0); + }); + } +} +for (const [fault, expected] of [["quit-shutdown", /Injected quit-only shutdown fault/], ["transcript", /transcript window did not mount/]]) { + test(`real Pi ${version}: canary rejects ${fault} instead of reporting a false success`, async () => { + const result = await runProbe("fullscreen", JSON.stringify(SURFACES), fault); + assert.notEqual(result.status, 0); + assert.match(result.stderr, expected); + }); +} diff --git a/pi-slate/tests/message-window.test.ts b/pi-slate/tests/message-window.test.ts deleted file mode 100644 index c74dedb9..00000000 --- a/pi-slate/tests/message-window.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { Container, ScrollView, Text, VStack } from "@earendil-works/pi-tui"; -import { MESSAGE_LENGTH_DEFAULT } from "../extensions/pi-slate/layout.ts"; -import { findChatContainer, MessageWindow } from "../extensions/pi-slate/message-window.ts"; - -test("message window keeps the newest messages and restores older ones", () => { - const chat = new Container(); - const messages = Array.from({ length: 5 }, (_, i) => new Text(`m${i}`, 0, 0)); - for (const message of messages) chat.addChild(message); - const window = new MessageWindow(chat, 3); - assert.deepEqual(chat.children, messages.slice(2)); - window.setLimit(5); - assert.deepEqual(chat.children, messages); - const extra = new Text("m5", 0, 0); - chat.addChild(extra); - assert.deepEqual(chat.children, [...messages.slice(1), extra]); - window.setLimit(Number.POSITIVE_INFINITY); - assert.equal(chat.children.length, 6); - window.dispose(); - chat.addChild(new Text("m6", 0, 0)); - assert.equal(chat.children.length, 7); -}); - -test("finder uses the last nested container inside the primary scroll view", () => { - const header = new Container(); - const chat = new Container(); - chat.addChild(new Text("hello", 0, 0)); - const document = new Container(); - document.addChild(header); - document.addChild(chat); - const root = new VStack([ - { component: new ScrollView(document, { primary: true }), grow: 1 }, - new Text("editor", 0, 0), - ]); - assert.equal(findChatContainer(root), chat); - assert.equal(findChatContainer(new VStack([new Container(), new Text("footer", 0, 0)])), undefined); -}); - -test("a live assistant row is not evicted by the next tool child", () => { - const chat = new Container(); - const assistant = new Text("assistant", 0, 0); - const tool = new Text("tool", 0, 0); - chat.addChild(assistant); - const window = new MessageWindow(chat, 1); - chat.addChild(tool); - assert.deepEqual(chat.children, [assistant, tool]); - window.dispose(); -}); - -test("default visible length stays synced at 100", () => { - assert.equal(MESSAGE_LENGTH_DEFAULT, 100); -}); - -test("disposed wrappers retained by successors only delegate, without trimming", () => { - const chat = new Container(); const items = Array.from({ length: 6 }, (_, i) => new Text(`m${i}`, 0, 0)); - items.slice(0, 3).forEach(item => chat.addChild(item)); - const window = new MessageWindow(chat, 1); - const add = chat.addChild, remove = chat.removeChild, clear = chat.clear; - const successorAdd = (item: Text) => add(item); - chat.addChild = successorAdd; chat.removeChild = item => remove(item); chat.clear = () => clear(); - window.dispose(); window.dispose(); window.setLimit(0); - assert.equal(chat.addChild, successorAdd); - items.slice(3).forEach(item => chat.addChild(item)); assert.deepEqual(chat.children, items); - chat.removeChild(items[1]!); assert.equal(chat.children.length, 5); - chat.clear(); assert.equal(chat.children.length, 0); - chat.addChild(items[0]!); assert.deepEqual(chat.children, [items[0]]); -}); diff --git a/pi-slate/tests/sidebar-data.test.ts b/pi-slate/tests/sidebar-data.test.ts index ad5e8e0a..f02636a6 100644 --- a/pi-slate/tests/sidebar-data.test.ts +++ b/pi-slate/tests/sidebar-data.test.ts @@ -1,13 +1,9 @@ import assert from "node:assert/strict"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { homedir, tmpdir } from "node:os"; +import { homedir } from "node:os"; import { join } from "node:path"; import test from "node:test"; -import { getPackageDir, VERSION, type SessionEntry, type Skill, type SlashCommandInfo } from "@earendil-works/pi-coding-agent"; -import { loadSidebarMcpHost } from "../extensions/pi-slate/sidebar-mcp.ts"; -const host = await loadSidebarMcpHost(getPackageDir(), VERSION); -assert(host); -import { commandResources, parseMcpFile, parseSidebarFolds, sidebarMessageCounts, sidebarMcp, sidebarText, sidebarUsage, SidebarMcpFiles, SidebarSkills } from "../extensions/pi-slate/sidebar-data.ts"; +import type { SessionEntry, Skill, SlashCommandInfo } from "@earendil-works/pi-coding-agent"; +import { commandResources, parseMcpFile, parseSidebarFolds, sidebarMessageCounts, sidebarMcp, sidebarText, sidebarUsage, SidebarSkills } from "../extensions/pi-slate/sidebar-data.ts"; const usage = { input: 100, output: 20, cacheRead: 300, cacheWrite: 50, reasoning: 10, totalTokens: 470, cost: { total: 0.25 } }; function entry(id: string, fields: Record): SessionEntry { return { id, parentId: null, timestamp: "0", ...fields } as unknown as SessionEntry; } @@ -108,69 +104,9 @@ test("explicit skill invocation and structured catalog work with skill commands tracker.reset([]); assert.deepEqual(tracker.snapshot(), []); }); -test("MCP rejected partial overrides never replace an exact-name valid server", () => { - const global = parseMcpFile(JSON.stringify({ mcpServers: { "web-search": { command: "search" }, other: { command: "other" } } })); - const project = parseMcpFile(JSON.stringify({ mcpServers: { web_search: { enabled: false } } })); - const result = sidebarMcp([{ name: "web-search", config: { command: "extension" } }], global, project, host); - assert.equal(result.find(item => item.name === "web-search")!.enabled, true); - assert.equal(result.find(item => item.name === "web_search")!.enabled, null); -}); - -test("disabled/invalid entries remain visible and errors never include credentials", () => { - const result = sidebarMcp([], parseMcpFile(JSON.stringify({ mcpServers: { a: { command: "a", enabled: false }, b: {}, c: null, d: { command: "d", enabled: "yes" } } })), undefined, host); - assert.deepEqual(result.map(x => x.enabled), [false, null, null, null]); - assert.equal(parseMcpFile('{"token":"SECRET",bad}').error, "MCP config unreadable"); - assert.equal(parseMcpFile('{"mcpServers":[]}').error, "MCP config invalid"); - assert.deepEqual(parseMcpFile(undefined), { servers: {} }); -}); - -test("MCP parsing follows the host's BOM support and still rejects malformed JSON", () => { - const text = '\uFEFF{"mcpServers":{"server":{"command":"server"}}}'; - assert.deepEqual(parseMcpFile(text, true), parseMcpFile(text.slice(1))); - assert.equal(parseMcpFile(text).error, "MCP config unreadable", "older hosts do not support a BOM"); - assert.equal(parseMcpFile('\uFEFF{"mcpServers":[]}', true).error, "MCP config invalid"); - assert.equal(parseMcpFile('\uFEFF{"token":"SECRET",bad}', true).error, "MCP config unreadable"); - assert.equal(parseMcpFile('\uFEFF\uFEFF{}', true).error, "MCP config unreadable"); -}); - -test("MCP file cache invalidates parsed files when host BOM support changes", async t => { - const root = await mkdtemp(join(tmpdir(), "slate-mcp-bom-")); t.after(() => rm(root, { recursive: true, force: true })); - await writeFile(join(root, "mcp.json"), '\uFEFF{"mcpServers":{"server":{"command":"server"}}}'); - const cache = new SidebarMcpFiles(); cache.setHost({ ...host, stripBom: false }); - assert.equal(cache.snapshot(root, root, false, []).mcpError, "MCP config unreadable"); - cache.setHost({ ...host, stripBom: true }); - assert.deepEqual(cache.snapshot(root, root, false, []), { mcp: [{ name: "server", source: "global", enabled: true }] }); - const parsed = cache.read(join(root, "mcp.json")); - cache.setHost({ ...host, stripBom: true }); - assert.equal(cache.read(join(root, "mcp.json")), parsed, "unchanged parsing semantics preserve the cache"); - cache.setHost(undefined); - assert.equal(cache.snapshot(root, root, false, []).mcpError, "MCP config unreadable"); - cache.setHost({ ...host, stripBom: false }); - assert.equal(cache.snapshot(root, root, false, []).mcpError, "MCP config unreadable"); -}); - -test("MCP states follow native enabled flags and supported transports, not ignored legacy keys", () => { - const result = sidebarMcp([], parseMcpFile(JSON.stringify({ mcpServers: { - legacy: { command: "server", disabled: true }, off: { command: "server", enabled: false }, - invalid: { url: "file:///private" }, sse: { type: "sse", url: "https://example.com/mcp" }, - http: { type: "streamable-http", url: "https://example.com/mcp" }, - } })), undefined, host); - assert.deepEqual(result.map(x => [x.name, x.enabled]), [["http", true], ["invalid", null], ["legacy", true], ["off", false], ["sse", null]]); -}); - -test("MCP file cache follows changes/removal and refuses untrusted project configuration", async t => { - const root = await mkdtemp(join(tmpdir(), "slate-mcp-")); t.after(() => rm(root, { recursive: true, force: true })); - const agent = join(root, "agent"), cwd = join(root, "repo"); await mkdir(agent); await mkdir(join(cwd, ".pi"), { recursive: true }); - const global = join(agent, "mcp.json"); - await writeFile(global, '{"mcpServers":{"a":{"command":"a"}}}'); - await writeFile(join(cwd, ".pi", "mcp.json"), '{"mcpServers":{"a":{"enabled":false}}}'); - const cache = new SidebarMcpFiles(); cache.setHost(host); - assert.equal(cache.snapshot(agent, cwd, false, []).mcp[0]!.enabled, true); - assert.equal(cache.snapshot(agent, cwd, true, []).mcp[0]!.enabled, false, "modern hosts merge partial project overrides"); - await writeFile(global, '{"mcpServers":{"b":{"command":"b"}}}'); - assert.equal(cache.snapshot(agent, cwd, false, []).mcp[0]!.name, "b"); - await rm(global); assert.deepEqual(cache.snapshot(agent, cwd, false, []).mcp, []); - cache.clear(); +test("MCP data re-exports remain configuration-only and independent of host modules", () => { + assert.deepEqual(sidebarMcp([], parseMcpFile('{"mcpServers":{"future":{"type":"future-transport","enabled":false}}}')), + [{ name: "future", source: "global", enabled: false }]); }); test("fold config defaults safely and display strings neutralize terminal escape/control data", () => { diff --git a/pi-slate/tests/sidebar-mcp.test.ts b/pi-slate/tests/sidebar-mcp.test.ts index 513d6f8a..89ae93cf 100644 --- a/pi-slate/tests/sidebar-mcp.test.ts +++ b/pi-slate/tests/sidebar-mcp.test.ts @@ -2,156 +2,84 @@ import assert from "node:assert/strict"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { pathToFileURL } from "node:url"; import test from "node:test"; -import { getPackageDir, VERSION } from "@earendil-works/pi-coding-agent"; -import { loadSidebarMcpHost } from "../extensions/pi-slate/sidebar-mcp.ts"; -import { SidebarMcpFiles } from "../extensions/pi-slate/sidebar-data.ts"; +import { mcpDetails, parseMcpFile, sidebarMcp, SidebarMcpFiles } from "../extensions/pi-slate/sidebar-mcp.ts"; -// Check both installations even at the same version: the operator's actual host may differ from the dev pin. -const installed = "/opt/homebrew/lib/node_modules/@earendil-works/pi-coding-agent"; -const installedVersion = await readFile(join(installed, "package.json"), "utf8").then(text => (JSON.parse(text) as { version: string }).version).catch(() => "unavailable"); -const packages = [[getPackageDir(), VERSION], [installed, installedVersion]] as const; -for (const [packageDir, version] of packages) { - test(`MCP parity with native ${version}: exact names, invalid values, overrides, collisions and project auth`, async t => { - const root = await mkdtemp(join(tmpdir(), "slate-mcp-parity-")); t.after(() => rm(root, { recursive: true, force: true })); - const agentDir = join(root, "agent"), cwd = join(root, "repo"); await mkdir(agentDir); await mkdir(join(cwd, ".pi"), { recursive: true }); - let native: { loadMcpConfig: (options: { agentDir: string; cwd: string; projectTrusted: boolean }) => { servers: Array<{ name: string; config: { enabled?: boolean } }>; errors: string[] } }; - try { native = await import(pathToFileURL(join(packageDir, "dist/extensions/mcp/config.js")).href); } - catch { if (packageDir !== getPackageDir()) { t.skip("operator host not installed here"); return; } throw new Error("pinned parser unavailable"); } - const host = await loadSidebarMcpHost(packageDir, version); assert(host); - const cache = new SidebarMcpFiles(); cache.setHost(host); - assert.deepEqual(cache.snapshot(agentDir, cwd, true, []), { mcp: [] }, "missing configuration files are not an MCP error"); - const fixtures = [ - [{}, {}], - [{ x: { command: "server" } }, { x: { enabled: false } }], - [{ x: { command: "server" } }, { x: { enabled: false, env: { SECRET: "SECRET" } } }], - [{ x: { command: "server" } }, { x: { command: "replacement", args: 123 } }], - [{ "web-search": { command: "server" }, web_search: { command: "collision" } }, {}], - [{ x: { url: "https://example.com", headers: { token: 123 } }, y: { url: "https://example.com", oauth: { callbackPort: -1, clientSecret: "SECRET" } } }, {}], - [{}, { x: { url: "https://example.com", auth: { provider: "fixture" } } }], - [{ x: { command: "server", enabled: false, exposure: "codemode-deferred" } }, {}], - [{ auto: { url: "https://example.com/mcp", oauth: { clientRegistration: "auto" } } }, {}], - ]; - for (const [global, project] of fixtures) { - await writeFile(join(agentDir, "mcp.json"), JSON.stringify({ mcpServers: global })); - await writeFile(join(cwd, ".pi/mcp.json"), JSON.stringify({ mcpServers: project })); - cache.clear(); - const loaded = native.loadMcpConfig({ agentDir, cwd, projectTrusted: true }); - const result = cache.snapshot(agentDir, cwd, true, []); - const configured = new Set([...Object.keys(global), ...Object.keys(project)]); - const nativeRows = loaded.servers.filter(row => configured.has(row.name)).map(row => [row.name, row.config.enabled !== false]).sort(); - assert.equal(Boolean(result.mcpError), loaded.errors.length > 0); - assert.equal(result.mcpWarning, undefined); - assert.deepEqual( - result.mcp.filter(row => row.enabled !== null && configured.has(row.name)).map(row => [row.name, row.enabled]).sort(), - nativeRows, - ); - assert(!JSON.stringify(result).includes("SECRET")); - } - if (host.stripBom) { - await writeFile(join(agentDir, "mcp.json"), '\uFEFF{"mcpServers":{"bom":{"command":"server"}}}'); - await writeFile(join(cwd, ".pi/mcp.json"), '\uFEFF{"mcpServers":{"bom":{"enabled":false}}}'); cache.clear(); - const loaded = native.loadMcpConfig({ agentDir, cwd, projectTrusted: true }); - assert.deepEqual(loaded.errors, []); - const result = cache.snapshot(agentDir, cwd, true, []); - assert.equal(result.mcpError, undefined); - assert.deepEqual(result.mcp.map(row => [row.name, row.enabled]), loaded.servers.map(row => [row.name, row.config.enabled !== false])); - } - await writeFile(join(agentDir, "mcp.json"), '{"mcpServers":{}}'); - await writeFile(join(cwd, ".pi/mcp.json"), '{"mcpServers":{"registered":{"enabled":false}}}'); cache.clear(); - assert.equal(cache.snapshot(agentDir, cwd, true, [{ name: "registered", config: { command: "server" } }]).mcp[0]!.enabled, true); - await writeFile(join(agentDir, "mcp.json"), JSON.stringify({ mcpServers: { auto: { url: "https://example.com/mcp", oauth: { clientRegistration: "auto" } } } })); - await writeFile(join(cwd, ".pi/mcp.json"), '{"mcpServers":{}}'); cache.clear(); - const autoLoaded = native.loadMcpConfig({ agentDir, cwd, projectTrusted: true }); - const autoResult = cache.snapshot(agentDir, cwd, true, []); - const autoEnabled = autoResult.mcp.find(row => row.name === "auto")?.enabled ?? null; - assert.equal(autoEnabled === true, autoLoaded.servers.some(row => row.name === "auto"), `${version} uses the running native validator`); - assert.equal(Boolean(autoResult.mcpError), autoLoaded.errors.length > 0); - assert.equal(autoResult.mcpWarning, undefined); - if (version === "1.4.0") assert.equal(autoEnabled, null, "Pi 1.4 rejects oauth.clientRegistration auto"); - if (version === "1.5.0") assert.equal(autoEnabled, true, "Pi 1.5 accepts oauth.clientRegistration auto"); - }); -} -test("host adapter requires native validation and modern namespaces", async t => { - const root = await mkdtemp(join(tmpdir(), "slate-mcp-exports-")); t.after(() => rm(root, { recursive: true, force: true })); - const fixtures = [ - { name: "missing-validator", exports: "export const mcpNamespace = name => name;", modern: false, legacy: false }, - { name: "invalid-validator", exports: "export const validateMcpServerConfig = true; export const mcpNamespace = name => name;", modern: false, legacy: false }, - { name: "missing-namespace", exports: "export const validateMcpServerConfig = (_name, value) => value;", modern: false, legacy: true }, - { name: "invalid-namespace", exports: "export const validateMcpServerConfig = (_name, value) => value; export const mcpNamespace = true;", modern: false, legacy: true }, - { name: "complete", exports: "export const validateMcpServerConfig = (_name, value) => value; export const mcpNamespace = name => name;", modern: true, legacy: true }, - { name: "throws", exports: "throw new Error(\"load failed\");", modern: false, legacy: false }, - ]; - const modernVersions = ["1.1.0", "1.4.0", "2.0.0", "not-a-version"]; - for (const fixture of fixtures) { - const packageDir = join(root, fixture.name); - await mkdir(join(packageDir, "dist/core"), { recursive: true }); - await writeFile(join(packageDir, "package.json"), '{"type":"module"}'); - await writeFile(join(packageDir, "dist/core/mcp-servers.js"), fixture.exports); - for (const version of modernVersions) { - assert.equal(Boolean(await loadSidebarMcpHost(packageDir, version)), fixture.modern, `${fixture.name}@${version}`); - } - assert.equal(Boolean(await loadSidebarMcpHost(packageDir, "0.99.0")), fixture.legacy, fixture.name); - assert.equal(Boolean(await loadSidebarMcpHost(packageDir, "1.0.0")), fixture.legacy, fixture.name); - } - for (const [version, projectOverrides, stripBom] of [ - ["0.99.0", false, false], ["1.0.0", false, false], ["1.0.3", true, true], ["1.0.4", true, false], - ["1.0.99", true, false], ["1.1.0", true, false], ["1.2.0", true, false], ["1.3.0", true, false], - ["1.3.1", true, false], ["1.4.0", true, true], ["1.4.1", true, true], ["1.5.0", true, true], - ["1.5.1", true, true], ["1.10.0", true, true], ["2.0.0", true, true], ["9.1.0", true, true], - ["unknown", true, true], ["1.5.0-rc.1", true, true], ["", true, true], - ] as const) { - const host = await loadSidebarMcpHost(join(root, "complete"), version); assert(host, version); - assert.equal(host.projectOverrides, projectOverrides, version); - assert.equal(host.stripBom, stripBom, version); - } - assert.equal(await loadSidebarMcpHost(join(root, "absent"), "1.5.0"), undefined); - assert.equal(await loadSidebarMcpHost(join(root, "absent"), "not-a-version"), undefined); +const file = (servers: Record) => parseMcpFile(JSON.stringify({ mcpServers: servers })); + +test("MCP is a version-independent configuration projection, never a second server validator", () => { + const result = sidebarMcp([], file({ + command: { command: "never executed", env: { TOKEN: "!never execute this SECRET" } }, + disabled: { enabled: false, command: "server" }, + future: { type: "future-transport", oauth: { futureField: "SECRET" } }, + invalidToPi: { url: "file:///not-accepted-by-Pi", oauth: false }, + empty: {}, scalar: null, unknownFlag: { enabled: "yes" }, + })); + assert.deepEqual(result.map(row => [row.name, row.enabled]), [ + ["command", true], ["disabled", false], ["empty", true], ["future", true], + ["invalidToPi", true], ["scalar", null], ["unknownFlag", null], + ]); + assert(!JSON.stringify(result).includes("SECRET")); + assert.match(mcpDetails(result[0]!), /does not validate/); + assert.match(mcpDetails(result.find(row => row.name === "scalar")!), /Enabled state could not be read/); }); -test("unavailable validation is not an error without entries, but file errors remain visible", async t => { - const root = await mkdtemp(join(tmpdir(), "slate-mcp-empty-")); t.after(() => rm(root, { recursive: true, force: true })); - await mkdir(join(root, ".pi")); - const cache = new SidebarMcpFiles(); - assert.deepEqual(cache.snapshot(root, root, true, []), { mcp: [] }, "missing files need no validation"); - await writeFile(join(root, "mcp.json"), "{}"); - await writeFile(join(root, ".pi/mcp.json"), '{"mcpServers":{}}'); - assert.deepEqual(cache.snapshot(root, root, true, []), { mcp: [] }, "empty files need no validation"); - await writeFile(join(root, ".pi/mcp.json"), '{"mcpServers":{"project":{"command":"server"}}}'); - assert.deepEqual(cache.snapshot(root, root, false, []), { mcp: [] }, "untrusted entries are not read"); - const project = cache.snapshot(root, root, true, []); - assert.equal(project.mcp[0]!.enabled, null); - assert.equal(project.mcpError, undefined); - assert.match(project.mcpWarning!, /unavailable/); - await writeFile(join(root, ".pi/mcp.json"), "{bad"); - const unreadable = cache.snapshot(root, root, true, []); - assert.equal(unreadable.mcpError, "MCP config unreadable"); - assert.equal(unreadable.mcpWarning, undefined); - await writeFile(join(root, "mcp.json"), '{"mcpServers":[]}'); - const invalid = cache.snapshot(root, root, false, []); - assert.equal(invalid.mcpError, "MCP config invalid"); - assert.equal(invalid.mcpWarning, undefined); - await writeFile(join(root, "mcp.json"), '{"mcpServers":{"global":{"command":"server"}}}'); - const global = cache.snapshot(root, root, false, []); - assert.equal(global.mcp[0]!.enabled, null); - assert.equal(global.mcpError, undefined); - assert.match(global.mcpWarning!, /unavailable/); - await writeFile(join(root, "mcp.json"), "{bad"); - const fileAndAdapter = cache.snapshot(root, root, false, [{ name: "registered", config: { command: "server", env: { TOKEN: "SECRET" } } }]); - assert.equal(fileAndAdapter.mcpError, "MCP config unreadable"); - assert.match(fileAndAdapter.mcpWarning!, /unavailable/); - assert.equal(fileAndAdapter.mcp.find(row => row.name === "registered")?.enabled, null); - assert(!JSON.stringify(fileAndAdapter).includes("SECRET")); +test("file declarations override registrations without revalidating the public registry", () => { + const result = sidebarMcp([ + { name: "web_search", config: { command: "extension", enabled: false } }, + { name: "registered", config: { enabled: true, futureTransport: {} } }, + ], file({ "web-search": { enabled: true, command: "file" } })); + assert.deepEqual(result, [ + { name: "registered", source: "extension", enabled: true }, + { name: "web-search", source: "global", enabled: true }, + ]); +}); + +test("trusted project declarations inherit exact-name global flags or replace full declarations", () => { + const global = file({ a: { command: "a", enabled: false }, b: { command: "b", enabled: false }, "web-search": { command: "web" } }); + const project = file({ a: { exposure: "future", futureOverrideField: true }, b: { command: "replacement" }, web_search: { enabled: false } }); + assert.deepEqual(sidebarMcp([], global, project).map(row => [row.name, row.enabled]), [ + ["a", false], ["b", true], ["web_search", false], ["web-search", true], + ].sort((a, b) => String(a[0]).localeCompare(String(b[0])))); + assert.equal(sidebarMcp([], global, file({ a: { enabled: true } })).find(row => row.name === "a")!.enabled, true); +}); + +test("BOM normalization is independent of host capabilities; actual malformed files remain errors", () => { + const text = '{"mcpServers":{"server":{"command":"server"}}}'; + assert.deepEqual(parseMcpFile(`\uFEFF${text}`), parseMcpFile(text)); + assert.equal(parseMcpFile('\uFEFF\uFEFF{}').error, "MCP config unreadable"); + assert.equal(parseMcpFile('{"token":"SECRET",bad}').error, "MCP config unreadable"); + for (const text of ['[]', 'null', '{"mcpServers":[]}']) assert.equal(parseMcpFile(text).error, "MCP config invalid"); + assert.deepEqual(parseMcpFile(undefined), { servers: {} }); + assert.deepEqual(parseMcpFile('{}'), { servers: {} }); }); -test("unavailable host adapter never fabricates enabled/disabled state", async () => { - assert.equal(await loadSidebarMcpHost("/missing", "1.5.0"), undefined); - assert.equal(await loadSidebarMcpHost("/missing", "2.0.0"), undefined); +test("cache follows edits/removal, bounds reads, and never reads untrusted project configuration", async t => { + const root = await mkdtemp(join(tmpdir(), "slate-mcp-projection-")); + t.after(() => rm(root, { recursive: true, force: true })); + const agent = join(root, "agent"), cwd = join(root, "repo"); + await mkdir(agent); await mkdir(join(cwd, ".pi"), { recursive: true }); + const global = join(agent, "mcp.json"), project = join(cwd, ".pi/mcp.json"); const cache = new SidebarMcpFiles(); - const result = cache.snapshot("/missing", "/missing", false, [{ name: "registered", config: { command: "server" } }]); - assert.equal(result.mcp[0]!.enabled, null); - assert.equal(result.mcpError, undefined); - assert.match(result.mcpWarning!, /unavailable/); - assert.deepEqual(cache.snapshot("/missing", "/missing", false, []), { mcp: [] }); + assert.deepEqual(cache.snapshot(agent, cwd, false, []), { mcp: [] }); + await writeFile(global, '\uFEFF{"mcpServers":{"a":{"command":"!never run","enabled":false}}}'); + await writeFile(project, '{"mcpServers":{"a":{"enabled":true}}}'); + assert.equal(cache.snapshot(agent, cwd, false, []).mcp[0]!.enabled, false); + const parsed = cache.read(global); + assert.equal(cache.read(global), parsed); + assert.equal(cache.snapshot(agent, cwd, true, []).mcp[0]!.enabled, true); + await writeFile(project, '{broken SECRET'); + assert.equal(cache.snapshot(agent, cwd, false, []).mcpError, undefined); + const result = cache.snapshot(agent, cwd, true, []); + assert.equal(result.mcpError, "MCP config unreadable"); + assert.equal(result.mcp[0]!.enabled, false, "a malformed project file does not erase readable global state"); + assert(!JSON.stringify(result).includes("SECRET")); + await writeFile(global, '{"mcpServers":{"b":{"command":"new"}}}'); + assert.equal(cache.snapshot(agent, cwd, false, []).mcp[0]!.name, "b"); + await writeFile(global, " ".repeat(1024 * 1024 + 1)); + assert.equal(cache.snapshot(agent, cwd, false, []).mcpError, "MCP config unreadable"); + await rm(global); + assert.deepEqual(cache.snapshot(agent, cwd, false, []), { mcp: [] }); + cache.clear(); + assert.match(await readFile(project, "utf8"), /SECRET/, "inspection never rewrites files"); }); diff --git a/pi-slate/tests/sidebar-preview.test.ts b/pi-slate/tests/sidebar-preview.test.ts index 01b48925..8fafd200 100644 --- a/pi-slate/tests/sidebar-preview.test.ts +++ b/pi-slate/tests/sidebar-preview.test.ts @@ -430,78 +430,39 @@ test("unknown values and invalid MCP config remain explicit even when folded", ( assert(!lines.some(line => line.includes("$0.00"))); }); -test("rejected MCP entries count as Errors without dumping the reason", () => { - const { sidebar } = fixture(); - sidebar.setResources({ - ...resources, - mcp: [ - { name: "ok", enabled: true, source: "global" }, - { name: "ok2", enabled: true, source: "global" }, - { name: "bad", enabled: null, source: "project" }, - ], - mcpError: "MCP configuration has rejected entries", - }); - const lines = plain(sidebar.render(80)); - assert(lines.some(line => line.includes("MCP · 2 Enabled · 1 Error")), "enabled count stays numeric next to the error count"); - assert(!lines.some(line => line.includes("rejected entries") || line.includes("config error"))); -}); - -test("unavailable MCP validation renders as a warning when folded or expanded", () => { +test("unknown MCP declarations are neutral when folded or expanded and inspection explains the boundary", () => { const { sidebar } = fixture(); const details: string[] = []; sidebar.setActions({ copy() {}, openFile() {}, inspect: (_title, text) => details.push(text) }); - sidebar.setResources({ - ...resources, - mcp: [{ name: "unknown-server", enabled: null, source: "global" }], - mcpWarning: "MCP configuration validation unavailable", - }); + sidebar.setResources({ ...resources, mcp: [{ name: "unknown-server", enabled: null, source: "global" }] }); const folded = plain(sidebar.render(80)); - assert(folded.some(line => line.includes("› MCP · — · 1 Warning"))); - assert(!folded.some(line => line.includes("Error") || line.includes("0 Enabled") || line.includes("validation unavailable"))); + assert(folded.some(line => line.includes("› MCP · —"))); + assert(!folded.some(line => /Error|Warning|0 Enabled/.test(line))); sidebar.setFolds({ mcp: true, skills: false, prompts: false }); const expanded = plain(sidebar.render(80)); - assert(expanded.some(line => line.includes("⌄ MCP · — · 1 Warning"))); + assert(expanded.some(line => line.includes("⌄ MCP · —"))); assert(expanded.some(line => line.trim() === "— unknown-server")); - assert(!expanded.some(line => line.includes("Error") || line.includes("0 Enabled") || line.includes("validation unavailable"))); sidebar.setThemeProvider(() => ({ fg: (tone: string, text: string) => tone === "warning" ? `\x1b[33m${text}\x1b[0m` : tone === "error" ? `\x1b[31m${text}\x1b[0m` : text, bold: (text: string) => text }) as Theme); - const painted = sidebar.render(80).join("\n"); - assert(painted.includes("\x1b[33m · 1 Warning\x1b[0m")); - assert(!painted.includes("\x1b[31m")); + assert(!/\x1b\[(?:33|31)m/.test(sidebar.render(80).join("\n"))); sidebar.handleMouse(mouse(row(sidebar, "unknown-server"))); - assert.match(details[0]!, /MCP configuration validation unavailable/); - assert(!details[0]!.includes("SECRET")); -}); - -test("MCP file errors stay numeric and outrank a validation warning", () => { - const { sidebar } = fixture(); - sidebar.setResources({ ...resources, mcpError: "MCP config unreadable", mcpWarning: "MCP configuration validation unavailable" }); - const mixed = plain(sidebar.render(80)); - assert(mixed.some(line => line.includes("MCP · 6 Enabled · 1 Error"))); - assert(!mixed.some(line => line.includes("Warning") || line.includes("validation unavailable"))); - sidebar.setResources({ - skills: [], commands: [], prompts: [], - mcp: [{ name: "bad", enabled: null, source: "global" }], - mcpError: "MCP config unreadable", - mcpWarning: "MCP configuration validation unavailable", - }); - const unknown = plain(sidebar.render(80)); - assert(unknown.some(line => line.includes("MCP · — · 1 Error"))); - assert(!unknown.some(line => line.includes("0 Enabled") || line.includes("Warning"))); + assert.match(details[0]!, /Slate does not validate MCP/); + assert.match(details[0]!, /Use \/mcp/); }); -test("a missing MCP host snapshot renders a warning instead of a config error", async t => { - const root = await mkdtemp(join(tmpdir(), "slate-mcp-warning-")); +test("readable MCP declarations render without a host adapter; actual file failures remain visible", async t => { + const root = await mkdtemp(join(tmpdir(), "slate-mcp-configured-")); t.after(() => rm(root, { recursive: true, force: true })); - await writeFile(join(root, "mcp.json"), JSON.stringify({ mcpServers: { docs: { command: "server" } } })); + await writeFile(join(root, "mcp.json"), '\uFEFF{"mcpServers":{"docs":{"type":"future-transport"}}}'); const snapshot = new SidebarMcpFiles().snapshot(root, root, false, []); const { sidebar } = fixture(); sidebar.setResources({ skills: [], commands: [], prompts: [], ...snapshot }); const lines = plain(sidebar.render(80)); assert.equal(snapshot.mcpError, undefined); - assert.equal(snapshot.mcp[0]!.enabled, null); - assert.match(snapshot.mcpWarning!, /unavailable/); - assert(lines.some(line => line.includes("MCP · — · 1 Warning"))); - assert(!lines.some(line => line.includes("Error") || line.includes("0 Enabled") || line.includes("validation unavailable"))); + assert.equal(snapshot.mcp[0]!.enabled, true); + assert(lines.some(line => line.includes("MCP · 1 Enabled"))); + assert(!lines.some(line => /Error|Warning/.test(line))); + sidebar.setResources({ skills: [], commands: [], prompts: [], ...snapshot, mcpError: "MCP config unreadable" }); + assert(plain(sidebar.render(80)).some(line => line.includes("MCP · 1 Enabled · 1 Error"))); }); test("reset discards old titles, images, task rows and hit targets without changing fold preference", () => { diff --git a/pi-slate/tests/sidebar-split.test.ts b/pi-slate/tests/sidebar-split.test.ts index 795660ef..1405fb52 100644 --- a/pi-slate/tests/sidebar-split.test.ts +++ b/pi-slate/tests/sidebar-split.test.ts @@ -72,7 +72,7 @@ test("a later root writer wins once and disposal never restores over it", () => test("yield does not overwrite a successor's setter wrapper", () => { const host = createHost({ id: "chat" }); const dispose = bindSplitHost(host, wrap("pane"), unwrap); - const slateSet = host.setLayoutRoot; + const slateSet = host.setLayoutRoot.bind(host); const successorSet = (component: Node | undefined) => slateSet(component); host.setLayoutRoot = successorSet; const foreign = { id: "foreign" }; @@ -83,3 +83,31 @@ test("yield does not overwrite a successor's setter wrapper", () => { assert.equal(host.setLayoutRoot, successorSet); assert.equal(host.layoutRoot, foreign); }); + + +test("leases do not patch method slots, including facades returning a fresh forwarding function on every read", () => { + const raw = createHost({ id: "chat" }); + const setter = raw.setLayoutRoot; + const host = new Proxy(raw, { + get(target, key) { + const value = Reflect.get(target, key); + return typeof value === "function" ? (...args: unknown[]) => Reflect.apply(value, target, args) : value; + }, + }); + let yielded = 0; + for (let i = 0; i < 3; i++) { + const lease = bindSplitHost(host, wrap("slate"), unwrap, () => { yielded++; }); + assert.equal(raw.setLayoutRoot, setter); + assert.equal(lease.active(), true); + lease(); + assert.equal(raw.layoutRoot?.id, "chat"); + assert.equal(raw.setLayoutRoot, setter); + } + const lease = bindSplitHost(host, wrap("slate"), unwrap, () => { yielded++; }); + host.setLayoutRoot({ id: "successor" }); + assert.equal(lease.active(), false); + assert.equal(lease.active(), false); + assert.equal(yielded, 1); + lease(); + assert.equal(raw.layoutRoot?.id, "successor"); +}); diff --git a/pi-slate/tests/stock-notices.test.ts b/pi-slate/tests/stock-notices.test.ts deleted file mode 100644 index 1262357d..00000000 --- a/pi-slate/tests/stock-notices.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { Container, Spacer, Text } from "@earendil-works/pi-tui"; -import { MessageWindow } from "../extensions/pi-slate/message-window.ts"; -import { - installNewSessionNoticeFilter, - isStockNewSessionNotice, -} from "../extensions/pi-slate/stock-notices.ts"; - -// Core handleClearCommand adds Spacer(1) then ThemedText themed via accent. -function addStockNewSessionBanner(chat: Container): void { - chat.addChild(new Spacer(1)); - chat.addChild(new Text("\x1b[38;5;1m✓ New session started\x1b[0m", 1, 1)); -} - -test("matches the stock new-session notice but not ordinary chat", () => { - assert.equal(isStockNewSessionNotice(new Text("\x1b[38;5;1m✓ New session started\x1b[0m", 1, 1)), true); - assert.equal(isStockNewSessionNotice(new Text("✓ New session started", 1, 1)), true); - assert.equal(isStockNewSessionNotice(new Text("✓ New session started", 0, 0)), false); - assert.equal(isStockNewSessionNotice(new Text("I typed ✓ New session started myself", 0, 0)), false); - assert.equal(isStockNewSessionNotice(new Text("New session started", 0, 0)), false); - assert.equal(isStockNewSessionNotice(new Spacer(1)), false); -}); - -test("filter suppresses the stock new-session banner and its padding spacer", () => { - const chat = new Container(); - const keep = new Text("hello", 0, 0); - chat.addChild(keep); - installNewSessionNoticeFilter(chat); - addStockNewSessionBanner(chat); - assert.deepEqual(chat.children, [keep]); - // A banner without its spacer is rejected without touching neighbors. - chat.addChild(new Text("✓ New session started", 1, 1)); - assert.deepEqual(chat.children, [keep]); -}); - -test("filter keeps unrelated text and installs once", () => { - const chat = new Container(); - installNewSessionNoticeFilter(chat); - const wrapped = chat.addChild; - installNewSessionNoticeFilter(chat); - assert.equal(chat.addChild, wrapped); - const mention = new Text("notes about ✓ New session started", 0, 0); - chat.addChild(mention); - assert.deepEqual(chat.children, [mention]); -}); - -test("filter composes with the message window in either install order", () => { - // Filter first, then MessageWindow wraps it. - const first = new Container(); - installNewSessionNoticeFilter(first); - const window = new MessageWindow(first, 100); - addStockNewSessionBanner(first); - const reply = new Text("answer", 0, 0); - first.addChild(reply); - assert.deepEqual(first.children, [reply]); - window.dispose(); - // The filter survives MessageWindow disposal. - addStockNewSessionBanner(first); - assert.deepEqual(first.children, [reply]); - - // MessageWindow first, then the filter wraps it. - const second = new Container(); - const early = new MessageWindow(second, 100); - installNewSessionNoticeFilter(second); - addStockNewSessionBanner(second); - const other = new Text("user", 0, 0); - second.addChild(other); - assert.deepEqual(second.children, [other]); - early.dispose(); - addStockNewSessionBanner(second); - assert.deepEqual(second.children, [other]); - - // If addChild is replaced, a later install re-wraps. - const third = new Container(); - const raw = third.addChild.bind(third); - installNewSessionNoticeFilter(third); - third.addChild = raw; - installNewSessionNoticeFilter(third); - addStockNewSessionBanner(third); - assert.deepEqual(third.children, []); - - const fourth = new Container(); - const stop = installNewSessionNoticeFilter(fourth); - stop(); - addStockNewSessionBanner(fourth); - assert.equal(fourth.children.length, 2); -}); diff --git a/pi-slate/tests/surfaces-install.test.ts b/pi-slate/tests/surfaces-install.test.ts index 0b97baea..57c7d085 100644 --- a/pi-slate/tests/surfaces-install.test.ts +++ b/pi-slate/tests/surfaces-install.test.ts @@ -24,7 +24,7 @@ const editorTheme = { borderColor: (text: string) => text } as EditorTheme; type Handler = (event: ExtensionEvent, ctx: ExtensionContext) => unknown; -async function fixture(t: TestContext, text: string | undefined, mode = "tui", compatible = true, conflict = false) { +async function fixture(t: TestContext, text: string | undefined, mode = "tui", compatible = true, conflict = false, configure?: (pi: ExtensionAPI, ui: ExtensionContext["ui"]) => void) { const cwd = await mkdtemp(join(tmpdir(), "slate-surfaces-")); const previous = { dir: process.env.PI_CODING_AGENT_DIR, offline: process.env.PI_OFFLINE }; process.env.PI_CODING_AGENT_DIR = cwd; @@ -40,6 +40,7 @@ async function fixture(t: TestContext, text: string | undefined, mode = "tui", c if (text !== undefined) await writeFile(path, text); const calls: string[] = []; const notices: string[] = []; + const notifications: Array<{ message: string; kind: string }> = []; const commands = new Map Promise }>(); const tools: ToolDefinition[] = []; const handlers = new Map(); @@ -92,7 +93,7 @@ async function fixture(t: TestContext, text: string | undefined, mode = "tui", c const ui = { theme, getTheme: (name: string) => ({ ...theme, name }), - notify: (message: string) => notices.push(message), + notify: (message: string, kind = "info") => { notices.push(message); notifications.push({ message, kind }); }, setHeader: (factory: ((tui: TUI, theme: Theme) => Component) | undefined) => { assert.ok(factory); calls.push("header"); header = factory(tui, theme); }, setFooter: (factory: (() => Component) | undefined) => { assert.ok(factory); calls.push("footer"); assert.deepEqual(factory().render(80), []); }, setEditorComponent: (factory: ((tui: TUI, theme: EditorTheme, keys: KeybindingsManager) => Editor) | undefined) => { @@ -135,6 +136,7 @@ async function fixture(t: TestContext, text: string | undefined, mode = "tui", c return Reflect.get(target, key, receiver); }, }); + configure?.(pi, ui as unknown as ExtensionContext["ui"]); piSlate(pi); const emit = async (type: string, extra: Record = {}, context = ctx) => { let result: unknown; @@ -147,7 +149,7 @@ async function fixture(t: TestContext, text: string | undefined, mode = "tui", c assert.equal(Editor.prototype.insertTextAtCursor, originalInsert); assert.equal(Reflect.get(Editor.prototype, "handlePaste"), originalPaste); assert.equal(foreign.insertTextAtCursor, foreignInsert); - return { pi, invalidate: () => { stale = true; }, calls, notices, commands, tools, shortcuts, emit, ctx, path, imagePath, chat, root, tui, composer, foreign, choices, inspected, extraCommands, bus, manager, draft: () => draft, eventBus: pi.events, header: () => header, setForeignEditor: () => { editorFactory = () => foreign; } }; + return { pi, invalidate: () => { stale = true; }, calls, notices, notifications, commands, tools, shortcuts, emit, ctx, path, imagePath, chat, root, tui, composer, foreign, choices, inspected, extraCommands, bus, manager, draft: () => draft, eventBus: pi.events, header: () => header, setForeignEditor: () => { editorFactory = () => foreign; } }; } for (const surfaces of [[], ["editor"], [...FRESH_SURFACES], [...SURFACES], ["header"], ["tool-cards"], ["sidebar"], ["transcript"], ["footer"]] as Surface[][]) { @@ -162,7 +164,8 @@ for (const surfaces of [[], ["editor"], [...FRESH_SURFACES], [...SURFACES], ["he assert.deepEqual(f.tools.map((tool) => tool.name).sort(), has(surfaces, "tool-cards") ? ["edit", "write"] : []); assert.equal(f.commands.has("exit"), true); assert.equal(f.commands.has("prompts"), has(surfaces, "editor")); - assert.equal(f.chat.children.length, has(surfaces, "transcript") ? 100 : 105); + assert.equal(f.chat.children.length, 105, "the host's canonical message collection is never trimmed"); + assert.equal(f.chat.render(80).length, has(surfaces, "transcript") ? 100 : 105); const message = { role: "assistant", content: [{ type: "thinking", thinking: "reasoning" }] }; await f.emit("agent_start"); await f.emit("message_update", { message, assistantMessageEvent: { type: "thinking_start", contentIndex: 0 } }); @@ -222,11 +225,13 @@ test('toolStyle "default" restores Slate cards and legacy read grouping', async assert.equal(JSON.parse(await readFile(f.path, "utf8")).toolStyle, "default"); }); -test("Claude mode still warns about lost edit/write registrations, not native read", async (t) => { +test("lost optional styling registrations are diagnostic-only and never blame native read", async (t) => { const f = await fixture(t, JSON.stringify({ version: 1, surfaces: ["tool-cards"] }), "tui", true, true); - const notice = f.notices.find((value) => value.includes("tool-cards unavailable")); - assert.match(notice ?? "", /edit, write/); - assert.doesNotMatch(notice ?? "", /read/); + assert.deepEqual(f.notifications, []); + await f.commands.get("slate")!.handler("diagnostics", f.ctx); + const report = f.inspected.join("\n"); + assert.match(report, /Host registrations retained for edit, write/); + assert.doesNotMatch(report, /retained for.*read/); }); test("unselected cards ignore Claude tool style entirely", async (t) => { @@ -340,11 +345,15 @@ for (const mode of ["print", "rpc", "json"]) { }); } -test("incompatible hosts report unavailable without fullscreen or overlay writes", async (t) => { - const f = await fixture(t, JSON.stringify({ surfaces: ["sidebar", "transcript"], focused: false }), "tui", false); - assert.match(f.notices.join("\n"), /sidebar unavailable/); - assert.match(f.notices.join("\n"), /transcript unavailable/); +test("unsupported optional UI capabilities are quiet, preserve settings and explain fallbacks on demand", async (t) => { + const text = JSON.stringify({ surfaces: ["sidebar", "transcript"], focused: false }); + const f = await fixture(t, text, "tui", false); + assert.deepEqual(f.notifications, []); assert.equal(f.chat.children.length, 105); + await f.commands.get("slate")!.handler("diagnostics", f.ctx); + assert.match(f.inspected.join("\n"), /sidebar: fallback/); + assert.match(f.inspected.join("\n"), /transcript: fallback/); + assert.equal(await readFile(f.path, "utf8"), text); }); test("hidden sidebar stays quiet on an incompatible host", async (t) => { @@ -352,9 +361,11 @@ test("hidden sidebar stays quiet on an incompatible host", async (t) => { assert.equal(f.notices.some((notice) => /sidebar unavailable/.test(notice)), false); }); -test("tool-cards reports first-registration losses without conflict repair", async (t) => { +test("tool ownership diagnostics preserve first-registration wins without repair or startup alarms", async (t) => { const f = await fixture(t, JSON.stringify({ surfaces: ["tool-cards"] }), "tui", true, true); - assert.match(f.notices.join("\n"), /tool-cards unavailable.*first registration wins/); + assert.deepEqual(f.notifications, []); + await f.commands.get("slate")!.handler("diagnostics", f.ctx); + assert.match(f.inspected.join("\n"), /first registration wins/); }); test("real host below-editor widget renderer adds no rows for the TUI handle", () => { @@ -830,3 +841,110 @@ for (const state of ["live", "invalidated", "replaced"] as const) { assert.deepEqual(f.notices.slice(notices), state === "live" ? ["Could not open file"] : []); }); } + + +test("a missing optional renderer hook uses default cards without startup alarms", async t => { + const f = await fixture(t, JSON.stringify({ surfaces: ["tool-cards"] }), "tui", true, false, pi => { + Reflect.set(pi, "registerToolRenderer", undefined); + }); + assert.deepEqual(f.notifications, []); + assert.deepEqual(f.tools.map(tool => tool.name).sort(), ["edit", "read", "write"]); + await f.commands.get("slate")!.handler("diagnostics", f.ctx); + assert.match(f.inspected.join("\n"), /tool-style: fallback/); +}); + +test("a throwing sidebar adapter cannot prevent the editor, header or transcript from installing", async t => { + t.mock.method(Sidebar.prototype, "attach", () => { throw new Error("SECRET private adapter error"); }); + const f = await fixture(t, JSON.stringify({ surfaces: ["header", "editor", "sidebar", "transcript"], focused: false })); + assert(f.calls.includes("header")); assert(f.calls.includes("editor")); + assert.equal(f.chat.render(80).length, 100); + assert.deepEqual(f.notifications, []); + await f.commands.get("slate")!.handler("diagnostics", f.ctx); + const report = f.inspected.join("\n"); + assert.match(report, /sidebar: fallback.*adapter failed/); + assert.doesNotMatch(report, /SECRET|private adapter error/); +}); + +test("a failing optional widget mount preserves the native root and unrelated editor/header surfaces", async t => { + const f = await fixture(t, JSON.stringify({ surfaces: ["header", "editor", "sidebar"] }), "tui", true, false, (_pi, ui) => { + ui.setWidget = () => { throw new Error("SECRET widget implementation"); }; + }); + assert(f.calls.includes("editor")); assert(f.calls.includes("header")); + assert.equal(Reflect.get(f.tui, "layoutRoot"), f.root); + assert.deepEqual(f.notifications, []); + await f.commands.get("slate")!.handler("diagnostics", f.ctx); + assert.match(f.inspected.join("\n"), /surface-host: fallback/); + assert.doesNotMatch(f.inspected.join("\n"), /SECRET/); +}); + +test("a throwing host catalog does not interrupt setup or leak exception details", async t => { + const f = await fixture(t, JSON.stringify({ surfaces: ["editor", "sidebar"] }), "tui", true, false, pi => { + pi.getCommands = () => { throw new Error("SECRET command catalog"); }; + }); + assert(f.composer); assert.deepEqual(f.notifications, []); + await f.commands.get("slate")!.handler("diagnostics", f.ctx); + assert.match(f.inspected.join("\n"), /command-catalog: fallback/); + assert.doesNotMatch(f.inspected.join("\n"), /SECRET/); +}); + +test("real MCP file failures remain visible and have actionable on-demand details", async t => { + const f = await fixture(t, JSON.stringify({ surfaces: ["editor", "sidebar"], focused: false })); + await writeFile(join(dirname(f.path), "mcp.json"), "{broken SECRET"); + await f.emit("message_end", { message: { role: "user", content: "refresh" } }); + assert.match(railOf(f).render(80).join("\n"), /1 Error/); + await f.commands.get("slate")!.handler("diagnostics", f.ctx); + const report = f.inspected.join("\n"); + assert.match(report, /MCP config unreadable.*Check global mcp.json/); + assert.doesNotMatch(report, /SECRET/); +}); + +test("a Slate header never classifies or deletes host conversation content by its painted text", async t => { + const f = await fixture(t, JSON.stringify({ surfaces: ["header"] })); + const exact = new Text("✓ New session started", 1, 1); + const blank = new Text("", 0, 1); + f.chat.addChild(blank); f.chat.addChild(exact); + assert(f.chat.children.includes(exact)); assert(f.chat.children.includes(blank)); +}); + +test("production editor teardown unwinds selection before image adapters", async t => { + const f = await fixture(t, JSON.stringify({ surfaces: ["editor"] })); + const editor = f.composer!; + editor.insertTextAtCursor(f.imagePath); + assert.equal(editor.getText(), "[image-1]"); + editor.setText(""); + await f.emit("session_shutdown"); + editor.insertTextAtCursor(f.imagePath); + assert.equal(editor.getText(), f.imagePath, "a retained disposed editor must not retain image rewrite wrappers"); +}); + +test("a production grouping fault keeps windowing mounted across synchronization", async t => { + const f = await fixture(t, JSON.stringify({ surfaces: ["transcript", "tool-cards"] })); + let paints = 0; + const broken = { + toolCallId: "fault", toolName: "bash", expanded: false, isPartial: false, + result: { content: {} }, setExpanded() {}, + render() { paints++; return ["native card"]; }, invalidate() {}, + }; + f.chat.addChild(broken); + assert.equal(f.chat.render(80).length, 100); + assert.equal(paints, 1); + assert.equal(f.chat.children.length, 106); + const installedRender = f.chat.render, installedMouse = f.chat.handleMouse; + await f.emit("message_end", { message: { role: "user", content: "synchronize" } }); + assert.equal(f.chat.render, installedRender); assert.equal(f.chat.handleMouse, installedMouse); + assert.equal(f.chat.render(80).length, 100); + assert.equal(paints, 2); + assert.deepEqual(f.notifications, []); + await f.commands.get("slate")!.handler("diagnostics", f.ctx); + assert.match(f.inspected.join("\n"), /tool-groups: fallback/); + assert.match(f.inspected.join("\n"), /transcript: active/); +}); + +test("a changed optional command-catalog shape cannot abort unrelated surface setup", async t => { + const f = await fixture(t, JSON.stringify({ surfaces: ["editor", "sidebar"] }), "tui", true, false, pi => { + Reflect.set(pi, "getCommands", () => ({ commands: [] })); + }); + assert(f.composer); assert.deepEqual(f.notifications, []); + await f.commands.get("slate")!.handler("diagnostics", f.ctx); + assert.match(f.inspected.join("\n"), /command-catalog: fallback/); +}); diff --git a/pi-slate/tests/tool-groups.test.ts b/pi-slate/tests/tool-groups.test.ts index 1fa8388d..3afefbd7 100644 --- a/pi-slate/tests/tool-groups.test.ts +++ b/pi-slate/tests/tool-groups.test.ts @@ -1,9 +1,9 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { Container, visibleWidth, type Component, type TuiMouseEvent } from "@earendil-works/pi-tui"; +import { Container, Text, visibleWidth, type Component, type TuiMouseEvent } from "@earendil-works/pi-tui"; import { AssistantMessageComponent, initTheme, type Theme } from "@earendil-works/pi-coding-agent"; import { ToolGroups } from "../extensions/pi-slate/tool-groups.ts"; -import { MessageWindow } from "../extensions/pi-slate/message-window.ts"; +import { ChatSurface } from "../extensions/pi-slate/chat-surface.ts"; const theme = { fg: (_: string, text: string) => text, bold: (text: string) => text } as Theme; function mockTool(name = "bash") { @@ -20,12 +20,12 @@ function setup() { const chat = new Container(); const first = mockTool(), second = mockTool(); chat.addChild(first); chat.addChild(second); - const groups = new ToolGroups(chat, () => theme, () => "custom+o to expand"); - return { chat, first, second, groups }; + const surface = new ChatSurface(chat, { groups: new ToolGroups(() => theme, () => "custom+o to expand") }); + return { chat, first, second, surface }; } test("groups keep child identity, count failures/thoughts, and show configured hint", () => { - const { chat, first, second, groups } = setup(); + const { chat, first, second, surface } = setup(); const overrides = new Map(); const thought = { ...text("hidden thought"), hideThinkingBlock: true, thinkingVisibilityOverrides: overrides, lastMessage: { role: "assistant", content: [{ type: "thinking", thinking: "private" }, { type: "toolCall", name: "bash" }] } }; chat.children.splice(1, 0, thought); @@ -43,61 +43,61 @@ test("groups keep child identity, count failures/thoughts, and show configured h thought.hideThinkingBlock = false; assert.match(chat.render(80).join("\n"), /hidden thought/, "Ctrl+T must reveal native thinking"); assert.doesNotMatch(chat.render(80).join("\n"), /Done|Running/); - groups.dispose(); + surface.dispose(); }); test("expanded members, images, unknown tools, text and narrow terminals break folding", () => { for (const boundary of [text("answer"), mockTool("other"), { ...text("image"), ...mockTool(), result: { isError: false, content: [{ type: "image" }] } }]) { - const { chat, groups } = setup(); + const { chat, surface } = setup(); chat.children.splice(1, 0, boundary); assert.doesNotMatch(chat.render(80).join("\n"), /Done/); - groups.dispose(); + surface.dispose(); } - const { chat, first, groups } = setup(); + const { chat, first, surface } = setup(); first.expanded = true; assert.match(chat.render(80).join("\n"), /expanded output/); assert.doesNotMatch(chat.render(80).join("\n"), /Done/); first.expanded = false; assert.doesNotMatch(chat.render(10).join("\n"), /Done/); assert.ok(chat.render(20).every((line) => visibleWidth(line) <= 20)); - groups.dispose(); + surface.dispose(); }); test("mutations fold like any other tool, diff block included", () => { for (const name of ["edit", "write"]) { - const { chat, groups } = setup(); + const { chat, surface } = setup(); chat.children.splice(1, 0, mockTool(name)); const rows = chat.render(80).join("\n"); assert.match(rows, /Done \(3 tool calls\)/); assert.match(rows, /├─ ✓ Bash \(pwd\)/, "members keep their guides"); - groups.dispose(); + surface.dispose(); } }); test("groups retain their summary and guides without a theme", () => { const chat = new Container(); chat.addChild(mockTool()); chat.addChild(mockTool()); - const groups = new ToolGroups(chat, () => undefined, () => "expand"); + const surface = new ChatSurface(chat, { groups: new ToolGroups(() => undefined, () => "expand") }); const rows = chat.render(80); assert.match(rows.join("\n"), /✓ Done \(2 tool calls\) expand/); assert.ok(rows.some(row => row.startsWith("├─ "))); assert.ok(rows.some(row => row.startsWith("└─ "))); - groups.dispose(); + surface.dispose(); }); test("native response-length warnings remain a grouping boundary", () => { initTheme("dark", false); - const { chat, groups } = setup(); + const { chat, surface } = setup(); const message = new AssistantMessageComponent({ role: "assistant", stopReason: "length", content: [ { type: "toolCall", id: "a", name: "bash", arguments: { command: "pwd" } }, ] } as unknown as ConstructorParameters[0], true); chat.children.unshift(message); assert.match(chat.render(100).join("\n"), /Response was truncated before completion/); - groups.dispose(); + surface.dispose(); }); test("mouse routing preserves source row coordinates and expansion", () => { - const { chat, first, second, groups } = setup(); + const { chat, first, second, surface } = setup(); chat.render(80); const event = { type: "click", button: "left", x: 6, y: 2, height: 6, width: 80 } as TuiMouseEvent; assert.equal(chat.handleMouse(event)?.handled, true); @@ -108,24 +108,60 @@ test("mouse routing preserves source row coordinates and expansion", () => { chat.render(80); chat.handleMouse({ ...event, y: 1 }); assert.equal(first.clicks[1]!.x, 6); - groups.dispose(); + surface.dispose(); }); -test("cleanup restores only owned methods, composes with message window, no global patches", () => { - const { chat, groups } = setup(); - const other = new Container(); - assert.notEqual(chat.render, other.render); - const window = new MessageWindow(chat, 1); - assert.doesNotMatch(chat.render(80).join("\n"), /Done/); - window.setLimit(10); - assert.match(chat.render(80).join("\n"), /Done/); - window.dispose(); - groups.dispose(); groups.dispose(); - assert.equal(chat.render, other.render); - assert.equal(chat.handleMouse, other.handleMouse); - const next = setup(); - const successor = () => ["successor"]; - next.chat.render = successor; - next.groups.dispose(); - assert.equal(next.chat.render, successor); +test("one chat owner updates grouping and windowing without replacing its host methods again", () => { + const { chat, surface } = setup(); + const render = chat.render, mouse = chat.handleMouse; + const painter = new ToolGroups(() => theme); + for (const groupsFirst of [true, false]) { + surface.update(groupsFirst ? { groups: painter } : { limit: 1 }); + assert.equal(chat.render, render); assert.equal(chat.handleMouse, mouse); + surface.update({ groups: painter, limit: 1 }); + assert.doesNotMatch(chat.render(80).join("\n"), /Done/); + surface.update({ groups: painter, limit: 10 }); + assert.match(chat.render(80).join("\n"), /Done/); + surface.update({ limit: 1 }); + assert.doesNotMatch(chat.render(80).join("\n"), /Done/); + } + surface.dispose(); surface.dispose(); + assert.equal(chat.render, Container.prototype.render); + assert.equal(chat.handleMouse, Container.prototype.handleMouse); +}); + +test("a grouping painter never patches the host or mutates its canonical children", () => { + const chat = new Container(); + chat.addChild(mockTool()); chat.addChild(mockTool()); + const original = [...chat.children]; + const painter = new ToolGroups(() => undefined); + assert.match(painter.render(chat.children, 80).join("\n"), /Done/); + assert.equal(chat.render, Container.prototype.render); + assert.equal(chat.handleMouse, Container.prototype.handleMouse); + assert.deepEqual(chat.children, original); +}); + +test("grouping metadata faults retain the window and paint selected native children only once", () => { + const chat = new Container(), broken = mockTool(), normal = new Text("visible normal message", 0, 0); + let paints = 0, normalPaints = 0, faults = 0; + const render = broken.render.bind(broken); + broken.render = width => { paints++; return render(width); }; + const renderNormal = normal.render.bind(normal); + normal.render = width => { normalPaints++; return renderNormal(width); }; + chat.addChild(new Text("hidden older message", 0, 0)); chat.addChild(normal); chat.addChild(broken); + let groups: ToolGroups | undefined = new ToolGroups(() => undefined); + const onGroupingFault = () => { faults++; groups = undefined; }; + const surface = new ChatSurface(chat, { limit: 2, groups, onGroupingFault }); + Reflect.set(broken.result, "content", {}); + assert.doesNotMatch(chat.render(80).join("\n"), /hidden older message/); + assert.equal(normalPaints, 1, "metadata is checked before any selected native child paints"); + assert.equal(paints, 1, "the faulting frame paints the selected native child only once"); + assert.equal(faults, 1); + surface.update({ limit: 2, groups, onGroupingFault }); + assert.doesNotMatch(chat.render(80).join("\n"), /hidden older message/); + assert.equal(normalPaints, 2, "each later native frame paints once"); + assert.equal(paints, 2); + assert.equal(faults, 1, "synchronization does not retry a quarantined painter"); + assert.equal(chat.children.length, 3); + surface.dispose(); }); diff --git a/pi-slate/tests/tool-style-load.test.ts b/pi-slate/tests/tool-style-load.test.ts index e1de0726..8d8d2a3c 100644 --- a/pi-slate/tests/tool-style-load.test.ts +++ b/pi-slate/tests/tool-style-load.test.ts @@ -9,6 +9,7 @@ import { Container, visibleWidth, type TUI, type TuiMouseEvent } from "@earendil import { ToolExecutionComponent } from "../node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/tool-execution.js"; import { theme } from "../node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/theme/theme.js"; import { ToolGroups } from "../extensions/pi-slate/tool-groups.ts"; +import { ChatSurface } from "../extensions/pi-slate/chat-surface.ts"; test("default style loads locally and host preserves expansion, padding, mouse and restored results", async (t) => { const dir = await mkdtemp(join(tmpdir(), "slate-tool-host-")); @@ -36,8 +37,8 @@ test("default style loads locally and host preserves expansion, padding, mouse a host.updateResult(JSON.parse(JSON.stringify({ content: [{ type: "text", text: "unique full body\nsecond row" }], isError: false, durationMs: 0 }))); chat.addChild(host); return host; }); - const groups = new ToolGroups(chat, () => theme); - t.after(() => groups.dispose()); + const surface = new ChatSurface(chat, { groups: new ToolGroups(() => theme) }); + t.after(() => surface.dispose()); const plain = () => chat.render(80).map(stripVTControlCharacters).join("\n"); assert.match(plain(), /Done \(2 tool calls\)/); assert.match(plain(), /2 lines · 0ms/); diff --git a/pi-subagents/package-lock.json b/pi-subagents/package-lock.json index 495524a4..c889a25c 100644 --- a/pi-subagents/package-lock.json +++ b/pi-subagents/package-lock.json @@ -1966,9 +1966,9 @@ } }, "node_modules/undici": { - "version": "8.12.2", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.2.tgz", - "integrity": "sha512-XtuDWVdqTLr3NAxm2ly9/hU6kUsJOgGh1RASzMBbsw6aWCOaQzHdW+QQDfdERjfJ2V5vL5pRPa4RetO9UfivDg==", + "version": "8.12.4", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.4.tgz", + "integrity": "sha512-8Mx+uNKKER2bA4AdtedkqRbwUCIJFr2nJckXRe5yrDqzePqwLP7VNcctN8/XdmDSbF7mS+jJKIszlL+vbam1VA==", "dev": true, "license": "MIT", "engines": { diff --git a/pi-subagents/package.json b/pi-subagents/package.json index ebe8f193..75e2044e 100644 --- a/pi-subagents/package.json +++ b/pi-subagents/package.json @@ -74,7 +74,7 @@ "yaml": "2.9.1" }, "overrides": { - "undici": "8.12.2" + "undici": "8.12.4" }, "devDependencies": { "@earendil-works/pi-ai": "1.3.0", diff --git a/pi-web-search/package-lock.json b/pi-web-search/package-lock.json index 4e85a1be..5880fc33 100644 --- a/pi-web-search/package-lock.json +++ b/pi-web-search/package-lock.json @@ -1948,9 +1948,9 @@ } }, "node_modules/undici": { - "version": "8.12.2", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.2.tgz", - "integrity": "sha512-XtuDWVdqTLr3NAxm2ly9/hU6kUsJOgGh1RASzMBbsw6aWCOaQzHdW+QQDfdERjfJ2V5vL5pRPa4RetO9UfivDg==", + "version": "8.12.4", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.4.tgz", + "integrity": "sha512-8Mx+uNKKER2bA4AdtedkqRbwUCIJFr2nJckXRe5yrDqzePqwLP7VNcctN8/XdmDSbF7mS+jJKIszlL+vbam1VA==", "dev": true, "license": "MIT", "engines": { diff --git a/pi-web-search/package.json b/pi-web-search/package.json index c5137914..60b9d431 100644 --- a/pi-web-search/package.json +++ b/pi-web-search/package.json @@ -68,7 +68,7 @@ } }, "overrides": { - "undici": "8.12.2" + "undici": "8.12.4" }, "devDependencies": { "@earendil-works/pi-ai": "1.3.0", diff --git a/scripts/packages.mjs b/scripts/packages.mjs index 52dc26d9..91eb7a2f 100644 --- a/scripts/packages.mjs +++ b/scripts/packages.mjs @@ -12,7 +12,7 @@ export const packages = { }, "pi-slate": { name: "pi-slate", tests: "tests", - integration: ["install-defaults.test.ts", "sidebar-split.test.ts", "diff-tools.test.ts", "read.test.ts", "tool-groups.test.ts", "tool-style-load.test.ts", "git-diff.test.ts", "package.test.mjs", "prompts.test.ts"], + integration: ["install-defaults.test.ts", "sidebar-split.test.ts", "diff-tools.test.ts", "read.test.ts", "tool-groups.test.ts", "tool-style-load.test.ts", "host-compat.test.mjs", "git-diff.test.ts", "package.test.mjs", "prompts.test.ts"], commands: ["slate", "prompts", "exit"], tools: [], }, "pi-subagents": { diff --git a/scripts/tests/ci.test.mjs b/scripts/tests/ci.test.mjs index dd450bf3..7ae74efa 100644 --- a/scripts/tests/ci.test.mjs +++ b/scripts/tests/ci.test.mjs @@ -150,6 +150,19 @@ test("workflow wires routing, full sweeps, and unconditional dependency-free rel assert.match(workflow, / {2}secrets:\n {4}uses: .\/\.github\/workflows\/secret-scan.yml/); }); +test("Slate gates packed-artifact latest-host compatibility in PRs and daily upstream checks", () => { + const workflow = read("../../.github/workflows/ci.yml"); + assert.match(workflow, /if: matrix.package == 'pi-slate'\n\s+name: Real interactive compatibility with latest Pi and packed Slate\n\s+run: npm run test:compat:latest --prefix pi-slate/); + assert(workflow.indexOf("Build and smoke-test npm artifact") < workflow.indexOf("Real interactive compatibility with latest Pi")); + const daily = read("../../.github/workflows/slate-compat.yml"); + assert.match(daily, /schedule:\n\s+- cron: '19 6 \* \* \*'/); + assert.match(daily, /npm run test:compat --prefix pi-slate/); + assert.match(daily, /npm run build --prefix pi-slate/); + assert.match(daily, /npm run test:compat:latest --prefix pi-slate/); + assert.doesNotMatch(daily, /continue-on-error/); + assert(packages["pi-slate"].integration.includes("host-compat.test.mjs")); +}); + test("the matrix is exactly one combined leg per affected package", () => { assert.deepEqual(ciMatrix(["pi-ask"]), [{ package: "pi-ask" }]); assert.deepEqual(ciMatrix(["pi-ask", "pi-web-search"]), [{ package: "pi-ask" }, { package: "pi-web-search" }]); diff --git a/tests/herdr-insights/collector-lifecycle-gates.test.mjs b/tests/herdr-insights/collector-lifecycle-gates.test.mjs index 059b37f4..92a36387 100644 --- a/tests/herdr-insights/collector-lifecycle-gates.test.mjs +++ b/tests/herdr-insights/collector-lifecycle-gates.test.mjs @@ -353,7 +353,7 @@ test("configure during a slow worker fences the old generation and commits only } }); -test("presenter prepare/status stay fast, running and truthful while a worker holds the lock", async (t) => { +test("presenter prepare/status stay fast, running and truthful while a worker holds the lock", { skip: process.platform === "win32" && "requires POSIX worker suspension" }, async (t) => { const ctx = await context("insights-lifecycle-presenter-"); let workerPid = null; t.after(async () => { @@ -381,10 +381,14 @@ test("presenter prepare/status stay fast, running and truthful while a worker ho return info?.pid === workerPid ? info : null; }); assert.ok(owner, "worker owns the writer lock"); - await waitFor(async () => { - const store = await readStore(ctx.store); - return store?.scan?.status === "running" ? store : null; + const running = await waitFor(async () => { + const snapshot = await collectSnapshot("status", { store: ctx.store }); + return snapshot.scan.status === "running" && snapshot.scan.workerPid === workerPid ? snapshot : null; }, { timeoutMs: 5_000 }); + assert.ok(running, "the worker published its running state"); + // Preserve the writer lock while measuring readers, rather than racing a finite scan. + process.kill(workerPid, "SIGSTOP"); + assert.equal((await readStoreLockInfo(ctx.store))?.pid, workerPid); const samples = []; for (const command of ["prepare", "status", "prepare", "status", "prepare"]) { @@ -417,6 +421,9 @@ test("presenter prepare/status stay fast, running and truthful while a worker ho const median = [...samples].sort((a, b) => a - b)[Math.floor(samples.length / 2)]; assert.ok(median < 1_000, `median ${median}ms stays under the hard local limit`); } finally { - if (workerPid) await stopWorker(workerPid); + if (workerPid) { + try { process.kill(workerPid, "SIGCONT"); } catch { /* worker already exited */ } + await stopWorker(workerPid); + } } }); diff --git a/tests/herdr-insights/collector-worker-lifecycle.test.mjs b/tests/herdr-insights/collector-worker-lifecycle.test.mjs index 6be5b79e..978cc46f 100644 --- a/tests/herdr-insights/collector-worker-lifecycle.test.mjs +++ b/tests/herdr-insights/collector-worker-lifecycle.test.mjs @@ -373,6 +373,12 @@ test("promotion spawns one worker that waits out the launcher lock handoff", asy assert.equal(again.pid, worker.pid); assert.equal((await readWorkerClaim(ctx.store))?.token, worker.claim.token); + // Lock acknowledgement precedes the worker’s first committed status. + const published = await waitFor(async () => { + const status = await collectSnapshot("status", { store: ctx.store }); + return status.scan.workerPid === worker.pid ? status : null; + }); + assert.ok(published, "the worker publishes its running status"); const started = Date.now(); const status = await collectSnapshot("status", { store: ctx.store }); assert.ok(Date.now() - started < 1_000, "status stays non-blocking during the worker lock");