Skip to content

TUI extension widget factory receives a Proxy that re-resolves methods at call time — wrapping tui.render infinitely recurses (RangeError) #7731

Description

@iknowkungfubar

Summary

Since 0.84.0, the TUI handed to extension widget factories (ctx.ui.setWidget(key, (tui) => ...)) is the stable-reference Proxy from createInteractiveTuiReference, whose get-trap returns method wrappers that re-resolve the method at call time. This breaks any extension that wraps a TUI method (e.g. pi-spark's fullscreen BottomFiller wraps tui.render): the captured "original" wrapper resolves back to the wrapped method → infinite recursion → RangeError: Maximum call stack size exceeded on the very first render pass. pi is unusable until the extension is removed from settings.

Confirmed crash: pi-spark 0.21.1 (latest) + pi 0.84.0. pi-spark has no fix; this is a regression in 0.84.0 (the proxy is new — widget factories previously received the raw TuiMainScreen).

Environment

  • pi @earendil-works/pi-coding-agent@0.84.0
  • @earendil-works/pi-tui@0.84.0
  • pi-spark 0.21.1 (any version with the fullscreen feature)
  • Node v22.23.2, Linux

Repro (minimal standalone extension, no pi-spark needed)

import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";

export default function (pi: ExtensionAPI) {
  pi.on("session_start", (_event, ctx) => {
    if (ctx.mode !== "tui") return;
    ctx.ui.setWidget("wrap-repro", (tui) => {
      const originalRender = tui.render; // get trap -> wrapper that re-resolves at call time
      tui.render = (width: number) => {
        const lines = originalRender(width); // resolves to this wrapper -> infinite recursion
        return lines;
      };
      return { render: () => [], invalidate: () => {}, dispose: () => {} };
    });
  });
}

Run pi in a terminal → crashes on the first render pass.

Actual

pi exiting due to uncaughtException:
RangeError: Maximum call stack size exceeded
    at tui.render (wrap-repro.ts:16:23)
    at tui.render (wrap-repro.ts:16:23)
    ... (repeats)

With pi-spark 0.21.1 the same crash looks like:

RangeError: Maximum call stack size exceeded
    at TuiMainScreen.patchedTuiRender [as render] (pi-spark/.../filler.ts:44:21)
    at Proxy.<anonymous> (interactive-mode.js:189:32)
    at TuiMainScreen.patchedTuiRender [as render] (pi-spark/.../filler.ts:44:39)
    at Proxy.<anonymous> (interactive-mode.js:189:32)
    ... (repeats)

Expected

An extension can wrap tui.render (or any TUI method) and delegate to the original, as extensions could before 0.84.0.

Root cause

dist/modes/interactive/interactive-mode.js:177:

export function createInteractiveTuiReference(getTui) {
    return new Proxy({}, {
        get: (_target, property) => {
            const tui = getTui();
            const value = Reflect.get(tui, property, tui);
            if (typeof value !== "function")
                return value;
            return (...args) => {
                const tui = getTui();
                const method = Reflect.get(tui, property, tui); // re-resolves at CALL time
                ...
                return Reflect.apply(method, tui, args);
            };
        },
        set: (_target, property, value) => {
            const tui = getTui();
            return Reflect.set(tui, property, value, tui); // writes to the raw renderer
        },
        ...
    });
}

Sequence:

  1. Extension captures tui.render → get-trap returns wrapper W.
  2. Extension sets tui.render = wrapped → set-trap writes wrapped onto the raw renderer's render.
  3. Next render (the TUI's internal this.render(width) at tui-main-screen.js:170, or pi's own this.ui.render(width) at interactive-mode.js:5218) invokes wrapped.
  4. wrapped calls the captured wrapper WW re-resolves renderer.render → gets wrapped → calls it → step 3 again. Infinite recursion.

The get-trap even captures const value = Reflect.get(tui, property, tui) and then ignores it for functions — the call-time re-resolution is deliberate (renderer can be swapped at runtime in 0.84.0's new regular↔fullscreen switching), but it makes the proxy unusable for method-wrapping extensions.

Suggested fix

Capture the method and TUI instance at access time instead of call time. Every call site inside interactive-mode uses a fresh this.ui.<method>() property access, so per-access capture still routes to the current renderer after a swap — nothing in pi relies on call-time re-resolution:

get: (_target, property) => {
    const tui = getTui();
    const value = Reflect.get(tui, property, tui);
    if (typeof value !== "function")
        return value;
    return (...args) => Reflect.apply(value, tui, args);
},

(Verified: with this change, the minimal repro above and pi-spark 0.21.1 both boot and render cleanly in regular and --tui-mode fullscreen, and pi's own fullscreen↔regular switching keeps working.)

Alternative: pass the raw renderer (not the proxy) to extension widget factories in setExtensionWidget (component = content(this.ui, theme) at interactive-mode.js:1654), since extensions mount once per session.

Workaround (until fixed)

Apply the access-time-capture change from the Suggested fix section to interactive-mode.js locally, or disable the offending extension. In pi-spark's case: remove "npm:pi-spark" from ~/.pi/agent/settings.json (or disable the fullscreen feature).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions