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:
- Extension captures
tui.render → get-trap returns wrapper W.
- Extension sets
tui.render = wrapped → set-trap writes wrapped onto the raw renderer's render.
- 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.
wrapped calls the captured wrapper W → W 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).
Summary
Since 0.84.0, the TUI handed to extension widget factories (
ctx.ui.setWidget(key, (tui) => ...)) is the stable-reference Proxy fromcreateInteractiveTuiReference, 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 fullscreenBottomFillerwrapstui.render): the captured "original" wrapper resolves back to the wrapped method → infinite recursion →RangeError: Maximum call stack size exceededon 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
@earendil-works/pi-coding-agent@0.84.0@earendil-works/pi-tui@0.84.0Repro (minimal standalone extension, no pi-spark needed)
Run
piin a terminal → crashes on the first render pass.Actual
With pi-spark 0.21.1 the same crash looks like:
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:Sequence:
tui.render→ get-trap returns wrapper W.tui.render = wrapped→ set-trap writeswrappedonto the raw renderer'srender.this.render(width)attui-main-screen.js:170, or pi's ownthis.ui.render(width)atinteractive-mode.js:5218) invokeswrapped.wrappedcalls the captured wrapper W → W re-resolvesrenderer.render→ getswrapped→ 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:(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)atinteractive-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.jslocally, or disable the offending extension. In pi-spark's case: remove"npm:pi-spark"from~/.pi/agent/settings.json(or disable the fullscreen feature).