fix(coding-agent): fixed stale loader recovery and updated catalog model constraints

- Stopped and cleared the working loader before auto-compaction and auto-retry.
- Ensured stale loadingAnimation is removed so agent_start recreates the Working loader.
- Set catalog model input/output costs to 0.09/0.18 and reduced maxTokens to 65536.
This commit is contained in:
can1357
2026-06-14 06:58:37 +02:00
parent 8e2d3b790f
commit e2e37dcde6
7 changed files with 403 additions and 15 deletions
+4
View File
@@ -1,6 +1,10 @@
# Changelog
## [Unreleased]
### Changed
- Changed catalog metadata to update a model’s per-token pricing to input 0.09 and output 0.18
- Changed the same cataloged model’s maximum token limit from 384000 to 65536
### Fixed
+3 -3
View File
@@ -55024,13 +55024,13 @@
"text"
],
"cost": {
"input": 0.098,
"output": 0.196,
"input": 0.09,
"output": 0.18,
"cacheRead": 0.02,
"cacheWrite": 0
},
"contextWindow": 1048576,
"maxTokens": 384000,
"maxTokens": 65536,
"thinking": {
"mode": "effort",
"efforts": [
+1
View File
@@ -36,6 +36,7 @@
- Fixed HTML session export rendering empty text tokens (`text`, `userMessageText`, `customMessageText`, `toolTitle`) as the dark-theme grey `#e5e5e7` on every theme not literally named `light`, making transcripts illegible on custom light themes like `sandstone`, `limestone`, and `porcelain`. `getResolvedThemeColors` and the standalone `isLightTheme` helper now classify against the resolved `statusLineBg` luminance (the same surface `Theme.isLight` uses), so the HTML `defaultText` falls back to `#000000` on light themes and the standalone helper stays in lockstep with `Theme.isLight` ([#2516](https://github.com/can1357/oh-my-pi/issues/2516)).
- Fixed the Agent Hub opening on its own from a stray mouse click. The double-tap-← gesture (empty editor) fired on any two `left` keys within 500ms, but terminals with "click to move cursor" / pointer features (iTerm2 option-click, WezTerm, kitty, tmux) synthesize a burst of arrow keys on click — delivered sub-millisecond apart in one stdin read — so a single click could pop the hub with no key ever pressed. The gesture now requires the second tap to land a human-plausible interval after the first (≥40ms, <500ms) and ignores any third-or-later rapid tap, so synthesized bursts are rejected while a deliberate double-tap still works. The same hardening applies to the focused-subagent ←← "return to main" gesture.
- Fixed the `ctrl+p` model-role cycle indicator (the `default / gpt / fable / …` chip track) stacking duplicate copies in the scrollback when other chat activity landed between two cycles. The track was emitted through `showStatus`, whose back-to-back coalescing only merges when the previous status is still the last transcript child; any interleaved append broke that identity check and appended a second track. It now renders into a dedicated anchored container above the editor (cleared and rebuilt in place each cycle, like the Todos HUD) and auto-clears after a short linger, so rapid presses or concurrent activity can never duplicate it.
- Fixed the `Working…` loader vanishing for the rest of a turn after an auto-compaction (context-overflow recovery) or auto-retry. Those overlays took over the shared status container with a bare `statusContainer.clear()`, which detached the working loader but left `loadingAnimation` set; the resumed turn's `agent_start` → `ensureLoadingAnimation()` is guarded by `if (!this.loadingAnimation)`, so it skipped re-attaching the loader and the spinner stayed gone while the agent kept streaming. The overlay handlers now fully tear the working loader down (stop + dereference) via `#stopWorkingLoader()`, so the next `agent_start` recreates and re-attaches it.
## [15.12.6] - 2026-06-14
### Breaking Changes
@@ -840,10 +840,27 @@ export class EventController {
this.sendCompletionNotification();
}
/**
* Tear down the live "Working…" loader: stop its animation timer AND clear the
* reference. A transient overlay (auto-compaction / auto-retry) that only ran
* `statusContainer.clear()` detached the loader from the container but left
* `ctx.loadingAnimation` set, so the resumed turn's `agent_start` →
* `ensureLoadingAnimation()` (guarded by `if (!this.loadingAnimation)`) skipped
* re-adding it and the spinner vanished while the agent kept streaming. Nulling
* the reference here lets the next `agent_start` recreate and re-attach it.
*/
#stopWorkingLoader(): void {
if (this.ctx.loadingAnimation) {
this.ctx.loadingAnimation.stop();
this.ctx.loadingAnimation = undefined;
}
}
async #handleAutoCompactionStart(
event: Extract<AgentSessionEvent, { type: "auto_compaction_start" }>,
): Promise<void> {
this.#cancelIdleCompaction();
this.#stopWorkingLoader();
this.ctx.statusContainer.clear();
const reasonText =
event.reason === "overflow"
@@ -929,6 +946,7 @@ export class EventController {
}
async #handleAutoRetryStart(event: Extract<AgentSessionEvent, { type: "auto_retry_start" }>): Promise<void> {
this.#stopWorkingLoader();
this.ctx.statusContainer.clear();
const delaySeconds = Math.round(event.delayMs / 1000);
this.ctx.retryLoader = new Loader(
@@ -0,0 +1,143 @@
/**
* Large-paste menu: when a paste reaches the configured `paste.largeMenuThreshold` line count,
* the editor's `onLargePaste` hook routes through `InputController.handleLargePaste`, which offers
* to wrap the text in a code block, wrap it in XML tags, or save it to a `local://` file. Below the
* threshold (or when disabled) the editor keeps its default collapse-to-`[Paste]`-marker behavior.
*/
import { afterEach, describe, expect, it, vi } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { InputController } from "@oh-my-pi/pi-coding-agent/modes/controllers/input-controller";
import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types";
function createContext(options?: { threshold?: number; choice?: string; artifactsDir?: string }) {
const insertPaste = vi.fn();
const insertText = vi.fn();
const requestRender = vi.fn();
const showStatus = vi.fn();
const showError = vi.fn();
const showHookSelector = vi.fn(async (_title: string, _options: unknown, _dialog?: unknown) => options?.choice);
const ctx = {
editor: { insertPaste, insertText } as unknown as InteractiveModeContext["editor"],
ui: { requestRender } as unknown as InteractiveModeContext["ui"],
settings: { get: () => options?.threshold ?? 100 } as unknown as InteractiveModeContext["settings"],
sessionManager: {
getArtifactsDir: () => options?.artifactsDir ?? null,
getSessionId: () => "test-session",
} as unknown as InteractiveModeContext["sessionManager"],
showHookSelector: showHookSelector as unknown as InteractiveModeContext["showHookSelector"],
showStatus,
showError,
} as unknown as InteractiveModeContext;
const controller = new InputController(ctx);
return { controller, spies: { insertPaste, insertText, requestRender, showStatus, showError, showHookSelector } };
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("InputController.handleLargePaste gate", () => {
it("declines and skips the menu below the threshold", () => {
const { controller } = createContext({ threshold: 100 });
const menu = vi.spyOn(controller, "presentLargePasteMenu").mockResolvedValue();
expect(controller.handleLargePaste("x", 50)).toBe(false);
expect(menu).not.toHaveBeenCalled();
});
it("declines when disabled (threshold 0), even for a huge paste", () => {
const { controller } = createContext({ threshold: 0 });
const menu = vi.spyOn(controller, "presentLargePasteMenu").mockResolvedValue();
expect(controller.handleLargePaste("x", 5000)).toBe(false);
expect(menu).not.toHaveBeenCalled();
});
it("intercepts and presents the menu at the threshold", () => {
const { controller } = createContext({ threshold: 100 });
const menu = vi.spyOn(controller, "presentLargePasteMenu").mockResolvedValue();
expect(controller.handleLargePaste("payload", 100)).toBe(true);
expect(menu).toHaveBeenCalledWith("payload", 100);
});
});
describe("InputController.presentLargePasteMenu actions", () => {
it("wraps the paste in a fenced code block collapsed to a marker", async () => {
const { controller, spies } = createContext({ choice: "Wrap in a code block" });
await controller.presentLargePasteMenu("hello\nworld", 2);
expect(spies.insertPaste).toHaveBeenCalledTimes(1);
expect(spies.insertPaste.mock.calls[0][0]).toBe("```\nhello\nworld\n```");
});
it("widens the fence so an embedded code fence cannot terminate the block early", async () => {
const { controller, spies } = createContext({ choice: "Wrap in a code block" });
await controller.presentLargePasteMenu("```\ncode\n```", 3);
expect(spies.insertPaste.mock.calls[0][0]).toBe("````\n```\ncode\n```\n````");
});
it("wraps the paste in XML tags collapsed to a marker", async () => {
const { controller, spies } = createContext({ choice: "Wrap in XML tags" });
await controller.presentLargePasteMenu("payload", 1);
expect(spies.insertPaste).toHaveBeenCalledWith("<pasted_text>\npayload\n</pasted_text>");
});
it("pastes inline when the menu is cancelled, so the content is not lost", async () => {
const { controller, spies } = createContext({ choice: undefined });
await controller.presentLargePasteMenu("payload", 1);
expect(spies.insertPaste).toHaveBeenCalledWith("payload");
});
it("titles the menu with the paste's line count", async () => {
const { controller, spies } = createContext({ choice: undefined });
await controller.presentLargePasteMenu("payload", 123);
expect(spies.showHookSelector.mock.calls[0][0]).toBe("Pasted 123 lines");
});
});
describe("InputController.presentLargePasteMenu file attachment", () => {
let dir: string | undefined;
afterEach(async () => {
if (dir) await fs.rm(dir, { recursive: true, force: true });
dir = undefined;
});
it("saves the paste to local:// and inserts a clean local://attachment reference", async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-paste-test-"));
const { controller, spies } = createContext({ choice: "Attach as a file", artifactsDir: dir });
await controller.presentLargePasteMenu("line one\nline two", 2);
expect(spies.insertText).toHaveBeenCalledWith("local://attachment-1 ");
expect(spies.insertPaste).not.toHaveBeenCalled();
// resolveLocalRoot maps an artifacts dir to "<dir>/local"; the reference resolves there.
const saved = await Bun.file(path.join(dir, "local", "attachment-1")).text();
expect(saved).toBe("line one\nline two");
});
it("does not overwrite an existing attachment file", async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-paste-test-"));
await Bun.write(path.join(dir, "local", "attachment-1"), "previous");
const { controller, spies } = createContext({ choice: "Attach as a file", artifactsDir: dir });
await controller.presentLargePasteMenu("fresh", 1);
expect(spies.insertText).toHaveBeenCalledWith("local://attachment-2 ");
expect(await Bun.file(path.join(dir, "local", "attachment-1")).text()).toBe("previous");
expect(await Bun.file(path.join(dir, "local", "attachment-2")).text()).toBe("fresh");
});
});
@@ -0,0 +1,177 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, type Mock, vi } from "bun:test";
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { EventController } from "@oh-my-pi/pi-coding-agent/modes/controllers/event-controller";
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types";
import type { AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session";
interface FakeWorkingLoader {
stop: Mock<() => void>;
kind: "working";
}
/**
* Faithful model of the shared `statusContainer` + working-loader invariant that
* InteractiveMode owns:
* - `agent_start` → `ensureLoadingAnimation()` only creates+attaches the loader
* when `loadingAnimation` is unset (the real `if (!this.loadingAnimation)`
* guard), so a stale, still-referenced loader makes it a no-op.
* - A transient overlay (auto-compaction / auto-retry) takes over the container.
*
* The regression: the overlay handlers cleared the container (detaching the
* working loader) but left `loadingAnimation` set, so the resumed turn's
* `agent_start` skipped re-attaching it — "Working…" vanished while the agent
* kept streaming. The fix tears the working loader down (stop + dereference) so
* the next `agent_start` recreates and re-attaches it.
*/
function createContext() {
const streamState = { isStreaming: false };
const children: unknown[] = [];
const statusContainer = {
children,
clear() {
children.length = 0;
},
addChild(child: unknown) {
children.push(child);
},
removeChild(child: unknown) {
const index = children.indexOf(child);
if (index !== -1) children.splice(index, 1);
},
};
const workingLoaders: FakeWorkingLoader[] = [];
const ctx = {
isInitialized: true,
settings: { get: () => false },
statusLine: { invalidate: vi.fn() },
updateEditorTopBorder: vi.fn(),
pendingTools: new Map<string, unknown>(),
hideThinkingBlock: false,
setWorkingMessage: vi.fn(),
clearPinnedError: vi.fn(),
loadingAnimation: undefined,
autoCompactionLoader: undefined,
retryLoader: undefined,
streamingComponent: undefined,
streamingMessage: undefined,
statusContainer,
chatContainer: { removeChild: vi.fn(), clear: vi.fn() },
flushPendingModelSwitch: vi.fn(async () => {}),
flushCompactionQueue: vi.fn(async () => {}),
rebuildChatFromMessages: vi.fn(),
reloadTodos: vi.fn(async () => {}),
showStatus: vi.fn(),
showWarning: vi.fn(),
showError: vi.fn(),
editor: { getText: () => "" },
sessionManager: { getSessionName: () => "test-session" },
ui: { requestRender: vi.fn(), requestComponentRender: vi.fn() },
viewSession: { isCompacting: false, getLastAssistantMessage: () => undefined },
session: {
get isStreaming() {
return streamState.isStreaming;
},
getToolByName: () => undefined,
},
} as unknown as InteractiveModeContext;
ctx.ensureLoadingAnimation = vi.fn(() => {
if (ctx.loadingAnimation) return;
statusContainer.clear();
const working: FakeWorkingLoader = { stop: vi.fn(), kind: "working" };
workingLoaders.push(working);
ctx.loadingAnimation = working as unknown as typeof ctx.loadingAnimation;
statusContainer.addChild(ctx.loadingAnimation);
});
return { ctx, streamState, statusContainer, workingLoaders };
}
const AGENT_START = { type: "agent_start" } as unknown as AgentSessionEvent;
const COMPACTION_START = {
type: "auto_compaction_start",
reason: "overflow",
action: "context-full",
} as unknown as AgentSessionEvent;
const COMPACTION_END = {
type: "auto_compaction_end",
action: "context-full",
result: { summary: "s", shortSummary: "s", tokensBefore: 10, details: {}, firstKeptEntryId: undefined },
willRetry: true,
} as unknown as AgentSessionEvent;
const RETRY_START = {
type: "auto_retry_start",
attempt: 1,
maxAttempts: 3,
delayMs: 1000,
errorMessage: "overloaded",
} as unknown as AgentSessionEvent;
describe("EventController loader recovery after overflow maintenance", () => {
beforeAll(async () => {
await initTheme(false);
});
beforeEach(async () => {
resetSettingsForTest();
await Settings.init({ inMemory: true });
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
resetSettingsForTest();
});
it("re-shows the Working… loader after auto-compaction recovers and streams a new turn", async () => {
const { ctx, streamState, statusContainer, workingLoaders } = createContext();
const controller = new EventController(ctx);
// Turn 1 begins: the working loader is created and attached.
await controller.handleEvent(AGENT_START);
const firstWorking = workingLoaders[0];
expect(firstWorking).toBeDefined();
expect(statusContainer.children).toContain(ctx.loadingAnimation);
// Overflow recovery hands the status container to the auto-compaction loader.
// The original turn's agent_end is held while the prompt is in flight, so the
// session keeps reporting streaming throughout.
streamState.isStreaming = true;
await controller.handleEvent(COMPACTION_START);
// The working loader must be fully torn down — not detached-but-referenced —
// so the upcoming agent_start can recreate it.
expect(firstWorking?.stop).toHaveBeenCalled();
expect(ctx.loadingAnimation).toBeUndefined();
expect(statusContainer.children).not.toContain(firstWorking);
await controller.handleEvent(COMPACTION_END);
// The retry continuation starts a fresh turn: the loader must reappear in the
// status container so streaming shows "Working…" again (issue: it stayed gone).
await controller.handleEvent(AGENT_START);
expect(ctx.loadingAnimation).toBeDefined();
expect(statusContainer.children).toContain(ctx.loadingAnimation);
expect(workingLoaders).toHaveLength(2);
});
it("re-shows the Working… loader after an auto-retry resumes the turn", async () => {
const { ctx, streamState, statusContainer, workingLoaders } = createContext();
const controller = new EventController(ctx);
await controller.handleEvent(AGENT_START);
const firstWorking = workingLoaders[0];
expect(statusContainer.children).toContain(ctx.loadingAnimation);
// A transient error: the retry loader takes over the status container.
streamState.isStreaming = true;
await controller.handleEvent(RETRY_START);
expect(firstWorking?.stop).toHaveBeenCalled();
expect(ctx.loadingAnimation).toBeUndefined();
// The retry attempt re-enters the agent loop, emitting a fresh agent_start.
await controller.handleEvent(AGENT_START);
expect(ctx.loadingAnimation).toBeDefined();
expect(statusContainer.children).toContain(ctx.loadingAnimation);
});
});
+57 -12
View File
@@ -18,6 +18,8 @@ import { VirtualTerminal } from "./virtual-terminal";
// drag settles.
const NO_MULTIPLEXER_ENV: Record<string, string | undefined> = { TMUX: undefined, STY: undefined, ZELLIJ: undefined };
const ALT_SCREEN_ENTER = "\x1b[?1049h";
const ALT_SCREEN_EXIT = "\x1b[?1049l";
async function withEnvPatch<T>(patch: Record<string, string | undefined>, run: () => T | Promise<T>): Promise<T> {
const saved: Record<string, string | undefined> = {};
@@ -39,10 +41,10 @@ async function withEnvPatch<T>(patch: Record<string, string | undefined>, run: (
}
// Deterministic scheduler so the test drives the resize settle window itself
// instead of waiting on the wall clock. `scheduleImmediate` callbacks are the
// per-event viewport paints; `scheduleRender` callbacks are delayed timers (the
// settle). `flushImmediates` paints the mid-drag state without firing the
// settle; `flushAll` fires the settle and the authoritative replay it queues.
// instead of waiting on the wall clock. Resize viewport paints are synchronous;
// `scheduleImmediate` callbacks are ordinary follow-up renders, and
// `scheduleRender` callbacks are delayed timers (the settle). `flushAll` fires
// the settle and the authoritative replay it queues.
class DeferScheduler implements RenderScheduler {
#time = 0;
#immediates: (() => void)[] = [];
@@ -277,7 +279,52 @@ describe("non-multiplexer resize viewport fast path", () => {
});
});
it("overwrites the viewport in place mid-drag (no ED2) and still rewraps at settle", async () => {
it("uses the alternate screen during width-drag frames so terminal reflow cannot show wrapped fragments", async () => {
await withEnvPatch(NO_MULTIPLEXER_ENV, async () => {
const term = new VirtualTerminal(40, 10, 1000);
const scheduler = new DeferScheduler();
const blocks = Array.from(
{ length: 10 },
(_v, i) => new CountingBlock([`row-${i}`.padEnd(40, String(i % 10))]),
);
const expected = Array.from({ length: 10 }, (_v, i) => `row-${i}`.padEnd(20, String(i % 10)));
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
tui.addChild(new TailTranscript(blocks));
try {
tui.start();
await scheduler.flushImmediates(term);
const writes = captureWrites(term);
// Shrinking full-width normal-screen rows makes Ghostty reflow them
// into wrapped fragments before the app writes again. The resize
// handler must synchronously switch to the alternate screen and
// repaint the new-width viewport in that same write.
term.resize(20, 10);
await term.flush();
expect(tui.resizeViewportActive).toBe(true);
expect(tui.resizeViewportPaints).toBe(1);
const drag = writes.join("");
expect(drag).toContain(ALT_SCREEN_ENTER);
expect(drag).not.toContain("\x1b[2J");
expect(drag).not.toContain("\x1b[3J");
expect(visible(term)).toEqual(expected);
const dragWrites = writes.length;
await scheduler.flushAll(term);
const settle = writes.slice(dragWrites).join("");
expect(settle).toContain(ALT_SCREEN_EXIT);
expect(settle.indexOf(ALT_SCREEN_EXIT)).toBeLessThan(settle.indexOf("\x1b[3J"));
expect(visible(term)).toEqual(expected);
} finally {
tui.stop();
}
});
});
it("overwrites the viewport without a normal-screen clear mid-drag and still rewraps at settle", async () => {
await withEnvPatch(NO_MULTIPLEXER_ENV, async () => {
const term = new VirtualTerminal(40, 10, 1000);
const { tui, scheduler } = makeTui(term);
@@ -289,18 +336,16 @@ describe("non-multiplexer resize viewport fast path", () => {
// One mid-drag SIGWINCH: the fast path repaints just the viewport.
term.resize(60, 10);
await scheduler.flushImmediates(term);
expect(tui.resizeViewportActive).toBe(true);
const drag = writes.join("");
// In-place overwrite: NO ED2 full-screen clear (and no ED3) — the
// screen never blanks mid-drag, so even a terminal that ignores DEC
// 2026 synchronized output cannot show a cleared-but-unpainted frame.
// The drag frame borrows the alternate screen and performs per-row
// self-clearing rewrites there. It must not clear/replay the normal
// screen, so even terminals that expose resize reflow between app
// writes cannot show a blanked normal-screen frame.
expect(drag).toContain(ALT_SCREEN_ENTER);
expect(drag).not.toContain("\x1b[2J");
expect(drag).not.toContain("\x1b[3J");
// Home anchor + per-row self-clearing rewrites (CSI K), with the new
// viewport content in the SAME frame: each row goes old -> new with
// no intervening blank.
expect(drag).toContain("\x1b[H");
expect(drag).toContain("\x1b[K");
expect(drag).toContain("b14-y");