From 7682e42aa1281634dd53cccb5c9988d5ff6ba51f Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Thu, 20 Aug 2026 20:41:02 +0800 Subject: [PATCH 1/2] fix: batch Resume UI layer installation --- extensions/shared/editor-layers.test.ts | 130 ++++++++++++++++++++ extensions/shared/editor-layers.ts | 150 ++++++++++++++++++++++++ extensions/subagents/index.ts | 48 ++++---- extensions/suggestions/index.ts | 45 ++++--- extensions/tasks/index.test.ts | 71 +++++++++-- extensions/tasks/index.ts | 32 +++-- extensions/workflows/index.ts | 48 ++++---- 7 files changed, 446 insertions(+), 78 deletions(-) create mode 100644 extensions/shared/editor-layers.test.ts create mode 100644 extensions/shared/editor-layers.ts diff --git a/extensions/shared/editor-layers.test.ts b/extensions/shared/editor-layers.test.ts new file mode 100644 index 00000000..185ffbe3 --- /dev/null +++ b/extensions/shared/editor-layers.test.ts @@ -0,0 +1,130 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { + ExtensionAPI, + ExtensionContext, + ExtensionFactory, +} from "@earendil-works/pi-coding-agent"; +import subagents from "../subagents/index.ts"; +import suggestions from "../suggestions/index.ts"; +import workflows from "../workflows/index.ts"; + +type EditorFactory = NonNullable< + ReturnType +>; + +function createEventBus() { + const handlers = new Map void>>(); + return { + emit(channel: string, data: unknown) { + for (const handler of handlers.get(channel) ?? []) handler(data); + }, + on(channel: string, handler: (data: unknown) => void) { + const listeners = handlers.get(channel) ?? new Set(); + listeners.add(handler); + handlers.set(channel, listeners); + return () => listeners.delete(handler); + }, + }; +} + +function editorLifecycleHarness() { + const lifecycle = new Map< + string, + Array<(event: unknown, ctx: ExtensionContext) => unknown> + >(); + const events = createEventBus(); + let editorFactory: EditorFactory | undefined; + let editorWrites = 0; + + const load = (factory: ExtensionFactory) => { + let activeTools: string[] = []; + const api = { + events, + on(event: string, handler: unknown) { + lifecycle.set(event, [ + ...(lifecycle.get(event) ?? []), + handler as (event: unknown, ctx: ExtensionContext) => unknown, + ]); + }, + registerTool(tool: { name: string }) { + activeTools = [ + ...activeTools.filter((name) => name !== tool.name), + tool.name, + ]; + }, + getActiveTools: () => [...activeTools], + setActiveTools(names: string[]) { + activeTools = [...names]; + }, + getAllTools: () => [], + registerCommand() {}, + registerMessageRenderer() {}, + registerEntryRenderer() {}, + getThinkingLevel: () => "off", + sendMessage() {}, + appendEntry() {}, + } as unknown as ExtensionAPI; + factory(api); + }; + + load(subagents); + load(suggestions); + load(workflows); + + const ctx = { + cwd: process.cwd(), + mode: "tui", + hasUI: true, + isIdle: () => true, + isProjectTrusted: () => false, + sessionManager: { + getLeafId: () => "leaf", + getBranch: () => [], + getSessionId: () => "session", + getEntries: () => [], + }, + ui: { + theme: { fg: (_color: string, text: string) => text }, + getEditorComponent: () => editorFactory, + setEditorComponent(factory: EditorFactory | undefined) { + editorFactory = factory; + editorWrites += 1; + }, + setStatus() {}, + setWidget() {}, + notify() {}, + }, + } as unknown as ExtensionContext; + + const emit = async (event: string) => { + for (const handler of lifecycle.get(event) ?? []) { + await handler({ type: event }, ctx); + } + }; + + return { + emit, + editorFactory: () => editorFactory, + editorWrites: () => editorWrites, + }; +} + +test("one session binds all OpenPI editor layers with one UI write", async () => { + const first = editorLifecycleHarness(); + await first.emit("session_start"); + await new Promise((resolve) => setTimeout(resolve, 10)); + + assert.equal(first.editorWrites(), 1); + assert.equal(typeof first.editorFactory(), "function"); + + await first.emit("session_shutdown"); + + const resumed = editorLifecycleHarness(); + await resumed.emit("session_start"); + await new Promise((resolve) => setTimeout(resolve, 10)); + + assert.equal(resumed.editorWrites(), 1); + assert.equal(typeof resumed.editorFactory(), "function"); + assert.notEqual(resumed.editorFactory(), first.editorFactory()); +}); diff --git a/extensions/shared/editor-layers.ts b/extensions/shared/editor-layers.ts new file mode 100644 index 00000000..20ee5868 --- /dev/null +++ b/extensions/shared/editor-layers.ts @@ -0,0 +1,150 @@ +import { + CustomEditor, + type ExtensionAPI, + type ExtensionContext, + type KeybindingsManager, +} from "@earendil-works/pi-coding-agent"; +import type { EditorComponent, EditorTheme, TUI } from "@earendil-works/pi-tui"; + +const CLAIM_CHANNEL = "openpi:editor-layers:claim"; +const REGISTER_CHANNEL = "openpi:editor-layers:register"; +const REMOVE_CHANNEL = "openpi:editor-layers:remove"; + +type EditorFactory = NonNullable< + ReturnType +>; + +export interface EditorLayer { + readonly id: string; + readonly order: number; + readonly wrap: ( + base: EditorComponent, + tui: TUI, + theme: EditorTheme, + keybindings: KeybindingsManager, + ) => EditorComponent; +} + +interface EditorLayerRegistration { + readonly ctx: ExtensionContext; + readonly layer: EditorLayer; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function readRegistration(value: unknown) { + if (!isRecord(value) || !isRecord(value.layer)) return undefined; + const { ctx, layer } = value; + if ( + !isRecord(ctx) || + typeof layer.id !== "string" || + typeof layer.order !== "number" || + typeof layer.wrap !== "function" + ) { + return undefined; + } + return { + ctx: ctx as unknown as ExtensionContext, + layer: layer as unknown as EditorLayer, + } satisfies EditorLayerRegistration; +} + +function readLayerId(value: unknown) { + if (!isRecord(value) || typeof value.id !== "string") return undefined; + return value.id; +} + +function composeEditorFactory( + previous: EditorFactory | undefined, + layers: readonly EditorLayer[], +) { + return ((tui, theme, keybindings) => { + let editor = + previous?.(tui, theme, keybindings) ?? + new CustomEditor(tui, theme, keybindings); + for (const layer of layers) { + editor = layer.wrap(editor, tui, theme, keybindings); + } + return editor; + }) satisfies EditorFactory; +} + +/** + * Each extension is evaluated in its own jiti module graph, so ordinary module + * singletons are not shared. The first OpenPI editor contributor claims the + * runtime EventBus and coordinates the rest through that host-owned boundary. + */ +function ensureCoordinator(pi: ExtensionAPI) { + const claim = { claimed: false }; + pi.events.emit(CLAIM_CHANNEL, claim); + if (claim.claimed) return; + + let ctx: ExtensionContext | undefined; + let installTimer: ReturnType | undefined; + const layers = new Map(); + + const cancelInstall = () => { + if (installTimer) clearTimeout(installTimer); + installTimer = undefined; + }; + + const install = () => { + installTimer = undefined; + const current = ctx; + if (!current || current.mode !== "tui" || layers.size === 0) return; + const ordered = [...layers.values()].sort( + (left, right) => + left.order - right.order || left.id.localeCompare(right.id), + ); + current.ui.setEditorComponent( + composeEditorFactory(current.ui.getEditorComponent(), ordered), + ); + }; + + const scheduleInstall = () => { + if (installTimer) return; + installTimer = setTimeout(install, 0); + }; + + pi.events.on(CLAIM_CHANNEL, (value) => { + if (isRecord(value) && value.claimed === false) value.claimed = true; + }); + pi.events.on(REGISTER_CHANNEL, (value) => { + const registration = readRegistration(value); + if (!registration || registration.ctx.mode !== "tui") return; + if (ctx !== registration.ctx) { + cancelInstall(); + layers.clear(); + ctx = registration.ctx; + } + layers.set(registration.layer.id, registration.layer); + scheduleInstall(); + }); + pi.events.on(REMOVE_CHANNEL, (value) => { + const id = readLayerId(value); + if (!id) return; + layers.delete(id); + if (layers.size > 0) return; + cancelInstall(); + ctx = undefined; + }); +} + +export function registerEditorLayer( + pi: ExtensionAPI, + ctx: ExtensionContext, + layer: EditorLayer, +) { + if (ctx.mode !== "tui") return; + ensureCoordinator(pi); + pi.events.emit(REGISTER_CHANNEL, { + ctx, + layer, + } satisfies EditorLayerRegistration); +} + +export function removeEditorLayer(pi: ExtensionAPI, id: string) { + pi.events.emit(REMOVE_CHANNEL, { id }); +} diff --git a/extensions/subagents/index.ts b/extensions/subagents/index.ts index 4f5533fb..892e336e 100644 --- a/extensions/subagents/index.ts +++ b/extensions/subagents/index.ts @@ -33,7 +33,6 @@ import type { ExtensionUIContext, } from "@earendil-works/pi-coding-agent"; import { - CustomEditor, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, defineTool, @@ -69,6 +68,10 @@ import { OPENPI_TOOL_SURFACE, patchOwnedTools, } from "../shared/tool-surface.ts"; +import { + registerEditorLayer, + removeEditorLayer, +} from "../shared/editor-layers.ts"; import { formatContextUtilization } from "./src/format.ts"; import { SubagentManager, type SubagentManagerShape } from "./src/manager.ts"; import { @@ -200,6 +203,7 @@ export default function (pi: ExtensionAPI) { let navigationManager: SubagentManagerShape | undefined; let widgetVisible = false; let requestWidgetRender: (() => void) | undefined; + let navigationLayerRegistered = false; let dashboardOpen = false; const resultDelivery = createDeferredResultDelivery(); const hideLifecycleTools = () => @@ -292,26 +296,26 @@ export default function (pi: ExtensionAPI) { const installSubagentNavigation = (ctx: ExtensionContext) => { if (ctx.mode !== "tui") return; - const previous = ctx.ui.getEditorComponent(); - ctx.ui.setEditorComponent((tui, theme, keybindings) => { - const base = - previous?.(tui, theme, keybindings) ?? - new CustomEditor(tui, theme, keybindings); - return new BelowEditorNavigationEditor( - base, - keybindings, - stripState, - () => Boolean(stripEntry()), - () => { - const entry = stripEntry(); - if (entry) void openDashboard(ctx, entry.snapshot.id); - }, - () => { - requestWidgetRender?.(); - tui.requestRender(); - }, - ); + registerEditorLayer(pi, ctx, { + id: "subagents", + order: 100, + wrap: (base, tui, _theme, keybindings) => + new BelowEditorNavigationEditor( + base, + keybindings, + stripState, + () => Boolean(stripEntry()), + () => { + const entry = stripEntry(); + if (entry) void openDashboard(ctx, entry.snapshot.id); + }, + () => { + requestWidgetRender?.(); + tui.requestRender(); + }, + ), }); + navigationLayerRegistered = true; }; /** @@ -447,6 +451,10 @@ export default function (pi: ExtensionAPI) { pi.on("agent_settled", () => flushResults(false)); pi.on("session_shutdown", async () => { + if (navigationLayerRegistered) { + removeEditorLayer(pi, "subagents"); + navigationLayerRegistered = false; + } resultDelivery.clear(); unsubStatus?.(); unsubStatus = undefined; diff --git a/extensions/suggestions/index.ts b/extensions/suggestions/index.ts index f5c278ae..3675d540 100644 --- a/extensions/suggestions/index.ts +++ b/extensions/suggestions/index.ts @@ -1,8 +1,11 @@ -import { - CustomEditor, - type ExtensionAPI, - type ExtensionContext, +import type { + ExtensionAPI, + ExtensionContext, } from "@earendil-works/pi-coding-agent"; +import { + registerEditorLayer, + removeEditorLayer, +} from "../shared/editor-layers.ts"; import { loadSetupConfig, SETUP_CONFIG_CHANGED_CHANNEL, @@ -51,6 +54,7 @@ export default function (pi: ExtensionAPI) { let sessionActive = false; let statusContext: ExtensionContext | undefined; let requestEditorRender: (() => void) | undefined; + let editorLayerRegistered = false; const updateStatus = () => { statusContext?.ui.setStatus( @@ -72,21 +76,22 @@ export default function (pi: ExtensionAPI) { const installSuggestionEditor = (ctx: ExtensionContext) => { if (ctx.mode !== "tui") return; - const previous = ctx.ui.getEditorComponent(); - ctx.ui.setEditorComponent((tui, theme, keybindings) => { - const base = - previous?.(tui, theme, keybindings) ?? - new CustomEditor(tui, theme, keybindings); - requestEditorRender = () => tui.requestRender(); - return new NextActionSuggestionEditor( - base, - keybindings, - suggestionState, - cancelPrediction, - requestEditorRender, - (text) => ctx.ui.theme.fg("dim", text), - ); + registerEditorLayer(pi, ctx, { + id: "suggestions", + order: 200, + wrap: (base, tui, _theme, keybindings) => { + requestEditorRender = () => tui.requestRender(); + return new NextActionSuggestionEditor( + base, + keybindings, + suggestionState, + cancelPrediction, + requestEditorRender, + (text) => ctx.ui.theme.fg("dim", text), + ); + }, }); + editorLayerRegistered = true; }; pi.on("session_start", (_event, ctx) => { @@ -160,6 +165,10 @@ export default function (pi: ExtensionAPI) { pi.events.on(SETUP_CONFIG_CHANGED_CHANNEL, cancelPrediction); pi.on("session_shutdown", async () => { + if (editorLayerRegistered) { + removeEditorLayer(pi, "suggestions"); + editorLayerRegistered = false; + } sessionActive = false; runBoundary.reset(); const task = activePrediction?.task; diff --git a/extensions/tasks/index.test.ts b/extensions/tasks/index.test.ts index 67e6a1fd..18b3cc6c 100644 --- a/extensions/tasks/index.test.ts +++ b/extensions/tasks/index.test.ts @@ -5,7 +5,7 @@ import type { ExtensionContext, Theme, } from "@earendil-works/pi-coding-agent"; -import type { Component } from "@earendil-works/pi-tui"; +import type { Component, TUI } from "@earendil-works/pi-tui"; import sessionTasks, { findTaskConflict, injectTaskProjection, @@ -25,13 +25,12 @@ const plainTheme = { strikethrough: (text: string) => text, } as unknown as Theme; -function widgetLines(widget: unknown) { - if (typeof widget !== "function") return []; - const component = (widget as (tui: unknown, theme: Theme) => Component)( - undefined, +function widgetComponent(widget: unknown, tui?: TUI) { + if (typeof widget !== "function") return undefined; + return (widget as (tui: TUI, theme: Theme) => Component)( + tui as TUI, plainTheme, ); - return component.render(120); } function widgetHarness( @@ -218,6 +217,42 @@ test("persistent task widget restores, updates, and expands all tasks", async () assert.equal(h.widgets.at(-1), undefined); }); +test("mounted task widget renders live task state without another UI write", async () => { + const h = widgetHarness(); + await h.emit("session_start"); + const add = h.tools.get("tasks_add"); + + await add.execute( + "first", + { items: [{ subject: "First task" }] }, + undefined, + undefined, + h.ctx, + ); + const mounted = h.widgets.at(-1); + let renderRequests = 0; + const component = widgetComponent(mounted, { + requestRender() { + renderRequests += 1; + }, + } as unknown as TUI); + assert.ok(component); + assert.match(component.render(120).join("\n"), /First task/); + + const widgetWrites = h.widgets.length; + await add.execute( + "second", + { items: [{ subject: "Second task" }] }, + undefined, + undefined, + h.ctx, + ); + + assert.equal(h.widgets.length, widgetWrites); + assert.equal(renderRequests, 1); + assert.match(component.render(120).join("\n"), /Second task/); +}); + test("four tracked tasks refresh after every explicit progress transition", async () => { const h = widgetHarness(); await h.emit("session_start"); @@ -240,9 +275,17 @@ test("four tracked tasks refresh after every explicit progress transition", asyn /Current task snapshot \(4 items\)/, ); assert.equal(added.details.items.length, 4); + let renderRequests = 0; + const mounted = widgetComponent(h.widgets.at(-1), { + requestRender() { + renderRequests += 1; + }, + } as unknown as TUI); + assert.ok(mounted); for (let id = 1; id <= 4; id++) { const widgetWritesBeforeStart = h.widgets.length; + const renderRequestsBeforeStart = renderRequests; const started = await update.execute( `start-${id}`, { id, status: "in_progress" }, @@ -250,17 +293,19 @@ test("four tracked tasks refresh after every explicit progress transition", asyn undefined, h.ctx, ); - assert.equal(h.widgets.length, widgetWritesBeforeStart + 1); + assert.equal(h.widgets.length, widgetWritesBeforeStart); + assert.equal(renderRequests, renderRequestsBeforeStart + 1); assert.match( started.content[0]?.text ?? "", new RegExp(`T${id} \\[in_progress\\] Task ${id}`), ); assert.match( - widgetLines(h.widgets.at(-1)).join("\n"), + mounted.render(120).join("\n"), new RegExp(`T${id} Task ${id}`), ); const widgetWritesBeforeDone = h.widgets.length; + const renderRequestsBeforeDone = renderRequests; const completed = await update.execute( `done-${id}`, { id, status: "done", note: `evidence-${id}` }, @@ -268,18 +313,18 @@ test("four tracked tasks refresh after every explicit progress transition", asyn undefined, h.ctx, ); - assert.equal(h.widgets.length, widgetWritesBeforeDone + 1); if (id < 4) { + assert.equal(h.widgets.length, widgetWritesBeforeDone); + assert.equal(renderRequests, renderRequestsBeforeDone + 1); assert.match( completed.content[0]?.text ?? "", new RegExp(`T${id} \\[done\\] Task ${id}`), ); assert.equal(completed.details.items.length, 4); - assert.match( - widgetLines(h.widgets.at(-1))[0] ?? "", - new RegExp(`${id} done`), - ); + assert.match(mounted.render(120)[0] ?? "", new RegExp(`${id} done`)); } else { + assert.equal(h.widgets.length, widgetWritesBeforeDone + 1); + assert.equal(renderRequests, renderRequestsBeforeDone); assert.match(completed.content[0]?.text ?? "", /Task batch closed/); assert.equal(h.widgets.at(-1), undefined); } diff --git a/extensions/tasks/index.ts b/extensions/tasks/index.ts index 72ccb99c..0de30463 100644 --- a/extensions/tasks/index.ts +++ b/extensions/tasks/index.ts @@ -99,6 +99,8 @@ export default function sessionTasks(pi: ExtensionAPI) { let notifiedProblem: string | undefined; let taskWidgetVisible = true; let taskWidgetExpanded = false; + let taskWidgetMounted = false; + let requestTaskWidgetRender: (() => void) | undefined; let ui: ExtensionContext["ui"] | undefined; let uiMode: ExtensionContext["mode"] | undefined; const hideLifecycleTools = () => @@ -124,22 +126,36 @@ export default function sessionTasks(pi: ExtensionAPI) { const updateTaskWidget = (ctx?: ExtensionContext) => { if (ctx?.hasUI) { + if (ui !== ctx.ui) { + taskWidgetMounted = false; + requestTaskWidgetRender = undefined; + } ui = ctx.ui; uiMode = ctx.mode; } if (!ui || uiMode !== "tui") return false; - const current = snapshot(); const shown = taskWidgetVisible && !problemMessage() && hasActionableTasks(); if (!shown) { + if (!taskWidgetMounted) return false; ui.setWidget(TASK_WIDGET_KEY, undefined); + taskWidgetMounted = false; + requestTaskWidgetRender = undefined; return false; } - ui.setWidget(TASK_WIDGET_KEY, (_tui, theme) => ({ - render: (width) => - renderTaskWidget(current, theme, width, taskWidgetExpanded), - invalidate() {}, - })); + if (taskWidgetMounted) { + requestTaskWidgetRender?.(); + return true; + } + ui.setWidget(TASK_WIDGET_KEY, (tui, theme) => { + requestTaskWidgetRender = () => tui.requestRender(); + return { + render: (width) => + renderTaskWidget(snapshot(), theme, width, taskWidgetExpanded), + invalidate() {}, + }; + }); + taskWidgetMounted = true; return true; }; @@ -515,10 +531,12 @@ export default function sessionTasks(pi: ExtensionAPI) { pi.on("session_shutdown", () => { try { - ui?.setWidget(TASK_WIDGET_KEY, undefined); + if (taskWidgetMounted) ui?.setWidget(TASK_WIDGET_KEY, undefined); } catch { // The interactive UI may already be disposed. } + taskWidgetMounted = false; + requestTaskWidgetRender = undefined; ui = undefined; uiMode = undefined; }); diff --git a/extensions/workflows/index.ts b/extensions/workflows/index.ts index 86e6af8e..f91e8012 100644 --- a/extensions/workflows/index.ts +++ b/extensions/workflows/index.ts @@ -33,7 +33,6 @@ import { randomBytes } from "node:crypto"; import * as fs from "node:fs"; import * as path from "node:path"; import { - CustomEditor, getAgentDir, getMarkdownTheme, keyHint, @@ -45,6 +44,10 @@ import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui"; import { Type, type Static } from "typebox"; import { formatActivityStatus } from "../shared/activity-status.ts"; import { waitBounded } from "../shared/child-session.ts"; +import { + registerEditorLayer, + removeEditorLayer, +} from "../shared/editor-layers.ts"; import { loadSetupConfig } from "../shared/setup-config.ts"; import { OPENPI_TOOL_SURFACE, @@ -445,6 +448,7 @@ export default function workflows(pi: ExtensionAPI) { let failedRuns = 0; let widgetVisible = false; let requestWidgetRender: (() => void) | undefined; + let navigationLayerRegistered = false; let dashboardOpen = false; /** * Start of the current request. The dashboard reports the work belonging to @@ -566,26 +570,26 @@ export default function workflows(pi: ExtensionAPI) { const installWorkflowNavigation = (ctx: ExtensionContext) => { if (ctx.mode !== "tui") return; - const previous = ctx.ui.getEditorComponent(); - ctx.ui.setEditorComponent((tui, theme, keybindings) => { - const base = - previous?.(tui, theme, keybindings) ?? - new CustomEditor(tui, theme, keybindings); - return new WorkflowNavigationEditor( - base, - keybindings, - stripState, - () => Boolean(stripEntry()), - () => { - const entry = stripEntry(); - if (entry) void openDashboard(ctx, entry.runId); - }, - () => { - requestWidgetRender?.(); - tui.requestRender(); - }, - ); + registerEditorLayer(pi, ctx, { + id: "workflows", + order: 300, + wrap: (base, tui, _theme, keybindings) => + new WorkflowNavigationEditor( + base, + keybindings, + stripState, + () => Boolean(stripEntry()), + () => { + const entry = stripEntry(); + if (entry) void openDashboard(ctx, entry.runId); + }, + () => { + requestWidgetRender?.(); + tui.requestRender(); + }, + ), }); + navigationLayerRegistered = true; }; pi.on("session_start", (_event, ctx) => { @@ -612,6 +616,10 @@ export default function workflows(pi: ExtensionAPI) { }); pi.on("session_shutdown", async () => { + if (navigationLayerRegistered) { + removeEditorLayer(pi, "workflows"); + navigationLayerRegistered = false; + } await shutdownActiveWorkflowRuns([...activeRuns.values()]); try { lastContext?.ui.setStatus("workflows", undefined); From d0082a2360f2e8f660eca4d2d69142909f1604c6 Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Thu, 20 Aug 2026 20:44:42 +0800 Subject: [PATCH 2/2] test: preserve RPC task cleanup signal --- extensions/tasks/index.test.ts | 3 +++ extensions/tasks/index.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/extensions/tasks/index.test.ts b/extensions/tasks/index.test.ts index 18b3cc6c..796da2df 100644 --- a/extensions/tasks/index.test.ts +++ b/extensions/tasks/index.test.ts @@ -391,6 +391,9 @@ test("task panel commands report actual visibility and conflicts block the short rpc.notifications.at(-1), "Task panel is available only in interactive TUI mode.", ); + await rpc.emit("session_shutdown"); + assert.equal(rpc.widgets.length, 1); + assert.equal(rpc.widgets.at(-1), undefined); }); test("detects foreign Todo/plan tools and reports their source", () => { diff --git a/extensions/tasks/index.ts b/extensions/tasks/index.ts index 0de30463..fa75158b 100644 --- a/extensions/tasks/index.ts +++ b/extensions/tasks/index.ts @@ -531,7 +531,7 @@ export default function sessionTasks(pi: ExtensionAPI) { pi.on("session_shutdown", () => { try { - if (taskWidgetMounted) ui?.setWidget(TASK_WIDGET_KEY, undefined); + ui?.setWidget(TASK_WIDGET_KEY, undefined); } catch { // The interactive UI may already be disposed. }