From e2e37dcde67e63cf80c679ada77d1399f2d21171 Mon Sep 17 00:00:00 2001 From: can1357 Date: Sun, 14 Jun 2026 06:58:37 +0200 Subject: [PATCH] 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. --- packages/catalog/CHANGELOG.md | 4 + packages/catalog/src/models.json | 6 +- packages/coding-agent/CHANGELOG.md | 1 + .../src/modes/controllers/event-controller.ts | 18 ++ .../test/input-controller-large-paste.test.ts | 143 ++++++++++++++ .../event-controller-loader-recovery.test.ts | 177 ++++++++++++++++++ .../tui/test/resize-viewport-defer.test.ts | 69 +++++-- 7 files changed, 403 insertions(+), 15 deletions(-) create mode 100644 packages/coding-agent/test/input-controller-large-paste.test.ts create mode 100644 packages/coding-agent/test/modes/controllers/event-controller-loader-recovery.test.ts diff --git a/packages/catalog/CHANGELOG.md b/packages/catalog/CHANGELOG.md index 6da4f2e37..4143198dc 100644 --- a/packages/catalog/CHANGELOG.md +++ b/packages/catalog/CHANGELOG.md @@ -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 diff --git a/packages/catalog/src/models.json b/packages/catalog/src/models.json index d0a11a76f..17010d618 100644 --- a/packages/catalog/src/models.json +++ b/packages/catalog/src/models.json @@ -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": [ diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a637207b8..c886462ca 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index bf518498a..d7b5ea8dd 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -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, ): Promise { this.#cancelIdleCompaction(); + this.#stopWorkingLoader(); this.ctx.statusContainer.clear(); const reasonText = event.reason === "overflow" @@ -929,6 +946,7 @@ export class EventController { } async #handleAutoRetryStart(event: Extract): Promise { + this.#stopWorkingLoader(); this.ctx.statusContainer.clear(); const delaySeconds = Math.round(event.delayMs / 1000); this.ctx.retryLoader = new Loader( diff --git a/packages/coding-agent/test/input-controller-large-paste.test.ts b/packages/coding-agent/test/input-controller-large-paste.test.ts new file mode 100644 index 000000000..455f5ecbd --- /dev/null +++ b/packages/coding-agent/test/input-controller-large-paste.test.ts @@ -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("\npayload\n"); + }); + + 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 "/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"); + }); +}); diff --git a/packages/coding-agent/test/modes/controllers/event-controller-loader-recovery.test.ts b/packages/coding-agent/test/modes/controllers/event-controller-loader-recovery.test.ts new file mode 100644 index 000000000..f880831dd --- /dev/null +++ b/packages/coding-agent/test/modes/controllers/event-controller-loader-recovery.test.ts @@ -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(), + 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); + }); +}); diff --git a/packages/tui/test/resize-viewport-defer.test.ts b/packages/tui/test/resize-viewport-defer.test.ts index bc3676512..a91b99632 100644 --- a/packages/tui/test/resize-viewport-defer.test.ts +++ b/packages/tui/test/resize-viewport-defer.test.ts @@ -18,6 +18,8 @@ import { VirtualTerminal } from "./virtual-terminal"; // drag settles. const NO_MULTIPLEXER_ENV: Record = { TMUX: undefined, STY: undefined, ZELLIJ: undefined }; +const ALT_SCREEN_ENTER = "\x1b[?1049h"; +const ALT_SCREEN_EXIT = "\x1b[?1049l"; async function withEnvPatch(patch: Record, run: () => T | Promise): Promise { const saved: Record = {}; @@ -39,10 +41,10 @@ async function withEnvPatch(patch: Record, 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");