diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b2d0e75200..170dc636b0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -112,6 +112,8 @@ - Fixed Telegram forum topics freezing after the identity header: an attached, trusted session whose topic-host lease expired (20 s `HEARTBEAT_TTL_MS`) could never renew it, because `renewActiveTopicLeases` only renewed sessions that already passed the trusted-lease gate, so every later `turn_stream`/`context_update`/tool frame was rejected pre-send with "trusted attachment lease is stale" and the topic never updated again (#4647). A live attachment that still owns its exact logical session and holds an authorized recovery lease may now re-arm its own expired host lease — from the ownership heartbeat and once more before the publication gate — mirroring `acquireLease` admission (expired-but-owned active lease, or a same-owner resume inside the disconnect-grace window, which also covers the incident's persisted `disconnect_grace` record). Dropped sessions, closed endpoints, foreign lease owners, archive-fenced/inactive topics, malformed bindings, and cross-session ownership checks all still fail closed. Daemon generation bumped 169→170. - A tool call the agent loop refuses now reports why it was refused. The loop attaches its own failure envelope (`{ failureKind }`) in place of the tool's details, and the TUI still dispatched that envelope at the tool's renderer, which owns only its own detail shape: `search_tool_bm25` threw on `details.tools`, `task` printed `Task result details unavailable`, `resolve` printed `Failed: pending action`, and `write` painted its success card — in every case the rejection text (for example the `\uXXXX`-escaped-arguments rejection that ends a Korean `task` call) never reached the screen. Such a result now renders the same error card a tool without a renderer already produces: the failed status line plus the reason. Results a tool produced itself keep their renderer, including `todo_write`'s own `failureKind`. - Discovered oMLX models now keep thinking metadata (`reasoning: true`, `supportsReasoningEffort`, `thinkingFormat: qwen-chat-template`) so `macos-omlx-*` role suffixes (`:low`/`:medium`/`:high`) survive clamp and reach oMLX as `chat_template_kwargs.reasoning_effort`. +- Command palette and selector overlays no longer dispose the reusable composer editor. Opening a selector cleared `editorContainer` with the live editor attached, and `Container.clear()` disposes children terminally — `Editor.dispose()` tears down the tab-width change listener — so every palette/model-selector/queued-message close re-mounted an editor whose listener was permanently gone (tab-width changes stopped re-deriving composer layout after the first palette round-trip). Overlay open paths now detach the reusable editor first (`Container.detachChild`) so `clear()` disposes only the transient overlay, and pet-aware restores (`GajaePetWidget.#mountEditor`, `InteractiveMode.restoreComposer`, `InputController` queued-message restore) detach both reusable mounts before clearing. Overlay open and close paths now route through a single pet-aware authority — `InteractiveModeContext.detachComposer()` detaches whatever composition is actually mounted (`PetFramedEditor` when a pet is active, the plain editor otherwise) before every terminal `clear()`, and restores go through the pet-aware `restoreComposer()` — so an active pet keeps its `PetFramedEditor` mount and composer reserve across every overlay cycle, including the queued-message selector. The same detach contract now also covers the remaining overlay open paths that cleared the shared container with the live composer attached: the `/share` custom-export and default-gist loaders, the OAuth API-key code input, the jobs overlay (`alt+j` / `/monitors`), the tasks pane (`alt+t`), the MCP-add wizard, and the `/debug` log/raw-SSE viewers — each detaches the reusable composer before its terminal `clear()` so only the transient overlay is disposed (#4604, follow-up to the `52dad458` remount fix; closes the follow-up scope filed in #4657). +- Overlay close and completion paths now restore the composer through the pet-aware authority too. The #4604 fix taught every overlay open path to detach the reusable composition before its terminal `clear()`, but several close paths still re-added the raw editor directly: the jobs overlay and tasks pane close callbacks, the OAuth manual-code submit, the `/share` custom-export and default-gist restore paths, and the MCP-add wizard completion. An active pet session therefore lost its `PetFramedEditor` mount and composer reserve after any of those transitions. All six close paths now route through `InteractiveModeContext.restoreComposer()` (with a plain editor-swap fallback for lightweight hosts that predate it), matching the open-path contract so an active pet keeps its framed composer across every overlay cycle (#4604). - Added built-in `MACOS LOCAL (OMLX)` model profiles (`macos-omlx-fast`, `macos-omlx-balanced`, `macos-omlx-quality`, `macos-omlx-abliterated-fast`, `macos-omlx-abliterated-balanced`) for oMLX local inference on Apple Silicon Macs with native full context support and single-LLM thinking effort role mappings to eliminate model swap latency. - Fixed an HTTP 400 that killed every deep-interview session on the `google-antigravity` provider before the first assistant turn. The Round-0 topology `ask` schema pinned `round` with `z.literal(0)`, which zod serializes as `const: 0` and the Cloud Code Assist normalizer rewrites to a numeric `enum: [0]` — a shape CCA rejects (`TYPE_STRING`). `round` is now pinned with an integer range `[0, 0]` instead, so the wire schema carries `type: integer` with the bounds spilled into the description (the same treatment `ambiguity` already gets) and no numeric enum remains. Runtime contract unchanged: only `0` validates (#4606). - The terminal-app integration docs now cite the upstream work that backs each support rating: Gajae Code is proposed for Paseo's in-app ACP provider catalog ([getpaseo/paseo#3471](https://github.com/getpaseo/paseo/pull/3471)) and for Orca's built-in agent registry ([stablyai/orca#15025](https://github.com/stablyai/orca/pull/15025)), while T3 Code has no GJC harness and the integration shape is under discussion upstream ([pingdotgg/t3code#7290](https://github.com/pingdotgg/t3code/discussions/7290)). diff --git a/packages/coding-agent/src/debug/index.ts b/packages/coding-agent/src/debug/index.ts index be98b0433e..dcf68c8000 100644 --- a/packages/coding-agent/src/debug/index.ts +++ b/packages/coding-agent/src/debug/index.ts @@ -345,6 +345,13 @@ export class DebugSelectorComponent extends Container { logSource, }); + // Detach the mounted composition (pet-aware) before clearing so + // terminal clear() disposes nothing the session still owns. + if (typeof this.ctx.detachComposer === "function") { + this.ctx.detachComposer(); + } else { + this.ctx.editorContainer.detachChild(this.ctx.editor); + } this.ctx.editorContainer.clear(); this.ctx.editorContainer.addChild(viewer); this.ctx.ui.setFocus(viewer); @@ -364,6 +371,13 @@ export class DebugSelectorComponent extends Container { onUpdate: () => this.ctx.ui.requestRender(), }); + // Detach the mounted composition (pet-aware) before clearing so the + // terminal clear() disposes nothing the session still owns. + if (typeof this.ctx.detachComposer === "function") { + this.ctx.detachComposer(); + } else { + this.ctx.editorContainer.detachChild(this.ctx.editor); + } this.ctx.editorContainer.clear(); this.ctx.editorContainer.addChild(viewer); this.ctx.ui.setFocus(viewer); diff --git a/packages/coding-agent/src/modes/components/gajae-pet-widget.ts b/packages/coding-agent/src/modes/components/gajae-pet-widget.ts index 73ac17c64f..04d9d9e721 100644 --- a/packages/coding-agent/src/modes/components/gajae-pet-widget.ts +++ b/packages/coding-agent/src/modes/components/gajae-pet-widget.ts @@ -443,6 +443,12 @@ export class GajaePetWidget { } #mountEditor(framed: boolean): void { + // The composer editor is reusable across overlays; disposal is terminal + // (Editor.dispose tears down the tab-width listener), so detach the + // reusable mounts before clearing. Only transient overlay children + // (palette, selectors) are disposed by the clear. + this.#editorContainer.detachChild(this.#editor); + this.#editorContainer.detachChild(this.#framedEditor); this.#editorContainer.clear(); this.#editorContainer.addChild(framed ? this.#framedEditor : this.#editor); } @@ -456,6 +462,12 @@ export class GajaePetWidget { if (owner === undefined || owner === this) this.#mountEditor(this.#mode !== "off"); } + /** Detach the mounted composition (plain or framed) so a terminal clear() cannot dispose it. */ + detachComposer(): void { + this.#editorContainer.detachChild(this.#editor); + this.#editorContainer.detachChild(this.#framedEditor); + } + #syncWorkingState(now: number): PetSkinId | undefined { if (this.#mode === "off") return undefined; const skin = PET_SKINS[this.#mode]; diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index 7cc2539045..0ab83e337c 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -188,6 +188,13 @@ export class CommandController { const customShare = await loadCustomShare(); if (customShare) { const loader = new BorderedLoader(this.ctx.ui, theme, "Sharing..."); + // Detach the mounted composition (pet-aware) before clearing so + // the terminal clear() disposes nothing the session still owns. + if (typeof this.ctx.detachComposer === "function") { + this.ctx.detachComposer(); + } else { + this.ctx.editorContainer.detachChild(this.ctx.editor); + } this.ctx.editorContainer.clear(); this.ctx.editorContainer.addChild(loader); this.ctx.ui.setFocus(loader); @@ -195,9 +202,16 @@ export class CommandController { const restoreEditor = async () => { loader.dispose(); - this.ctx.editorContainer.clear(); - this.ctx.editorContainer.addChild(this.ctx.editor); - this.ctx.ui.setFocus(this.ctx.editor); + // Prefer the pet-aware composer restore (InteractiveMode.restoreComposer); fall back + // to a plain editor swap for contexts that predate it (e.g. lightweight test doubles). + if (typeof this.ctx.restoreComposer === "function") { + this.ctx.restoreComposer(); + } else { + this.ctx.editorContainer.clear(); + this.ctx.editorContainer.addChild(this.ctx.editor); + this.ctx.ui.setFocus(this.ctx.editor); + this.ctx.ui.requestRender(); + } }; try { @@ -245,6 +259,13 @@ export class CommandController { } const loader = new BorderedLoader(this.ctx.ui, theme, "Creating gist..."); + // Detach the mounted composition (pet-aware) before clearing so the + // terminal clear() disposes nothing the session still owns. + if (typeof this.ctx.detachComposer === "function") { + this.ctx.detachComposer(); + } else { + this.ctx.editorContainer.detachChild(this.ctx.editor); + } this.ctx.editorContainer.clear(); this.ctx.editorContainer.addChild(loader); this.ctx.ui.setFocus(loader); @@ -252,9 +273,16 @@ export class CommandController { const restoreEditor = async () => { loader.dispose(); - this.ctx.editorContainer.clear(); - this.ctx.editorContainer.addChild(this.ctx.editor); - this.ctx.ui.setFocus(this.ctx.editor); + // Prefer the pet-aware composer restore (InteractiveMode.restoreComposer); fall back + // to a plain editor swap for contexts that predate it (e.g. lightweight test doubles). + if (typeof this.ctx.restoreComposer === "function") { + this.ctx.restoreComposer(); + } else { + this.ctx.editorContainer.clear(); + this.ctx.editorContainer.addChild(this.ctx.editor); + this.ctx.ui.setFocus(this.ctx.editor); + this.ctx.ui.requestRender(); + } }; let cancellationRequested = false; diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index 070c911601..b740376021 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -1311,6 +1311,23 @@ export class InputController { } #restoreEditorFocus(): void { + // Prefer the pet-aware composer restore (InteractiveMode.restoreComposer): + // in an active pet session the mounted child is PetFramedEditor, and + // mounting the raw editor would drop the pet reserve beside the composer. + // Fall back to a plain editor swap for contexts that predate it (e.g. + // lightweight test doubles). + if (typeof this.ctx.restoreComposer === "function") { + this.ctx.restoreComposer(); + return; + } + // The composer composition is reusable across overlays: detach + // (never dispose) whatever is mounted so only the transient overlay + // is torn down. + if (typeof this.ctx.detachComposer === "function") { + this.ctx.detachComposer(); + } else { + this.ctx.editorContainer.detachChild(this.ctx.editor); + } this.ctx.editorContainer.clear(); this.ctx.editorContainer.addChild(this.ctx.editor); this.ctx.ui.setFocus(this.ctx.editor); @@ -1372,6 +1389,14 @@ export class InputController { ), }, ); + // Detach the mounted composition (pet-aware) before clearing so the + // terminal clear() disposes only the selector overlay, never the + // composer (disposal is terminal). + if (typeof this.ctx.detachComposer === "function") { + this.ctx.detachComposer(); + } else { + this.ctx.editorContainer.detachChild(this.ctx.editor); + } this.ctx.editorContainer.clear(); this.ctx.editorContainer.addChild(selector); this.ctx.ui.setFocus(selector); diff --git a/packages/coding-agent/src/modes/controllers/runtime-mcp-command-controller.ts b/packages/coding-agent/src/modes/controllers/runtime-mcp-command-controller.ts index b7b389ef1c..dec625af59 100644 --- a/packages/coding-agent/src/modes/controllers/runtime-mcp-command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/runtime-mcp-command-controller.ts @@ -469,9 +469,15 @@ export class MCPCommandController { // Save current editor state const done = () => { - this.ctx.editorContainer.clear(); - this.ctx.editorContainer.addChild(this.ctx.editor); - this.ctx.ui.setFocus(this.ctx.editor); + // Prefer the pet-aware composer restore (InteractiveMode.restoreComposer); fall back + // to a plain editor swap for contexts that predate it (e.g. lightweight test doubles). + if (typeof this.ctx.restoreComposer === "function") { + this.ctx.restoreComposer(); + } else { + this.ctx.editorContainer.clear(); + this.ctx.editorContainer.addChild(this.ctx.editor); + this.ctx.ui.setFocus(this.ctx.editor); + } }; // Create wizard with OAuth handler and connection test @@ -503,7 +509,14 @@ export class MCPCommandController { parsed.initialName, ); - // Replace editor with wizard + // Replace editor with wizard. Detach the mounted composition + // (pet-aware) first so the terminal clear() disposes nothing the + // session still owns. + if (typeof this.ctx.detachComposer === "function") { + this.ctx.detachComposer(); + } else { + this.ctx.editorContainer.detachChild(this.ctx.editor); + } this.ctx.editorContainer.clear(); this.ctx.editorContainer.addChild(wizard); this.ctx.ui.setFocus(wizard); diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index 29d465ddf0..ac3a0cd22d 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -1338,6 +1338,14 @@ export class SelectorController { } }; const { component, focus } = create(done); + // The composer composition is reusable across overlays; detach whatever + // is actually mounted (pet-aware) before clearing so clear() disposes + // only the transient overlay, not the composer (disposal is terminal). + if (typeof this.ctx.detachComposer === "function") { + this.ctx.detachComposer(); + } else { + this.ctx.editorContainer.detachChild(this.ctx.editor); + } this.ctx.editorContainer.clear(); this.ctx.editorContainer.addChild(component); this.ctx.ui.setFocus(focus); @@ -3156,11 +3164,26 @@ export class SelectorController { const codeInput = new Input(); codeInput.onSubmit = () => { const code = codeInput.getValue(); - this.ctx.editorContainer.clear(); - this.ctx.editorContainer.addChild(this.ctx.editor); - this.ctx.ui.setFocus(this.ctx.editor); + // Prefer the pet-aware composer restore (InteractiveMode.restoreComposer); + // fall back to a plain editor swap for contexts that predate it + // (e.g. lightweight test doubles). + if (typeof this.ctx.restoreComposer === "function") { + this.ctx.restoreComposer(); + } else { + this.ctx.editorContainer.clear(); + this.ctx.editorContainer.addChild(this.ctx.editor); + this.ctx.ui.setFocus(this.ctx.editor); + } resolve(code); }; + // Detach the mounted composition (pet-aware) before + // clearing so the clear() disposes only the prior + // transient, never the composer (disposal is terminal). + if (typeof this.ctx.detachComposer === "function") { + this.ctx.detachComposer(); + } else { + this.ctx.editorContainer.detachChild(this.ctx.editor); + } this.ctx.editorContainer.clear(); this.ctx.editorContainer.addChild(codeInput); this.ctx.ui.setFocus(codeInput); @@ -3604,9 +3627,15 @@ export class SelectorController { showJobsOverlay(observer: JobsObserver): void { let overlay: JobsOverlayComponent | undefined; const close = () => { - this.ctx.editorContainer.clear(); - this.ctx.editorContainer.addChild(this.ctx.editor); - this.ctx.ui.setFocus(this.ctx.editor); + // Prefer the pet-aware composer restore (InteractiveMode.restoreComposer); fall back + // to a plain editor swap for contexts that predate it (e.g. lightweight test doubles). + if (typeof this.ctx.restoreComposer === "function") { + this.ctx.restoreComposer(); + } else { + this.ctx.editorContainer.clear(); + this.ctx.editorContainer.addChild(this.ctx.editor); + this.ctx.ui.setFocus(this.ctx.editor); + } this.ctx.ui.requestRender(); }; overlay = new JobsOverlayComponent(observer, { @@ -3616,6 +3645,14 @@ export class SelectorController { this.ctx.ui.requestRender(); }, }); + // Detach the mounted composition (pet-aware) before clearing so the + // clear() disposes only a prior transient, never the composer + // (disposal is terminal). + if (typeof this.ctx.detachComposer === "function") { + this.ctx.detachComposer(); + } else { + this.ctx.editorContainer.detachChild(this.ctx.editor); + } this.ctx.editorContainer.clear(); this.ctx.editorContainer.addChild(overlay); this.ctx.ui.setFocus(overlay.getFocus()); @@ -3632,9 +3669,15 @@ export class SelectorController { unsubscribe?.(); this.#tasksPane = undefined; this.#closeTasksPane = undefined; - this.ctx.editorContainer.clear(); - this.ctx.editorContainer.addChild(this.ctx.editor); - this.ctx.ui.setFocus(this.ctx.editor); + // Prefer the pet-aware composer restore (InteractiveMode.restoreComposer); fall back + // to a plain editor swap for contexts that predate it (e.g. lightweight test doubles). + if (typeof this.ctx.restoreComposer === "function") { + this.ctx.restoreComposer(); + } else { + this.ctx.editorContainer.clear(); + this.ctx.editorContainer.addChild(this.ctx.editor); + this.ctx.ui.setFocus(this.ctx.editor); + } this.ctx.ui.requestRender(); }; this.#closeTasksPane = close; @@ -3646,6 +3689,14 @@ export class SelectorController { }, }); unsubscribe = aggregator.onChange(() => this.#tasksPane?.refresh()); + // Detach the mounted composition (pet-aware) before clearing so the + // clear() disposes only a prior transient, never the composer + // (disposal is terminal). + if (typeof this.ctx.detachComposer === "function") { + this.ctx.detachComposer(); + } else { + this.ctx.editorContainer.detachChild(this.ctx.editor); + } this.ctx.editorContainer.clear(); this.ctx.editorContainer.addChild(this.#tasksPane); this.ctx.ui.setFocus(this.#tasksPane.getFocus()); diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 2821a1c498..7ea0c70a91 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -1427,6 +1427,9 @@ export class InteractiveMode implements InteractiveModeContext { if (this.petWidget) { this.petWidget.remountComposer(); } else { + // The composer is reusable across overlays: detach (never dispose) + // before clearing so only the transient overlay is torn down. + this.editorContainer.detachChild(this.editor); this.editorContainer.clear(); this.editorContainer.addChild(this.editor); } @@ -1437,6 +1440,17 @@ export class InteractiveMode implements InteractiveModeContext { this.ui.requestRender(); } + detachComposer(): void { + // Pet-aware: an active pet mounts PetFramedEditor, so detach whatever + // composition is actually mounted — a raw-editor-only detach would be a + // no-op and let the terminal clear() dispose the framed composer. + if (this.petWidget) { + this.petWidget.detachComposer(); + } else { + this.editorContainer.detachChild(this.editor); + } + } + #createPetWidget(editor: CustomEditor): GajaePetWidget { return new GajaePetWidget({ ui: this.ui, diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index c3ff8404c5..124da0d3b4 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -292,6 +292,8 @@ export interface InteractiveModeContext { commitPetPreviewMode(mode: PetMode): boolean; /** Re-mount the composer (pet-aware) after an overlay/selector closes. */ restoreComposer(): void; + /** Detach the mounted composer composition (pet-aware) before a terminal overlay clear(). */ + detachComposer?(): void; startPendingSubmission( input: { text: string; diff --git a/packages/coding-agent/test/command-palette-interactive-host.test.ts b/packages/coding-agent/test/command-palette-interactive-host.test.ts index 43e620ded8..61ae497b37 100644 --- a/packages/coding-agent/test/command-palette-interactive-host.test.ts +++ b/packages/coding-agent/test/command-palette-interactive-host.test.ts @@ -19,7 +19,7 @@ import { HistoryStorage } from "@gajae-code/coding-agent/session/history-storage import { SessionManager } from "@gajae-code/coding-agent/session/session-manager"; import * as titleGenerator from "@gajae-code/coding-agent/utils/title-generator"; import { setKeybindings } from "@gajae-code/tui"; -import { TempDir } from "@gajae-code/utils"; +import { getDefaultTabWidth, setDefaultTabWidth, TempDir } from "@gajae-code/utils"; import { ModelRegistry } from "../src/config/model-registry"; interface InteractivePaletteHost { @@ -425,6 +425,45 @@ describe("command palette InteractiveMode host", () => { expect(host.mode.editorContainer.children).toEqual([host.mode.editor]); } }); + it("keeps the composer's tab-width listener alive across repeated palette open/close cycles", async () => { + // Regression for the editor-lifecycle half of #4604: opening the + // palette used to clear() the container with the live editor attached, + // and Container.clear() disposes children terminally — Editor.dispose() + // tears down the tab-width change listener. Every restore after that + // re-mounted a dead editor whose listener was permanently gone. + const host = await createHost(); + const editor = host.mode.editor; + const defaultWidth = getDefaultTabWidth(); + const otherWidth = defaultWidth === 3 ? 4 : 3; + const invalidations = { count: 0 }; + const originalInvalidate = editor.invalidate.bind(editor); + editor.invalidate = () => { + invalidations.count += 1; + originalInvalidate(); + }; + try { + editor.setText(""); + await Promise.resolve(); + let previousCount = 0; + for (let index = 0; index < 3; index += 1) { + const palette = await openPalette(host); + palette.handleInput("\u001b"); + await waitFor(() => host.mode.editorContainer.children[0] === editor, "the composer to be restored"); + setDefaultTabWidth(otherWidth); + setDefaultTabWidth(defaultWidth); + // Each cycle's tab-width toggles reached a live listener. After + // the old clear()-dispose bug, cycle 2+ contributed nothing. + expect(invalidations.count).toBeGreaterThan(previousCount + 1); + previousCount = invalidations.count; + } + + // The restored composer still accepts input. + editor.handleInput("x"); + expect(editor.getText()).toBe("x"); + } finally { + setDefaultTabWidth(defaultWidth); + } + }); it("blocks the palette while a draft or palette command is active without leaking a modal", async () => { const host = await createHost(); diff --git a/packages/coding-agent/test/custom-model-preset-creation.test.ts b/packages/coding-agent/test/custom-model-preset-creation.test.ts index 74fc2e2eaf..076e9b169f 100644 --- a/packages/coding-agent/test/custom-model-preset-creation.test.ts +++ b/packages/coding-agent/test/custom-model-preset-creation.test.ts @@ -853,6 +853,7 @@ describe("custom model preset creation", () => { ui: { setFocus: () => {}, requestRender: () => {} }, editorContainer: { clear: () => {}, + detachChild: () => {}, addChild: (child: unknown) => { if (child instanceof ModelSelectorComponent) selector = child; }, diff --git a/packages/coding-agent/test/gajae-pet-widget.test.ts b/packages/coding-agent/test/gajae-pet-widget.test.ts index 631a686962..320e49f10c 100644 --- a/packages/coding-agent/test/gajae-pet-widget.test.ts +++ b/packages/coding-agent/test/gajae-pet-widget.test.ts @@ -10,7 +10,9 @@ import { type TUI, wrapITerm2RecordForTmux, } from "@gajae-code/tui"; -import type { CustomEditor } from "../src/modes/components/custom-editor"; +import { getDefaultTabWidth, setDefaultTabWidth } from "@gajae-code/utils"; +import { defaultEditorTheme } from "../../tui/test/test-themes"; +import { CustomEditor } from "../src/modes/components/custom-editor"; import { GajaePetWidget, PetFramedEditor } from "../src/modes/components/gajae-pet-widget"; import { setVerifiedItermPetAvailability } from "../src/modes/components/pet-capability"; @@ -192,9 +194,28 @@ function makeWidget( isWorking?: () => boolean; autoFlexGapMs?: [number, number] | null; protocol?: "sixel" | "kitty" | null; + /** Mount a real CustomEditor in a real Container so disposal semantics match production. */ + editor?: "real"; } = {}, ) { const stubs = makeStubs(columns, rows); + if (options.editor === "real") { + const editor = new CustomEditor(defaultEditorTheme); + const editorContainer = new Container(); + editorContainer.addChild(editor); + const widget = new GajaePetWidget({ + ui: stubs.ui, + editor, + editorContainer, + floorContainer: stubs.floorContainer, + isWorking: options.isWorking ?? (() => false), + getComposerBottomOffset: () => stubs.floorContainer.render(columns).length + (options.bottomOffset ?? 0), + syncManagedItermCursor: async () => true, + forcePixelProtocol: options.protocol === null ? undefined : (options.protocol ?? "sixel"), + autoFlexGapMs: options.autoFlexGapMs !== undefined ? options.autoFlexGapMs : null, + }); + return { ...stubs, editor, editorContainer, widget }; + } const widget = new GajaePetWidget({ ui: stubs.ui, editor: stubs.editor, @@ -646,6 +667,101 @@ describe("GajaePetWidget", () => { expect(stubs.written).toHaveLength(0); second.dispose(); }); + it("remounts the plain editor while never activated so palette close paths cannot leak a modal", () => { + const { widget, editor, editorContainer } = makeWidget(80, 30, { editor: "real" }); + try { + // Palette open: host detaches the reusable composer and mounts the + // transient overlay (pet never activated, so no overlay claim). + const overlay = new Container(); + editorContainer.detachChild(editor); + editorContainer.clear(); + editorContainer.addChild(overlay); + widget.remountComposer(); + + expect(editorContainer.children).toEqual([editor]); + expect(editorContainer.children[0]).not.toBe(overlay); + expect(widget.mode).toBe("off"); + } finally { + widget.dispose(); + editor.dispose(); + } + }); + it("remounts the framed editor while active after a palette overlay replaces it", () => { + const { widget, editor, editorContainer } = makeWidget(80, 30, { editor: "real" }); + try { + widget.setMode("red"); + const framed = editorContainer.children[0]; + const overlay = new Container(); + editorContainer.clear(); + editorContainer.addChild(overlay); + widget.remountComposer(); + + expect(editorContainer.children).toEqual([framed]); + } finally { + widget.dispose(); + editor.dispose(); + } + }); + it("keeps the composer usable across repeated palette close/remount cycles without disposing it", () => { + // Real editor so the disposal contract is observable: Editor.dispose() + // tears down the tab-width change listener, and that listener fires + // editor.invalidate() on a runtime tab-width change. Counting + // invalidations therefore proves the reusable editor was never + // disposed across overlay open/close cycles. + const { widget, editor, editorContainer } = makeWidget(80, 30, { editor: "real" }); + const defaultWidth = getDefaultTabWidth(); + const otherWidth = defaultWidth === 3 ? 4 : 3; + const invalidations = { count: 0 }; + const originalInvalidate = editor.invalidate.bind(editor); + editor.invalidate = () => { + invalidations.count += 1; + originalInvalidate(); + }; + try { + editor.setText("draft text"); + const rendersBefore = editor.render(80).length; + + for (let cycle = 0; cycle < 4; cycle += 1) { + // Production-shaped open (SelectorController.showSelector): + // detach the reusable composer, then clear() disposes only the + // transient overlay, then mount it. + const overlay = new Container(); + editorContainer.detachChild(editor); + editorContainer.clear(); + editorContainer.addChild(overlay); + // Production-shaped close: pet-aware composer restore. + widget.remountComposer(); + // Tab-width toggle: exactly one invalidate per change while the + // editor's listener is live. + setDefaultTabWidth(otherWidth); + setDefaultTabWidth(defaultWidth); + } + + expect(editorContainer.children).toEqual([editor]); + expect(editor.getText()).toBe("draft text"); + // All 4 cycles' tab-width toggles reached a live listener. + expect(invalidations.count).toBe(8); + expect(editor.render(80).length).toBe(rendersBefore); + editor.handleInput("x"); + expect(editor.getText()).toBe("draft textx"); + + // Red control for the probe itself: a genuinely disposed editor's + // listener no longer fires, so invalidations stop accruing. + const disposedCount = invalidations.count; + editorContainer.clear(); + editorContainer.addChild(editor); + setDefaultTabWidth(otherWidth); + setDefaultTabWidth(defaultWidth); + expect(invalidations.count).toBe(disposedCount); + } finally { + setDefaultTabWidth(defaultWidth); + widget.dispose(); + // The red control above disposes the editor through clear(); call + // dispose() again so the fixture's tab-width listener is always + // torn down (dispose is terminal and idempotent). + editor.dispose(); + } + }); it("retains emitted predecessor cleanup across an unavailable terminal takeover", () => { const stubs = makeStubs(); const make = () => diff --git a/packages/coding-agent/test/input-controller-keybindings.test.ts b/packages/coding-agent/test/input-controller-keybindings.test.ts index 6ab800ffcc..610d884374 100644 --- a/packages/coding-agent/test/input-controller-keybindings.test.ts +++ b/packages/coding-agent/test/input-controller-keybindings.test.ts @@ -3,10 +3,13 @@ import { afterEach, beforeAll, describe, expect, it, type Mock, vi } from "bun:t import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; +import { Container } from "@gajae-code/tui"; +import { getDefaultTabWidth, setDefaultTabWidth } from "@gajae-code/utils"; import { defaultEditorTheme } from "../../tui/test/test-themes"; import { formatKeyHint as formatKeyHintForPlatform } from "../src/config/keybindings"; import { resetSettingsForTest, Settings } from "../src/config/settings"; import { CustomEditor, type PasteTextContext } from "../src/modes/components/custom-editor"; +import { GajaePetWidget, PetFramedEditor } from "../src/modes/components/gajae-pet-widget"; import { QueuedMessageSelectorComponent } from "../src/modes/components/queued-message-selector"; import { InputController } from "../src/modes/controllers/input-controller"; import { initTheme } from "../src/modes/theme/theme"; @@ -198,6 +201,10 @@ async function createContext(options?: { clear: vi.fn(() => { editorContainerChildren.length = 0; }), + detachChild: vi.fn((child: unknown) => { + const index = editorContainerChildren.indexOf(child); + if (index !== -1) editorContainerChildren.splice(index, 1); + }), addChild: vi.fn((child: unknown) => { editorContainerChildren.push(child); }), @@ -710,6 +717,307 @@ describe("InputController keybinding setup", () => { expect(spies.updatePendingMessagesDisplay).toHaveBeenCalledTimes(1); }); + it("restores the pet-aware composer after closing the queued-message selector", async () => { + // Active pet session: the mounted composer child is PetFramedEditor, so + // the queued-message close must route through ctx.restoreComposer() + // (pet-aware) instead of re-adding the raw editor. This pins the + // reserve/frame contract for the queue selector close path. + const { InputController, ctx, editor, queues } = await createContext(); + const restoreComposer = vi.fn(() => { + // Pet-aware stand-in: InteractiveMode.restoreComposer remounts the + // composer (the framed wrapper in an active pet session). + queues.editorContainerChildren.length = 0; + queues.editorContainerChildren.push(editor); + }); + Object.assign(ctx, { restoreComposer }); + queues.sessionQueuedMessages.push("older session queue", "newest session queue"); + editor.setText("current draft"); + const controller = new InputController(ctx); + + controller.handleDequeue(); + expect(restoreComposer).not.toHaveBeenCalled(); + expect(queues.editorContainerChildren[0]).toBeInstanceOf(QueuedMessageSelectorComponent); + + const selector = queues.editorContainerChildren[0]; + if (!(selector instanceof QueuedMessageSelectorComponent)) { + throw new Error("Expected queued message selector to be shown"); + } + selector.handleInput("\x1b"); + + expect(restoreComposer).toHaveBeenCalledTimes(1); + expect(queues.editorContainerChildren).toEqual([editor]); + }); + + it("restores the pet-aware composer after deleting the last queued message through the selector", async () => { + const { InputController, ctx, editor, queues } = await createContext(); + const restoreComposer = vi.fn(() => { + queues.editorContainerChildren.length = 0; + queues.editorContainerChildren.push(editor); + }); + Object.assign(ctx, { restoreComposer }); + queues.sessionQueuedMessages.push("older session queue", "newest session queue"); + editor.setText("current draft"); + const controller = new InputController(ctx); + + controller.handleDequeue(); + + const selector = queues.editorContainerChildren[0]; + if (!(selector instanceof QueuedMessageSelectorComponent)) { + throw new Error("Expected queued message selector to be shown"); + } + // Delete re-opens with the remaining entry; capture the re-opened + // selector (ids are re-derived from the live queue) before deleting it. + selector.handleInput("\x1b[3~"); + const reopened = queues.editorContainerChildren[0]; + if (!(reopened instanceof QueuedMessageSelectorComponent)) { + throw new Error("Expected the queued message selector to re-open"); + } + reopened.handleInput("\x1b[3~"); + + // Deleting the last remaining entry closes the selector through the + // pet-aware restore, not the raw editor swap. + expect(restoreComposer).toHaveBeenCalledTimes(1); + expect(queues.editorContainerChildren).toEqual([editor]); + expect(queues.sessionQueuedMessages).toEqual([]); + }); + + it("restores the pet-aware composer after moving the last queued message out through the selector", async () => { + const { InputController, ctx, editor, queues } = await createContext(); + const restoreComposer = vi.fn(() => { + queues.editorContainerChildren.length = 0; + queues.editorContainerChildren.push(editor); + }); + Object.assign(ctx, { restoreComposer }); + queues.sessionQueuedMessages.push("older session queue", "newest session queue"); + editor.setText("current draft"); + const controller = new InputController(ctx); + + controller.handleDequeue(); + + const selector = queues.editorContainerChildren[0]; + if (!(selector instanceof QueuedMessageSelectorComponent)) { + throw new Error("Expected queued message selector to be shown"); + } + // Move the newest entry down (a real reorder); the selector re-opens + // with the reordered queue, and deleting the last entry then closes + // through the pet-aware restore. + selector.handleInput("\x1b[1;5B"); + const reopened = queues.editorContainerChildren[0]; + if (!(reopened instanceof QueuedMessageSelectorComponent)) { + throw new Error("Expected the queued message selector to re-open"); + } + reopened.handleInput("\x1b[3~"); + const reopenedAgain = queues.editorContainerChildren[0]; + if (!(reopenedAgain instanceof QueuedMessageSelectorComponent)) { + throw new Error("Expected the queued message selector to re-open"); + } + reopenedAgain.handleInput("\x1b[3~"); + + expect(restoreComposer).toHaveBeenCalledTimes(1); + expect(queues.editorContainerChildren).toEqual([editor]); + expect(queues.sessionQueuedMessages).toEqual([]); + }); + + it("falls back to the plain editor swap when restoreComposer is absent", async () => { + // Off mode / lightweight doubles: ctx.restoreComposer is absent, so the + // close falls back to the plain detach/clear/re-add swap and still + // restores focus on the raw editor. + const { InputController, ctx, editor, queues } = await createContext(); + queues.sessionQueuedMessages.push("older session queue", "newest session queue"); + editor.setText("current draft"); + const controller = new InputController(ctx); + + controller.handleDequeue(); + + const selector = queues.editorContainerChildren[0]; + if (!(selector instanceof QueuedMessageSelectorComponent)) { + throw new Error("Expected queued message selector to be shown"); + } + selector.handleInput("\x1b"); + + expect(queues.editorContainerChildren).toEqual([editor]); + expect(ctx.ui.setFocus).toHaveBeenCalledWith(editor); + }); + + it("keeps a real composer alive across queued-message selector open/restore with real disposal semantics", async () => { + // Real Container + CustomEditor (not the array-double): Container.clear() + // disposes attached children terminally, so this regression proves the + // queued-message open path detaches the reusable editor before clear() + // and the restored composer keeps its tab-width listener and input state. + const { InputController, ctx, queues } = await createContext(); + const editor = new CustomEditor(defaultEditorTheme); + const editorContainer = new Container(); + editorContainer.addChild(editor); + ctx.editor = editor; + ctx.editorContainer = editorContainer as unknown as InteractiveModeContext["editorContainer"]; + queues.editorContainerChildren = editorContainer.children as unknown[]; + const restoreComposer = vi.fn(() => { + editorContainer.detachChild(editor); + editorContainer.clear(); + editorContainer.addChild(editor); + }); + Object.assign(ctx, { restoreComposer }); + const defaultWidth = getDefaultTabWidth(); + const otherWidth = defaultWidth === 3 ? 4 : 3; + const invalidations = { count: 0 }; + const originalInvalidate = editor.invalidate.bind(editor); + editor.invalidate = () => { + invalidations.count += 1; + originalInvalidate(); + }; + try { + editor.setText("current draft"); + queues.sessionQueuedMessages.push("older session queue", "newest session queue"); + const controller = new InputController(ctx); + + for (let cycle = 0; cycle < 3; cycle += 1) { + controller.handleDequeue(); + const selector = editorContainer.children[0]; + if (!(selector instanceof QueuedMessageSelectorComponent)) { + throw new Error("Expected queued message selector to be shown"); + } + expect(editorContainer.children).toEqual([selector]); + selector.handleInput("\x1b"); + + expect(restoreComposer).toHaveBeenCalledTimes(cycle + 1); + expect(editorContainer.children).toEqual([editor]); + setDefaultTabWidth(otherWidth); + setDefaultTabWidth(defaultWidth); + } + + // Every cycle's tab-width toggles reached a live listener; under the + // old clear()-with-editor-attached bug, cycle 2+ would add nothing. + expect(invalidations.count).toBeGreaterThan(3); + editor.handleInput("x"); + expect(editor.getText()).toBe("current draftx"); + } finally { + setDefaultTabWidth(defaultWidth); + editor.dispose(); + } + }); + + it("preserves the pet-framed composer across queued-message selector transitions in an active pet session", async () => { + // Controller-level active-pet regression: the mounted child is + // PetFramedEditor, so the queued-selector open must detach the framed + // composition (not just the raw editor) and the close must remount it + // through the pet-aware restore. Exercises cancel, final-item delete, + // and move/refresh, then proves the composer still accepts input and + // receives tab-width invalidations. + const { InputController, ctx, queues } = await createContext(); + const editor = new CustomEditor(defaultEditorTheme); + const editorContainer = new Container(); + const floorContainer = new Container(); + editorContainer.addChild(editor); + const petUi = { + requestRender: () => {}, + setPostRenderEmitter: () => {}, + queueTerminalCleanup: () => {}, + get terminalAvailable() { + return false; + }, + get isRunning() { + return true; + }, + terminal: { columns: 80, rows: 30, write: () => {} }, + acquireRasterLease: async () => ({ status: "denied" as const }), + invalidateRasterLease: async () => {}, + writeRasterOutput: async () => ({ status: "written" as const }), + }; + const widget = new GajaePetWidget({ + ui: petUi as unknown as never, + editor, + editorContainer, + floorContainer, + isWorking: () => false, + getComposerBottomOffset: () => floorContainer.render(80).length, + syncManagedItermCursor: async () => true, + forcePixelProtocol: "sixel", + autoFlexGapMs: null, + }); + // Active pet: mounts the framed composition and claims the emitter. + widget.setMode("red"); + const framed = editorContainer.children[0]; + if (!(framed instanceof PetFramedEditor)) { + widget.dispose(); + editor.dispose(); + throw new Error("Expected the active pet to mount PetFramedEditor"); + } + ctx.editor = editor; + ctx.editorContainer = editorContainer as unknown as InteractiveModeContext["editorContainer"]; + queues.editorContainerChildren = editorContainer.children as unknown[]; + // Pet-aware host contract, exactly as InteractiveMode implements it. + Object.assign(ctx, { + detachComposer: () => widget.detachComposer(), + restoreComposer: () => widget.remountComposer(), + }); + const defaultWidth = getDefaultTabWidth(); + const otherWidth = defaultWidth === 3 ? 4 : 3; + const invalidations = { count: 0 }; + const originalInvalidate = editor.invalidate.bind(editor); + editor.invalidate = () => { + invalidations.count += 1; + originalInvalidate(); + }; + try { + editor.setText("current draft"); + + // Cancel: the framed composition survives the overlay round-trip. + queues.sessionQueuedMessages.push("older session queue", "newest session queue"); + const controller = new InputController(ctx); + controller.handleDequeue(); + let selector = editorContainer.children[0]; + if (!(selector instanceof QueuedMessageSelectorComponent)) { + throw new Error("Expected queued message selector to be shown"); + } + selector.handleInput("\x1b"); + expect(editorContainer.children[0]).toBe(framed); + expect(editorContainer.children).toHaveLength(1); + + // Final-item delete: delete down to empty, still framed. + controller.handleDequeue(); + selector = editorContainer.children[0]; + if (!(selector instanceof QueuedMessageSelectorComponent)) { + throw new Error("Expected queued message selector to re-open"); + } + selector.handleInput("\x1b[3~"); + let reopened = editorContainer.children[0]; + if (!(reopened instanceof QueuedMessageSelectorComponent)) { + throw new Error("Expected queued message selector to re-open after delete"); + } + reopened.handleInput("\x1b[3~"); + expect(queues.sessionQueuedMessages).toEqual([]); + expect(editorContainer.children[0]).toBe(framed); + + // Move/refresh: requeue, reorder through the selector, then cancel. + queues.sessionQueuedMessages.push("first queued message", "second queued message"); + controller.handleDequeue(); + selector = editorContainer.children[0]; + if (!(selector instanceof QueuedMessageSelectorComponent)) { + throw new Error("Expected queued message selector to be shown"); + } + selector.handleInput("\x1b[1;5B"); + reopened = editorContainer.children[0]; + if (!(reopened instanceof QueuedMessageSelectorComponent)) { + throw new Error("Expected queued message selector to re-open after move"); + } + reopened.handleInput("\x1b"); + expect(editorContainer.children[0]).toBe(framed); + + // The composer still accepts input and its tab-width listener is + // live (a disposed editor's listener would stop firing). + const before = invalidations.count; + setDefaultTabWidth(otherWidth); + setDefaultTabWidth(defaultWidth); + expect(invalidations.count).toBeGreaterThan(before); + editor.handleInput("x"); + expect(editor.getText()).toBe("current draftx"); + } finally { + setDefaultTabWidth(defaultWidth); + widget.dispose(); + editor.dispose(); + } + }); + it("deletes the selected queued message from the selector", async () => { const { InputController, ctx, editor, spies, queues } = await createContext(); queues.sessionQueuedMessages.push("older session queue", "newest session queue"); diff --git a/packages/coding-agent/test/login-preset-recommendation.test.ts b/packages/coding-agent/test/login-preset-recommendation.test.ts index c669536c81..c25073a3e7 100644 --- a/packages/coding-agent/test/login-preset-recommendation.test.ts +++ b/packages/coding-agent/test/login-preset-recommendation.test.ts @@ -99,7 +99,7 @@ function createControllerContext( }; const ctx = { ui: { setFocus: vi.fn(), requestRender: vi.fn() }, - editorContainer: { clear: vi.fn(), addChild: vi.fn() }, + editorContainer: { clear: vi.fn(), detachChild: vi.fn(), addChild: vi.fn() }, editor: {}, settings, session, diff --git a/packages/coding-agent/test/model-selector-controller-batch.test.ts b/packages/coding-agent/test/model-selector-controller-batch.test.ts index 7f808c10b4..4bb4b380f0 100644 --- a/packages/coding-agent/test/model-selector-controller-batch.test.ts +++ b/packages/coding-agent/test/model-selector-controller-batch.test.ts @@ -124,7 +124,7 @@ function createControllerContext() { }; const ctx = { ui: { setFocus: vi.fn(), requestRender: vi.fn() }, - editorContainer: { clear: vi.fn(), addChild: vi.fn() }, + editorContainer: { clear: vi.fn(), detachChild: vi.fn(), addChild: vi.fn() }, editor: {}, settings, session, diff --git a/packages/coding-agent/test/model-selector-profiles-redteam.test.ts b/packages/coding-agent/test/model-selector-profiles-redteam.test.ts index f354bf72aa..32ebcc1d55 100644 --- a/packages/coding-agent/test/model-selector-profiles-redteam.test.ts +++ b/packages/coding-agent/test/model-selector-profiles-redteam.test.ts @@ -124,7 +124,7 @@ function createControllerContext(options: { missingCredentials?: boolean } = {}) }; const ctx = { ui: { setFocus: vi.fn(), requestRender: vi.fn() }, - editorContainer: { clear: vi.fn(), addChild: vi.fn() }, + editorContainer: { clear: vi.fn(), detachChild: vi.fn(), addChild: vi.fn() }, editor: {}, settings, session, @@ -353,7 +353,7 @@ test("delete action restores the profile when post-delete notification fails", a const settings = Settings.isolated({ "modelProfile.default": "unrelated" }); const ctx = { ui: { setFocus: vi.fn(), requestRender: vi.fn() }, - editorContainer: { clear: vi.fn(), addChild: vi.fn() }, + editorContainer: { clear: vi.fn(), detachChild: vi.fn(), addChild: vi.fn() }, editor: {}, settings, session: { diff --git a/packages/coding-agent/test/model-selector-profiles.test.ts b/packages/coding-agent/test/model-selector-profiles.test.ts index 698189ce71..373baff22f 100644 --- a/packages/coding-agent/test/model-selector-profiles.test.ts +++ b/packages/coding-agent/test/model-selector-profiles.test.ts @@ -141,7 +141,7 @@ function createControllerContext(options: { missingCredentials?: boolean } = {}) }; const ctx = { ui: { setFocus: vi.fn(), requestRender: vi.fn() }, - editorContainer: { clear: vi.fn(), addChild: vi.fn() }, + editorContainer: { clear: vi.fn(), detachChild: vi.fn(), addChild: vi.fn() }, editor: {}, settings, session, diff --git a/packages/coding-agent/test/modes/components/theme-selector-input.test.ts b/packages/coding-agent/test/modes/components/theme-selector-input.test.ts index bdb20cb784..4755768a00 100644 --- a/packages/coding-agent/test/modes/components/theme-selector-input.test.ts +++ b/packages/coding-agent/test/modes/components/theme-selector-input.test.ts @@ -120,6 +120,10 @@ describe("ThemeSelectorComponent input handling", () => { clear() { this.children = []; }, + detachChild(child: unknown) { + const index = this.children.indexOf(child); + if (index !== -1) this.children.splice(index, 1); + }, addChild(child: unknown) { this.children.push(child); }, @@ -170,6 +174,10 @@ describe("ThemeSelectorComponent input handling", () => { clear() { this.children = []; }, + detachChild(child: unknown) { + const index = this.children.indexOf(child); + if (index !== -1) this.children.splice(index, 1); + }, addChild(child: unknown) { this.children.push(child); }, diff --git a/packages/coding-agent/test/modes/components/thinking-selector.test.ts b/packages/coding-agent/test/modes/components/thinking-selector.test.ts index 7d80fd84e5..2737a1ad3e 100644 --- a/packages/coding-agent/test/modes/components/thinking-selector.test.ts +++ b/packages/coding-agent/test/modes/components/thinking-selector.test.ts @@ -87,6 +87,10 @@ describe("SelectorController effort selector", () => { clear() { this.children = []; }, + detachChild(child: unknown) { + const index = this.children.indexOf(child); + if (index !== -1) this.children.splice(index, 1); + }, addChild(child: unknown) { this.children.push(child); }, @@ -148,6 +152,10 @@ describe("SelectorController effort selector", () => { clear() { this.children = []; }, + detachChild(child: unknown) { + const index = this.children.indexOf(child); + if (index !== -1) this.children.splice(index, 1); + }, addChild(child: unknown) { this.children.push(child); }, @@ -205,6 +213,10 @@ describe("SelectorController effort selector", () => { clear() { this.children = []; }, + detachChild(child: unknown) { + const index = this.children.indexOf(child); + if (index !== -1) this.children.splice(index, 1); + }, addChild(child: unknown) { this.children.push(child); }, @@ -245,6 +257,10 @@ describe("SelectorController effort selector", () => { clear() { this.children = []; }, + detachChild(child: unknown) { + const index = this.children.indexOf(child); + if (index !== -1) this.children.splice(index, 1); + }, addChild(child: unknown) { this.children.push(child); }, diff --git a/packages/coding-agent/test/modes/controllers/selector-controller-command-palette.test.ts b/packages/coding-agent/test/modes/controllers/selector-controller-command-palette.test.ts index e8dd56fb52..ed731dfc27 100644 --- a/packages/coding-agent/test/modes/controllers/selector-controller-command-palette.test.ts +++ b/packages/coding-agent/test/modes/controllers/selector-controller-command-palette.test.ts @@ -6,7 +6,7 @@ import type { SlashCommand } from "@gajae-code/tui"; describe("SelectorController command palette", () => { it("surfaces rejected handlers without an unhandled rejection", async () => { - const component = { clear: vi.fn(), addChild: vi.fn() }; + const component = { clear: vi.fn(), detachChild: vi.fn(), addChild: vi.fn() }; const errorShown = Promise.withResolvers(); const showError = vi.fn(() => errorShown.resolve()); const ctx = { @@ -37,7 +37,7 @@ describe("SelectorController command palette", () => { } }); it("surfaces rejected action handlers", async () => { - const component = { clear: vi.fn(), addChild: vi.fn() }; + const component = { clear: vi.fn(), detachChild: vi.fn(), addChild: vi.fn() }; const errorShown = Promise.withResolvers(); const showError = vi.fn(() => errorShown.resolve()); const ctx = { @@ -70,7 +70,7 @@ describe("SelectorController command palette", () => { expect(showError).toHaveBeenCalledWith("external editor failed"); }); it("uses effective display strings and omits unbound action shortcuts", () => { - const component = { clear: vi.fn(), addChild: vi.fn() }; + const component = { clear: vi.fn(), detachChild: vi.fn(), addChild: vi.fn() }; const keybindings = { getDisplayString(action: string) { return ( diff --git a/packages/coding-agent/test/modes/controllers/selector-controller-resume-model.test.ts b/packages/coding-agent/test/modes/controllers/selector-controller-resume-model.test.ts index 59ad7253b9..0163647f79 100644 --- a/packages/coding-agent/test/modes/controllers/selector-controller-resume-model.test.ts +++ b/packages/coding-agent/test/modes/controllers/selector-controller-resume-model.test.ts @@ -25,6 +25,10 @@ function createContext(options: { clear() { this.children = []; }, + detachChild(child: unknown) { + const index = this.children.indexOf(child); + if (index !== -1) this.children.splice(index, 1); + }, addChild(child: unknown) { this.children.push(child); }, diff --git a/packages/coding-agent/test/modes/controllers/selector-controller-session-delete.test.ts b/packages/coding-agent/test/modes/controllers/selector-controller-session-delete.test.ts index eece442309..dd87a704fd 100644 --- a/packages/coding-agent/test/modes/controllers/selector-controller-session-delete.test.ts +++ b/packages/coding-agent/test/modes/controllers/selector-controller-session-delete.test.ts @@ -52,6 +52,13 @@ function createContext(currentSessionFile: string): { this.children = []; calls.push("editorContainer.clear"); }, + detachChild(child: unknown) { + const index = this.children.indexOf(child); + if (index !== -1) { + this.children.splice(index, 1); + calls.push("editorContainer.detachChild"); + } + }, addChild(child: unknown) { this.children.push(child); calls.push("editorContainer.addChild"); diff --git a/packages/coding-agent/test/modes/controllers/share-command-temp-security.test.ts b/packages/coding-agent/test/modes/controllers/share-command-temp-security.test.ts index ea22361bb2..1a16ea4a9b 100644 --- a/packages/coding-agent/test/modes/controllers/share-command-temp-security.test.ts +++ b/packages/coding-agent/test/modes/controllers/share-command-temp-security.test.ts @@ -17,6 +17,10 @@ function createContainer() { addChild(child: unknown) { this.children.push(child); }, + detachChild(child: unknown) { + const index = this.children.indexOf(child); + if (index !== -1) this.children.splice(index, 1); + }, clear() { this.children = []; }, @@ -38,6 +42,28 @@ function createContext(exportToHtml: (file: string) => Promise) { return { ctx, editor, editorContainer }; } +/** Pet-aware host: the composer is restored through restoreComposer, never the raw editor. */ +function createPetContext(exportToHtml: (file: string) => Promise) { + const editor = { id: "core-editor" }; + const editorContainer = createContainer(); + editorContainer.addChild(editor); + const restoreComposer = vi.fn(() => { + editorContainer.clear(); + editorContainer.addChild({ id: "pet-framed-editor" }); + }); + const ctx = { + session: { exportToHtml: vi.fn(exportToHtml) }, + editor, + editorContainer, + ui: { setFocus: vi.fn(), requestRender: vi.fn() }, + showError: vi.fn(), + showStatus: vi.fn(), + restoreComposer, + detachComposer: vi.fn(), + } as unknown as InteractiveModeContext; + return { ctx, editor, editorContainer, restoreComposer }; +} + async function waitForFile(file: string): Promise { for (let attempt = 0; attempt < 400; attempt++) { if (await Bun.file(file).exists()) return; @@ -206,4 +232,64 @@ process.stdout.write("https://gist.github.com/example/deadbeef\\n"); await fs.rm(root, { recursive: true, force: true }); }, ); + + it("restores the composer through the pet-aware authority after a custom share", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-share-pet-test-")); + const agentDir = path.join(root, "agent"); + await fs.mkdir(agentDir); + setAgentDir(agentDir); + await Bun.write( + path.join(agentDir, "share.ts"), + `export default async () => { + await Bun.sleep(1); + return "https://example.test/share"; +}; +`, + ); + const { ctx, editor, editorContainer, restoreComposer } = createPetContext(async file => { + await Bun.write(file, "session"); + }); + + await new CommandController(ctx).handleShareCommand(); + + expect(restoreComposer).toHaveBeenCalledTimes(1); + expect(ctx.showStatus).toHaveBeenCalledWith("Share URL: https://example.test/share"); + // The pet-aware restore remounted the framed composition, never the raw editor. + expect(editorContainer.children).not.toContain(editor); + expect((editorContainer.children[0] as { id?: string }).id).toBe("pet-framed-editor"); + await fs.rm(root, { recursive: true, force: true }); + }); + + it.skipIf(process.platform === "win32")( + "restores the composer through the pet-aware authority after a gist upload", + async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-gist-pet-test-")); + const binDir = path.join(root, "bin"); + const fakeGh = path.join(binDir, "gh"); + await fs.mkdir(binDir); + await Bun.write( + fakeGh, + `#!/usr/bin/env bun +if (Bun.argv[2] === "auth") process.exit(0); +process.stdout.write("https://gist.github.com/example/deadbeef\\n"); +`, + ); + await fs.chmod(fakeGh, 0o755); + process.env.PATH = `${binDir}:${originalPath ?? ""}`; + + const { ctx, editor, editorContainer, restoreComposer } = createPetContext(async file => { + await Bun.write(file, "session"); + }); + + await new CommandController(ctx).handleShareCommand(); + + expect(restoreComposer).toHaveBeenCalledTimes(1); + expect(ctx.showStatus).toHaveBeenCalledWith( + expect.stringContaining("https://gist.github.com/example/deadbeef"), + ); + expect(editorContainer.children).not.toContain(editor); + expect((editorContainer.children[0] as { id?: string }).id).toBe("pet-framed-editor"); + await fs.rm(root, { recursive: true, force: true }); + }, + ); }); diff --git a/packages/coding-agent/test/provider-onboarding-wizard-redteam.test.ts b/packages/coding-agent/test/provider-onboarding-wizard-redteam.test.ts index 8eb221bceb..3a883e3a3c 100644 --- a/packages/coding-agent/test/provider-onboarding-wizard-redteam.test.ts +++ b/packages/coding-agent/test/provider-onboarding-wizard-redteam.test.ts @@ -321,6 +321,10 @@ function createControllerContext( clear: () => { children.length = 0; }, + detachChild: (child: unknown) => { + const index = children.indexOf(child); + if (index !== -1) children.splice(index, 1); + }, addChild: (child: unknown) => { children.push(child); }, diff --git a/packages/coding-agent/test/provider-onboarding-wizard.test.ts b/packages/coding-agent/test/provider-onboarding-wizard.test.ts index 6deb4e06a0..bec9523d9d 100644 --- a/packages/coding-agent/test/provider-onboarding-wizard.test.ts +++ b/packages/coding-agent/test/provider-onboarding-wizard.test.ts @@ -264,6 +264,10 @@ function createControllerContext( clear: () => { children.length = 0; }, + detachChild: (child: unknown) => { + const index = children.indexOf(child); + if (index !== -1) children.splice(index, 1); + }, addChild: (child: unknown) => { children.push(child); }, diff --git a/packages/coding-agent/test/qa-pet-close-path-redteam.test.ts b/packages/coding-agent/test/qa-pet-close-path-redteam.test.ts new file mode 100644 index 0000000000..5735d48a4f --- /dev/null +++ b/packages/coding-agent/test/qa-pet-close-path-redteam.test.ts @@ -0,0 +1,230 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from "bun:test"; +import { JobsOverlayComponent } from "../src/modes/components/jobs-overlay"; +import { TasksPaneComponent } from "../src/modes/components/tasks-pane"; +import { MCPCommandController } from "../src/modes/controllers/runtime-mcp-command-controller"; +import { SelectorController } from "../src/modes/controllers/selector-controller"; +import { getThemeByName, setThemeInstance } from "../src/modes/theme/theme"; +import type { InteractiveModeContext } from "../src/modes/types"; + +beforeAll(async () => { + const loadedTheme = await getThemeByName("red-claw"); + if (!loadedTheme) { + throw new Error("Failed to load dark theme for tests"); + } + setThemeInstance(loadedTheme); +}); +type TestEditor = { + id: string; +}; + +type TestEditorContainer = { + children: unknown[]; + clearCount: number; + clear(): void; + addChild(child: unknown): void; + detachChild(child: unknown): void; +}; + +function makeEditorContainer(editor: TestEditor): TestEditorContainer { + const container: TestEditorContainer = { + children: [editor], + clearCount: 0, + clear() { + container.clearCount += 1; + container.children = []; + }, + addChild(child: unknown) { + container.children.push(child); + }, + detachChild(child: unknown) { + const index = container.children.indexOf(child); + if (index !== -1) container.children.splice(index, 1); + }, + }; + return container; +} + +type OverlayHost = { + ctx: InteractiveModeContext; + editorContainer: TestEditorContainer; + editor: TestEditor; + restoreComposer: ReturnType; + detachComposer: ReturnType; +}; + +/** + * Minimal host double: an active pet session restores the framed composer + * through ctx.restoreComposer() and never re-adds the raw editor directly. + */ +function makeOverlayHost(): OverlayHost { + const editor: TestEditor = { id: "core-editor" }; + const editorContainer = makeEditorContainer(editor); + const restoreComposer = vi.fn(() => { + editorContainer.clear(); + editorContainer.addChild({ id: "pet-framed-editor" }); + }); + const detachComposer = vi.fn(() => { + editorContainer.detachChild(editor); + }); + const ctx = { + editor, + editorContainer, + ui: { + requestRender: vi.fn(), + setFocus: vi.fn(), + }, + chatContainer: { addChild: vi.fn() }, + restoreComposer, + detachComposer, + isStopped: () => false, + } as unknown as InteractiveModeContext; + return { ctx, editorContainer, editor, restoreComposer, detachComposer }; +} + +function expectFramedRestore(host: OverlayHost, expectedRestoreCalls: number): void { + expect(host.restoreComposer).toHaveBeenCalledTimes(expectedRestoreCalls); + // The pet-aware restore remounted the framed composition, never the raw editor. + expect(host.editorContainer.children.some(child => (child as TestEditor).id === "pet-framed-editor")).toBe(true); + expect(host.editorContainer.children).not.toContain(host.editor); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("qa-pet-close-path-redteam (fix-forward for PR #4605 review findings)", () => { + it("jobs overlay close restores the pet-aware composer instead of the raw editor", () => { + const host = makeOverlayHost(); + const controller = new SelectorController(host.ctx as never); + + controller.showJobsOverlay({ + acknowledgeFailures: vi.fn(), + getSnapshot: vi.fn(() => ({ monitors: [], crons: [], failedUnacknowledged: [] })), + getMonitorOutput: vi.fn(() => ""), + } as never); + + // Open detached the composer and mounted the overlay. + expect(host.detachComposer).toHaveBeenCalledTimes(1); + expect(host.editorContainer.children[0]).toBeInstanceOf(JobsOverlayComponent); + + // Cancel from the overlay list closes through the pet-aware restore. + const overlay = host.editorContainer.children[0] as JobsOverlayComponent; + overlay.handleInput("\x1b"); + + expectFramedRestore(host, 1); + }); + + it("tasks pane close restores the pet-aware composer instead of the raw editor", () => { + const host = makeOverlayHost(); + const controller = new SelectorController(host.ctx as never); + + controller.showTasksPane({ + acknowledgeFailures: vi.fn(), + getSnapshot: vi.fn(() => ({ rows: [] })), + onChange: vi.fn(() => () => {}), + } as never); + + expect(host.detachComposer).toHaveBeenCalledTimes(1); + expect(host.editorContainer.children[0]).toBeInstanceOf(TasksPaneComponent); + + const pane = host.editorContainer.children[0] as TasksPaneComponent; + // Cancel closes the pane through the pet-aware restore. + pane.handleInput("\x1b"); + + expectFramedRestore(host, 1); + }); + + it("MCP add wizard close restores the pet-aware composer instead of the raw editor", async () => { + const host = makeOverlayHost(); + const controller = new MCPCommandController(host.ctx as never); + + // "/mcp add" with no args opens the wizard overlay. + await controller.handle("mcp add"); + + const wizard = host.editorContainer.children[0]; + expect(wizard).toBeDefined(); + expect(host.detachComposer).toHaveBeenCalledTimes(1); + + // Cancel through the wizard's cancel path closes through the pet-aware restore. + const handleInput = (wizard as { handleInput: (data: string) => void }).handleInput.bind(wizard); + handleInput("\x1b"); + handleInput("\r"); + + expectFramedRestore(host, 1); + }); + + it("OAuth manual-code submit restores the pet-aware composer instead of the raw editor", async () => { + const host = makeOverlayHost(); + const controller = new SelectorController(host.ctx as never); + + let capturedPrompt: ((prompt: { message: string; placeholder?: string }) => Promise) | undefined; + const login = vi.fn( + async ( + _providerId: string, + handlers: { + onAuth: (info: { url: string }) => void; + onPrompt: (prompt: { message: string; placeholder?: string }) => Promise; + }, + ) => { + capturedPrompt = handlers.onPrompt; + handlers.onAuth({ url: "https://example.com/oauth" }); + }, + ); + const ctx = host.ctx as unknown as Record; + ctx.session = { + modelRegistry: { + authStorage: { + login, + listCredentialInventory: vi.fn(() => []), + listCredentialRemovalTargets: vi.fn(() => []), + }, + refresh: vi.fn(async () => {}), + getModelProfiles: vi.fn(() => new Map()), + }, + setCredentialPin: vi.fn(async () => {}), + setCredentialAuto: vi.fn(async () => {}), + }; + ctx.oauthManualInput = { clear: vi.fn(), waitForInput: vi.fn(async () => "unused") }; + ctx.showStatus = vi.fn(); + ctx.showError = vi.fn(); + + // anthropic is a callback-server provider, so login uses the manual + // code input overlay. + await controller.showOAuthSelector("login", "anthropic"); + + // onAuth ran without an overlay; the prompt opens the code input. + expect(capturedPrompt).toBeDefined(); + const promptPromise = capturedPrompt!({ message: "Paste the code" }); + + const codeInput = host.editorContainer.children.find( + child => typeof (child as { onSubmit?: unknown }).onSubmit === "function", + ) as { onSubmit: (data?: string) => void; handleInput: (data: string) => void; getValue?: () => string }; + expect(codeInput).toBeDefined(); + codeInput.handleInput("a"); + codeInput.onSubmit(); + + await expect(promptPromise).resolves.toBe("a"); + expectFramedRestore(host, 1); + }); + + it("jobs/tasks close paths keep working for hosts that predate restoreComposer (fallback)", () => { + const host = makeOverlayHost(); + const legacyHost = { + ...host, + ctx: { ...host.ctx, restoreComposer: undefined } as unknown as InteractiveModeContext, + }; + const controller = new SelectorController(legacyHost.ctx as never); + + controller.showJobsOverlay({ + acknowledgeFailures: vi.fn(), + getSnapshot: vi.fn(() => ({ monitors: [], crons: [], failedUnacknowledged: [] })), + getMonitorOutput: vi.fn(() => ""), + } as never); + + const overlay = legacyHost.editorContainer.children[0] as JobsOverlayComponent; + overlay.handleInput("\x1b"); + + // Fallback swaps the raw editor back in. + expect(legacyHost.editorContainer.children).toEqual([legacyHost.editor]); + }); +});