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:
@@ -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
|
||||
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
+177
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user