Merge pull request #4150 from jeremymcs/claude/debug-tui-auto-mode-vCnxA

Split Container.clear() into clear() and detachChildren()
This commit is contained in:
Jeremy McSpadden 2026-04-13 18:39:33 -05:00 committed by GitHub
commit fc7d195e09
5 changed files with 63 additions and 3 deletions

View file

@ -1402,7 +1402,9 @@ export class InteractiveMode {
// widgetContainerAbove: spacer collapses when pinned content is visible
// so there's no extra blank line between pinned output and the editor border.
this.widgetContainerAbove.clear();
// Use detachChildren() (not clear()) — the extensionWidgetsAbove map owns
// disposal; clear() would dispose every mounted widget on every re-render.
this.widgetContainerAbove.detachChildren();
const pinned = this.pinnedMessageContainer;
this.widgetContainerAbove.addChild({
render: () => pinned.children.length > 0 ? [] : [""],
@ -1422,7 +1424,9 @@ export class InteractiveMode {
spacerWhenEmpty: boolean,
leadingSpacer: boolean,
): void {
container.clear();
// Detach without disposing — the widgets map owns lifecycle; disposing
// here would kill refresh timers and subscriptions on every re-render.
container.detachChildren();
if (widgets.size === 0) {
if (spacerWhenEmpty) {

View file

@ -1,7 +1,8 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { TUI } from "../tui.js";
import { Container, TUI } from "../tui.js";
import type { Component } from "../tui.js";
import type { Terminal } from "../terminal.js";
function makeTerminal(): Terminal {
@ -48,3 +49,39 @@ describe("TUI", () => {
assert.equal(anyTui.inputBuffer, "");
});
});
describe("Container", () => {
function makeDisposableChild(counter: { disposed: number }): Component & { dispose(): void } {
return {
render: () => [],
invalidate() {},
dispose() {
counter.disposed++;
},
};
}
it("detachChildren() removes children without disposing them", () => {
const c = new Container();
const counter = { disposed: 0 };
c.addChild(makeDisposableChild(counter));
c.addChild(makeDisposableChild(counter));
c.detachChildren();
assert.equal(c.children.length, 0);
assert.equal(counter.disposed, 0);
});
it("clear() still disposes children (regression guard for detach/dispose split)", () => {
const c = new Container();
const counter = { disposed: 0 };
c.addChild(makeDisposableChild(counter));
c.addChild(makeDisposableChild(counter));
c.clear();
assert.equal(c.children.length, 0);
assert.equal(counter.disposed, 2);
});
});

View file

@ -197,6 +197,17 @@ export class Container implements Component {
this._prevRender = null;
}
/**
* Remove all children without calling dispose on them.
* Use when child lifecycle is owned elsewhere and the container is only a
* render mount (e.g. extension widget containers in InteractiveMode, where
* the extensionWidgets* maps own disposal).
*/
detachChildren(): void {
this.children = [];
this._prevRender = null;
}
invalidate(): void {
for (const child of this.children) {
child.invalidate?.();

View file

@ -806,6 +806,9 @@ export async function bootstrapAutoSession(
ctx.ui.setStatus("gsd-auto", s.stepMode ? "next" : "auto");
ctx.ui.setFooter(hideFooter);
// Hide gsd-health during AUTO — gsd-progress is the single source of truth
// for last-commit / cost / health signal while auto is running.
ctx.ui.setWidget("gsd-health", undefined);
const modeLabel = s.stepMode ? "Step-mode" : "Auto-mode";
const pendingCount = (state.registry ?? []).filter(
(m) => m.status !== "complete" && m.status !== "parked",

View file

@ -199,6 +199,7 @@ import {
postUnitPostVerification,
} from "./auto-post-unit.js";
import { bootstrapAutoSession, openProjectDbIfPresent, type BootstrapDeps } from "./auto-start.js";
import { initHealthWidget } from "./health-widget.js";
import { autoLoop, resolveAgentEnd, resolveAgentEndCancelled, _resetPendingResolve, isSessionSwitchInFlight, type LoopDeps, type ErrorContext } from "./auto-loop.js";
// Slice-level parallelism (#2340)
import { getEligibleSlices } from "./slice-parallel-eligibility.js";
@ -650,6 +651,7 @@ function handleLostSessionLock(
ctx?.ui.setStatus("gsd-auto", undefined);
ctx?.ui.setWidget("gsd-progress", undefined);
ctx?.ui.setFooter(undefined);
if (ctx) initHealthWidget(ctx);
}
/**
@ -684,6 +686,7 @@ function cleanupAfterLoopExit(ctx: ExtensionContext): void {
ctx.ui.setStatus("gsd-auto", undefined);
ctx.ui.setWidget("gsd-progress", undefined);
ctx.ui.setFooter(undefined);
initHealthWidget(ctx);
}
// Restore CWD out of worktree back to original project root
@ -943,6 +946,7 @@ export async function stopAuto(
ctx?.ui.setStatus("gsd-auto", undefined);
ctx?.ui.setWidget("gsd-progress", undefined);
ctx?.ui.setFooter(undefined);
if (ctx) initHealthWidget(ctx);
restoreProjectRootEnv();
restoreMilestoneLockEnv();
@ -1044,6 +1048,7 @@ export async function pauseAuto(
ctx?.ui.setStatus("gsd-auto", "paused");
ctx?.ui.setWidget("gsd-progress", undefined);
ctx?.ui.setFooter(undefined);
if (ctx) initHealthWidget(ctx);
const resumeCmd = s.stepMode ? "/gsd next" : "/gsd auto";
ctx?.ui.notify(
`${s.stepMode ? "Step" : "Auto"}-mode paused (Escape). Type to interact, or ${resumeCmd} to resume.`,