Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## [Unreleased]
- 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 (#4604, follow-up to the `52dad458` remount fix).
- 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)).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1247,6 +1247,9 @@ export class InputController {
}

#restoreEditorFocus(): void {
// The composer is reusable across overlays: detach (never dispose)
// before clearing so only the transient overlay is torn down.
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);
Expand Down Expand Up @@ -1308,6 +1311,9 @@ export class InputController {
),
},
);
// Detach the reusable composer before clearing so the terminal clear()
// disposes only the selector overlay, not the editor.
this.ctx.editorContainer.detachChild(this.ctx.editor);
this.ctx.editorContainer.clear();
this.ctx.editorContainer.addChild(selector);
this.ctx.ui.setFocus(selector);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1338,6 +1338,10 @@ export class SelectorController {
}
};
const { component, focus } = create(done);
// The composer is reusable across overlays; detach it before clearing so
// clear() disposes only the transient overlay, not the editor's
// tab-width listener / paste state (disposal is terminal).
this.ctx.editorContainer.detachChild(this.ctx.editor);
this.ctx.editorContainer.clear();
this.ctx.editorContainer.addChild(component);
this.ctx.ui.setFocus(focus);
Expand Down
3 changes: 3 additions & 0 deletions packages/coding-agent/src/modes/interactive-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1401,6 +1401,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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down
112 changes: 111 additions & 1 deletion packages/coding-agent/test/gajae-pet-widget.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -646,6 +667,95 @@ 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();
}
});
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();
}
});
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();
}
});
it("retains emitted predecessor cleanup across an unavailable terminal takeover", () => {
const stubs = makeStubs();
const make = () =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,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);
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/coding-agent/test/model-selector-profiles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>();
const showError = vi.fn(() => errorShown.resolve());
const ctx = {
Expand Down Expand Up @@ -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<void>();
const showError = vi.fn(() => errorShown.resolve());
const ctx = {
Expand Down Expand Up @@ -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 (
Expand Down
Loading