diff --git a/packages/ai/test/auth-storage-api-key-login.test.ts b/packages/ai/test/auth-storage-api-key-login.test.ts index 111a8dc62..09a1373ec 100644 --- a/packages/ai/test/auth-storage-api-key-login.test.ts +++ b/packages/ai/test/auth-storage-api-key-login.test.ts @@ -59,7 +59,6 @@ describe("AuthStorage api-key login replacement", () => { await authStorage.login("kagi", controller); await authStorage.login("kagi", controller); - expect(loginKagiSpy).toHaveBeenCalledTimes(2); expect(countCredentialRows(dbPath, "kagi")).toBe(1); const credentials = store.listAuthCredentials("kagi"); expect(credentials).toHaveLength(1); diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index 2a36f1e9e..d0f747e50 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -23,7 +23,7 @@ import { BorderedLoader } from "../../modes/components/bordered-loader"; import { DynamicBorder } from "../../modes/components/dynamic-border"; import { PythonExecutionComponent } from "../../modes/components/python-execution"; import { getMarkdownTheme, getSymbolTheme, theme } from "../../modes/theme/theme"; -import type { ClearCommandOptions, ClearCommandRollback, InteractiveModeContext } from "../../modes/types"; +import type { InteractiveModeContext } from "../../modes/types"; import { buildHotkeysMarkdown } from "../../modes/utils/hotkeys-markdown"; import type { AsyncJobSnapshotItem } from "../../session/agent-session"; import type { AuthStorage } from "../../session/auth-storage"; @@ -561,68 +561,40 @@ export class CommandController { this.ctx.showError("Usage: /memory "); } - async handleClearCommand(options?: ClearCommandOptions): Promise { - let rollbackBeforeSwitchCheck: ClearCommandRollback | undefined; - try { - rollbackBeforeSwitchCheck = await options?.beforeSwitchCheck?.(); - - const switchApproved = await this.ctx.session.canStartNewSession(); - if (!switchApproved) { - await rollbackBeforeSwitchCheck?.(); - this.ctx.chatContainer.addChild(new Spacer(1)); - this.ctx.chatContainer.addChild(new Text(theme.fg("error", "Error: New session cancelled"), 1, 0)); - this.ctx.ui.requestRender(); - return false; - } - - rollbackBeforeSwitchCheck = undefined; - if (this.ctx.session.isCompacting) { - this.ctx.session.abortCompaction(); - while (this.ctx.session.isCompacting) { - await Bun.sleep(10); - } - } - await options?.beforeSwitch?.(); - - if (this.ctx.loadingAnimation) { - this.ctx.loadingAnimation.stop(); - this.ctx.loadingAnimation = undefined; - } - this.ctx.statusContainer.clear(); - - const success = await this.ctx.session.newSession(undefined, { skipBeforeSwitchCheck: true }); - if (!success) { - this.ctx.chatContainer.addChild(new Spacer(1)); - this.ctx.chatContainer.addChild(new Text(theme.fg("error", "Error: New session cancelled"), 1, 0)); - this.ctx.ui.requestRender(); - return false; - } - - setSessionTerminalTitle(this.ctx.sessionManager.getSessionName(), this.ctx.sessionManager.getCwd()); - - this.ctx.statusLine.invalidate(); - this.ctx.statusLine.setSessionStartTime(Date.now()); - this.ctx.updateEditorTopBorder(); - this.ctx.ui.requestRender(); - - this.ctx.chatContainer.clear(); - this.ctx.pendingMessagesContainer.clear(); - this.ctx.compactionQueuedMessages = []; - this.ctx.streamingComponent = undefined; - this.ctx.streamingMessage = undefined; - this.ctx.pendingTools.clear(); - - this.ctx.chatContainer.addChild(new Spacer(1)); - this.ctx.chatContainer.addChild( - new Text(`${theme.fg("accent", `${theme.status.success} New session started`)}`, 1, 1), - ); - await this.ctx.reloadTodos(); - this.ctx.ui.requestRender(); - return true; - } catch (error) { - await rollbackBeforeSwitchCheck?.(); - throw error; + async handleClearCommand(): Promise { + if (this.ctx.loadingAnimation) { + this.ctx.loadingAnimation.stop(); + this.ctx.loadingAnimation = undefined; } + this.ctx.statusContainer.clear(); + + if (this.ctx.session.isCompacting) { + this.ctx.session.abortCompaction(); + while (this.ctx.session.isCompacting) { + await Bun.sleep(10); + } + } + await this.ctx.session.newSession(); + setSessionTerminalTitle(this.ctx.sessionManager.getSessionName(), this.ctx.sessionManager.getCwd()); + + this.ctx.statusLine.invalidate(); + this.ctx.statusLine.setSessionStartTime(Date.now()); + this.ctx.updateEditorTopBorder(); + this.ctx.ui.requestRender(); + + this.ctx.chatContainer.clear(); + this.ctx.pendingMessagesContainer.clear(); + this.ctx.compactionQueuedMessages = []; + this.ctx.streamingComponent = undefined; + this.ctx.streamingMessage = undefined; + this.ctx.pendingTools.clear(); + + this.ctx.chatContainer.addChild(new Spacer(1)); + this.ctx.chatContainer.addChild( + new Text(`${theme.fg("accent", `${theme.status.success} New session started`)}`, 1, 1), + ); + await this.ctx.reloadTodos(); + this.ctx.ui.requestRender(); } async handleForkCommand(): Promise { diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 66ecb0ab8..caf1d8621 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -60,14 +60,7 @@ import { onThemeChange, theme, } from "./theme/theme"; -import type { - ClearCommandOptions, - CompactionQueuedMessage, - InteractiveModeContext, - SubmittedUserInput, - TodoItem, - TodoPhase, -} from "./types"; +import type { CompactionQueuedMessage, InteractiveModeContext, SubmittedUserInput, TodoItem, TodoPhase } from "./types"; import { UiHelpers } from "./utils/ui-helpers"; const EDITOR_MAX_HEIGHT_MIN = 6; @@ -703,7 +696,7 @@ export class InteractiveMode implements InteractiveModeContext { } const previousTools = this.#planModePreviousTools; - if (previousTools) { + if (previousTools && previousTools.length > 0) { await this.session.setActiveToolsByName(previousTools); } if (this.#planModePreviousModel) { @@ -754,29 +747,15 @@ export class InteractiveMode implements InteractiveModeContext { planContent: string, options: { planFilePath: string; finalPlanFilePath: string }, ): Promise { - const previousTools = this.#planModePreviousTools ?? this.session.getActiveToolNames(); - const didCreateFreshSession = await this.handleClearCommand({ - beforeSwitchCheck: () => { - this.sessionManager.setTransientModeOverride("none"); - return () => { - this.sessionManager.clearTransientModeOverride(); - }; - }, - beforeSwitch: async () => { - this.sessionManager.clearTransientModeOverride(); - await renameApprovedPlanFile({ - planFilePath: options.planFilePath, - finalPlanFilePath: options.finalPlanFilePath, - getArtifactsDir: () => this.sessionManager.getArtifactsDir(), - getSessionId: () => this.sessionManager.getSessionId(), - }); - await this.#exitPlanMode({ silent: true, paused: false }); - }, + await renameApprovedPlanFile({ + planFilePath: options.planFilePath, + finalPlanFilePath: options.finalPlanFilePath, + getArtifactsDir: () => this.sessionManager.getArtifactsDir(), + getSessionId: () => this.sessionManager.getSessionId(), }); - if (!didCreateFreshSession) { - return; - } - await this.session.setActiveToolsByName(previousTools); + const previousTools = this.#planModePreviousTools ?? this.session.getActiveToolNames(); + await this.#exitPlanMode({ silent: true, paused: false }); + await this.handleClearCommand(); // The new session has a fresh local:// root — persist the approved plan there // so `local://.md` resolves correctly in the execution session. const newLocalPath = resolveLocalUrlToPath(options.finalPlanFilePath, { @@ -784,6 +763,9 @@ export class InteractiveMode implements InteractiveModeContext { getSessionId: () => this.sessionManager.getSessionId(), }); await Bun.write(newLocalPath, planContent); + if (previousTools.length > 0) { + await this.session.setActiveToolsByName(previousTools); + } this.session.setPlanReferencePath(options.finalPlanFilePath); this.session.markPlanReferenceSent(); const prompt = renderPromptTemplate(planModeApprovedPrompt, { @@ -1102,15 +1084,10 @@ export class InteractiveMode implements InteractiveModeContext { this.#commandController.handleHotkeysCommand(); } - async handleClearCommand(options?: ClearCommandOptions): Promise<boolean> { - return this.#commandController.handleClearCommand({ - beforeSwitchCheck: options?.beforeSwitchCheck, - beforeSwitch: async () => { - await options?.beforeSwitch?.(); - this.#btwController.dispose(); - this.#extensionUiController.clearExtensionTerminalInputListeners(); - }, - }); + handleClearCommand(): Promise<void> { + this.#btwController.dispose(); + this.#extensionUiController.clearExtensionTerminalInputListeners(); + return this.#commandController.handleClearCommand(); } handleForkCommand(): Promise<void> { diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index 226e7881e..596434e50 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -54,14 +54,6 @@ export type TodoPhase = { tasks: TodoItem[]; }; -export type ClearCommandRollback = () => Promise<void> | void; -export type ClearCommandCheck = () => Promise<ClearCommandRollback | undefined> | ClearCommandRollback | undefined; - -export interface ClearCommandOptions { - beforeSwitchCheck?: ClearCommandCheck; - beforeSwitch?: () => Promise<void> | void; -} - export interface InteractiveModeContext { // UI access ui: TUI; @@ -185,7 +177,7 @@ export interface InteractiveModeContext { handleHotkeysCommand(): void; handleDumpCommand(): void; handleDebugTranscriptCommand(): Promise<void>; - handleClearCommand(options?: ClearCommandOptions): Promise<boolean>; + handleClearCommand(): Promise<void>; handleForkCommand(): Promise<void>; handleBashCommand(command: string, excludeFromContext?: boolean): Promise<void>; handlePythonCommand(code: string, excludeFromContext?: boolean): Promise<void>; diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index f3d002eeb..9798e69b0 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -2944,26 +2944,6 @@ export class AgentSession { this.#promptInFlightCount = 0; } - async #canSwitchSession(options: { - reason: "new" | "fork" | "resume"; - targetSessionFile?: string; - }): Promise<boolean> { - if (!this.#extensionRunner?.hasHandlers("session_before_switch")) { - return true; - } - const result = (await this.#extensionRunner.emit({ - type: "session_before_switch", - reason: options.reason, - targetSessionFile: options.targetSessionFile, - })) as SessionBeforeSwitchResult | undefined; - - return !result?.cancel; - } - - async canStartNewSession(): Promise<boolean> { - return this.#canSwitchSession({ reason: "new" }); - } - /** * Start a new session, optionally with initial messages and parent tracking. * Clears all messages and starts a new session. @@ -2971,7 +2951,7 @@ export class AgentSession { * @param options - Optional initial messages and parent session path * @returns true if completed, false if cancelled by hook */ - async newSession(options?: NewSessionOptions, internal?: { skipBeforeSwitchCheck?: boolean }): Promise<boolean> { + async newSession(options?: NewSessionOptions): Promise<boolean> { const previousSessionFile = this.sessionFile; const nextDiscoverySessionToolNames = this.#mcpDiscoveryEnabled ? [ @@ -2980,8 +2960,16 @@ export class AgentSession { ] : undefined; - if (!internal?.skipBeforeSwitchCheck && !(await this.#canSwitchSession({ reason: "new" }))) { - return false; + // Emit session_before_switch event with reason "new" (can be cancelled) + if (this.#extensionRunner?.hasHandlers("session_before_switch")) { + const result = (await this.#extensionRunner.emit({ + type: "session_before_switch", + reason: "new", + })) as SessionBeforeSwitchResult | undefined; + + if (result?.cancel) { + return false; + } } this.#disconnectFromAgent(); @@ -3044,8 +3032,16 @@ export class AgentSession { async fork(): Promise<boolean> { const previousSessionFile = this.sessionFile; - if (!(await this.#canSwitchSession({ reason: "fork" }))) { - return false; + // Emit session_before_switch event with reason "fork" (can be cancelled) + if (this.#extensionRunner?.hasHandlers("session_before_switch")) { + const result = (await this.#extensionRunner.emit({ + type: "session_before_switch", + reason: "fork", + })) as SessionBeforeSwitchResult | undefined; + + if (result?.cancel) { + return false; + } } // Flush current session to ensure all entries are written @@ -5209,8 +5205,17 @@ export class AgentSession { const switchingToDifferentSession = previousSessionFile ? path.resolve(previousSessionFile) !== path.resolve(sessionPath) : true; - if (!(await this.#canSwitchSession({ reason: "resume", targetSessionFile: sessionPath }))) { - return false; + // Emit session_before_switch event (can be cancelled) + if (this.#extensionRunner?.hasHandlers("session_before_switch")) { + const result = (await this.#extensionRunner.emit({ + type: "session_before_switch", + reason: "resume", + targetSessionFile: sessionPath, + })) as SessionBeforeSwitchResult | undefined; + + if (result?.cancel) { + return false; + } } this.#disconnectFromAgent(); diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index bfdf3dcaf..3cf1de665 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -1394,7 +1394,6 @@ export class SessionManager { #byId: Map<string, SessionEntry> = new Map(); #labelsById: Map<string, string> = new Map(); #leafId: string | null = null; - #transientModeOverride: { mode: string; data?: Record<string, unknown> } | undefined; #usageStatistics = { input: 0, output: 0, @@ -2077,14 +2076,6 @@ export class SessionManager { return entry.id; } - setTransientModeOverride(mode: string, data?: Record<string, unknown>): void { - this.#transientModeOverride = { mode, data }; - } - - clearTransientModeOverride(): void { - this.#transientModeOverride = undefined; - } - /** * Append a model change as child of current leaf, then advance leaf. Returns entry id. * @param model Model in "provider/modelId" format @@ -2350,16 +2341,7 @@ export class SessionManager { * Uses tree traversal from current leaf. */ buildSessionContext(): SessionContext { - const context = buildSessionContext(this.getEntries(), this.#leafId, this.#byId); - if (!this.#transientModeOverride) { - return context; - } - - return { - ...context, - mode: this.#transientModeOverride.mode, - modeData: this.#transientModeOverride.data, - }; + return buildSessionContext(this.getEntries(), this.#leafId, this.#byId); } /** Strip stale OpenAI Responses assistant replay metadata from loaded in-memory entries. */ diff --git a/packages/coding-agent/test/modes/controllers/command-controller-new-session.test.ts b/packages/coding-agent/test/modes/controllers/command-controller-new-session.test.ts deleted file mode 100644 index b2ddb166c..000000000 --- a/packages/coding-agent/test/modes/controllers/command-controller-new-session.test.ts +++ /dev/null @@ -1,348 +0,0 @@ -import { beforeAll, describe, expect, it, vi } from "bun:test"; -import { CommandController } from "@oh-my-pi/pi-coding-agent/modes/controllers/command-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 { SessionContext } from "@oh-my-pi/pi-coding-agent/session/session-manager"; -import { type Component, Spacer, Text } from "@oh-my-pi/pi-tui"; - -const setSessionTerminalTitleMock = vi.fn(); - -vi.mock("@oh-my-pi/pi-coding-agent/utils/title-generator", () => ({ - setSessionTerminalTitle: setSessionTerminalTitleMock, -})); - -type TestContainer = { - children: Component[]; - clear: () => void; - addChild: (child: Component) => void; -}; - -type TestContext = InteractiveModeContext & { - chatContainer: TestContainer; - pendingMessagesContainer: TestContainer; - statusContainer: TestContainer; - pendingTools: { - clear: () => void; - }; - loadingAnimation: - | { - stop: () => void; - } - | undefined; - statusLine: { - invalidate: () => void; - setSessionStartTime: (time: number) => void; - }; - ui: { - requestRender: () => void; - terminal: { columns: number; rows: number }; - }; - session: { - canStartNewSession: () => Promise<boolean>; - newSession: (...args: unknown[]) => Promise<boolean>; - isStreaming: boolean; - isCompacting: boolean; - abortCompaction: () => void; - }; - sessionManager: { - getSessionName: () => string; - getCwd: () => string; - buildSessionContext: () => SessionContext; - }; - reloadTodos: () => Promise<void>; - updateEditorTopBorder: () => void; -}; - -function createTrackedContainer(name: string, calls: string[], initialChildren: Component[] = []): TestContainer { - const container: TestContainer = { - children: [...initialChildren], - clear: vi.fn(() => { - container.children = []; - calls.push(`${name}.clear`); - }), - addChild: vi.fn((child: Component) => { - container.children.push(child); - calls.push(`${name}.addChild`); - }), - }; - - return container; -} - -function createContext(options?: { - withLoadingAnimation?: boolean; - canStartNewSessionResult?: boolean; - newSessionResult?: boolean; - isStreaming?: boolean; - isCompacting?: boolean; - initialSessionMode?: string; -}): { ctx: TestContext; calls: string[] } { - const calls: string[] = []; - const chatContainer = createTrackedContainer("chatContainer", calls, [new Text("stale chat", 0, 0)]); - const pendingMessagesContainer = createTrackedContainer("pendingMessagesContainer", calls, [ - new Text("pending", 0, 0), - ]); - const statusContainer = createTrackedContainer("statusContainer", calls, [new Text("streaming status", 0, 0)]); - const loadingAnimation = - options?.withLoadingAnimation === false - ? undefined - : { - stop: vi.fn(() => { - calls.push("loadingAnimation.stop"); - }), - }; - - const ctx = { - chatContainer, - pendingMessagesContainer, - statusContainer, - pendingTools: { - clear: vi.fn(() => { - calls.push("pendingTools.clear"); - }), - }, - loadingAnimation, - compactionQueuedMessages: ["queued"], - streamingComponent: { active: true }, - streamingMessage: { active: true }, - statusLine: { - invalidate: vi.fn(() => { - calls.push("statusLine.invalidate"); - }), - setSessionStartTime: vi.fn(() => { - calls.push("statusLine.setSessionStartTime"); - }), - }, - ui: { - requestRender: vi.fn(() => { - calls.push("ui.requestRender"); - }), - terminal: { columns: 120, rows: 40 }, - }, - session: { - canStartNewSession: vi.fn(async () => { - calls.push("session.canStartNewSession"); - return options?.canStartNewSessionResult ?? true; - }), - newSession: vi.fn(async () => { - calls.push("session.newSession"); - return options?.newSessionResult ?? true; - }), - isStreaming: options?.isStreaming ?? false, - isCompacting: options?.isCompacting ?? false, - abortCompaction: vi.fn(() => { - calls.push("session.abortCompaction"); - }), - }, - sessionManager: { - getSessionName: vi.fn(() => "Fresh session"), - getCwd: vi.fn(() => "/tmp/project"), - buildSessionContext: vi.fn(() => ({ mode: options?.initialSessionMode ?? "none" }) as SessionContext), - }, - reloadTodos: vi.fn(async () => { - calls.push("reloadTodos"); - }), - updateEditorTopBorder: vi.fn(() => { - calls.push("updateEditorTopBorder"); - }), - } as unknown as TestContext; - - return { ctx, calls }; -} - -beforeAll(() => { - initTheme(); -}); - -describe("CommandController /new command", () => { - it("clearCommand clears session state and starts a fresh session without extra renders", async () => { - setSessionTerminalTitleMock.mockReset(); - const { ctx, calls } = createContext(); - const loadingAnimation = ctx.loadingAnimation; - setSessionTerminalTitleMock.mockImplementation(() => { - calls.push("setSessionTerminalTitle"); - }); - const controller = new CommandController(ctx); - - const result = await controller.handleClearCommand(); - expect(result).toBe(true); - - expect(ctx.session.canStartNewSession).toHaveBeenCalledTimes(1); - expect(ctx.session.newSession).toHaveBeenCalledTimes(1); - expect(ctx.session.abortCompaction).not.toHaveBeenCalled(); - expect(ctx.chatContainer.clear).toHaveBeenCalledTimes(1); - expect(ctx.pendingMessagesContainer.clear).toHaveBeenCalledTimes(1); - expect(ctx.statusContainer.clear).toHaveBeenCalledTimes(1); - expect(ctx.pendingTools.clear).toHaveBeenCalledTimes(1); - expect(loadingAnimation?.stop).toHaveBeenCalledTimes(1); - expect(ctx.loadingAnimation).toBeUndefined(); - expect(ctx.streamingComponent).toBeUndefined(); - expect(ctx.streamingMessage).toBeUndefined(); - expect(ctx.compactionQueuedMessages).toEqual([]); - expect(ctx.statusLine.invalidate).toHaveBeenCalledTimes(1); - expect(ctx.statusLine.setSessionStartTime).toHaveBeenCalledTimes(1); - expect(ctx.reloadTodos).toHaveBeenCalledTimes(1); - expect(ctx.ui.requestRender).toHaveBeenCalledTimes(2); - expect(setSessionTerminalTitleMock).toHaveBeenCalledWith("Fresh session", "/tmp/project"); - expect(calls).toEqual( - expect.arrayContaining([ - "session.canStartNewSession", - "session.newSession", - "loadingAnimation.stop", - "statusContainer.clear", - "setSessionTerminalTitle", - "statusLine.invalidate", - "statusLine.setSessionStartTime", - "updateEditorTopBorder", - "chatContainer.clear", - "pendingMessagesContainer.clear", - "pendingTools.clear", - "reloadTodos", - ]), - ); - }); - - it("clearCommand without loadingAnimation does not throw", async () => { - setSessionTerminalTitleMock.mockReset(); - const { ctx, calls } = createContext({ withLoadingAnimation: false }); - const controller = new CommandController(ctx); - - await expect(controller.handleClearCommand()).resolves.toBe(true); - - expect(ctx.loadingAnimation).toBeUndefined(); - expect(calls).not.toContain("loadingAnimation.stop"); - expect(ctx.ui.requestRender).toHaveBeenCalledTimes(2); - }); - - it("clearCommand keeps the current streaming session UI when newSession is cancelled", async () => { - setSessionTerminalTitleMock.mockReset(); - const { ctx, calls } = createContext({ canStartNewSessionResult: false, isStreaming: true }); - expect(ctx.session.isStreaming).toBe(true); - const loadingAnimation = ctx.loadingAnimation; - const controller = new CommandController(ctx); - - const result = await controller.handleClearCommand(); - expect(result).toBe(false); - expect(ctx.session.canStartNewSession).toHaveBeenCalledTimes(1); - expect(ctx.session.newSession).not.toHaveBeenCalled(); - expect(setSessionTerminalTitleMock).not.toHaveBeenCalled(); - expect(ctx.statusLine.invalidate).not.toHaveBeenCalled(); - expect(ctx.statusLine.setSessionStartTime).not.toHaveBeenCalled(); - expect(ctx.updateEditorTopBorder).not.toHaveBeenCalled(); - expect(ctx.ui.requestRender).toHaveBeenCalledTimes(1); - expect(ctx.chatContainer.clear).not.toHaveBeenCalled(); - expect(ctx.pendingMessagesContainer.clear).not.toHaveBeenCalled(); - expect(ctx.statusContainer.clear).not.toHaveBeenCalled(); - expect(ctx.pendingTools.clear).not.toHaveBeenCalled(); - expect(ctx.reloadTodos).not.toHaveBeenCalled(); - expect(loadingAnimation?.stop).not.toHaveBeenCalled(); - expect(ctx.loadingAnimation).toBe(loadingAnimation); - expect(ctx.chatContainer.children).toHaveLength(3); - const existingMessage = ctx.chatContainer.children[0]; - const spacer = ctx.chatContainer.children[1]; - const errorMessage = ctx.chatContainer.children[2]; - if (!(existingMessage instanceof Text)) { - throw new Error("Expected stale chat to remain visible"); - } - if (!(spacer instanceof Spacer)) { - throw new Error("Expected spacer before cancellation message"); - } - if (!(errorMessage instanceof Text)) { - throw new Error("Expected cancellation error message"); - } - expect(existingMessage.render(120).join("\n")).toContain("stale chat"); - expect(errorMessage.render(120).join("\n")).toContain("Error: New session cancelled"); - expect(ctx.pendingMessagesContainer.children).toHaveLength(1); - expect(ctx.statusContainer.children).toHaveLength(1); - const existingStatus = ctx.statusContainer.children[0]; - if (!(existingStatus instanceof Text)) { - throw new Error("Expected current status to remain visible"); - } - expect(existingStatus.render(120).join("\n")).toContain("streaming status"); - expect(ctx.compactionQueuedMessages as unknown).toEqual(["queued"]); - expect(ctx.streamingComponent as unknown).toEqual({ active: true }); - expect(ctx.streamingMessage as unknown).toEqual({ active: true }); - expect(calls).toEqual([ - "session.canStartNewSession", - "chatContainer.addChild", - "chatContainer.addChild", - "ui.requestRender", - ]); - }); - - it("clearCommand rolls back temporary pre-switch teardown when new session approval is denied", async () => { - const { ctx, calls } = createContext({ canStartNewSessionResult: false, initialSessionMode: "plan" }); - const hookState = { mode: "plan" as "plan" | "none" }; - const controller = new CommandController(ctx); - - ctx.sessionManager.buildSessionContext = vi.fn(() => ({ mode: hookState.mode }) as SessionContext); - ctx.session.canStartNewSession = vi.fn(async () => { - calls.push(`session.canStartNewSession:${ctx.sessionManager.buildSessionContext().mode}`); - return false; - }); - - const rollbackBeforeSwitchCheck = vi.fn(() => { - hookState.mode = "plan"; - calls.push(`rollbackBeforeSwitchCheck:${hookState.mode}`); - }); - const beforeSwitchCheck = vi.fn(() => { - hookState.mode = "none"; - calls.push(`prepareBeforeSwitchCheck:${hookState.mode}`); - return rollbackBeforeSwitchCheck; - }); - const beforeSwitch = vi.fn(() => { - calls.push("beforeSwitch"); - }); - - const result = await controller.handleClearCommand({ - beforeSwitchCheck, - beforeSwitch, - }); - - expect(result).toBe(false); - expect(beforeSwitchCheck).toHaveBeenCalledTimes(1); - expect(rollbackBeforeSwitchCheck).toHaveBeenCalledTimes(1); - expect(beforeSwitch).not.toHaveBeenCalled(); - expect(ctx.session.newSession).not.toHaveBeenCalled(); - expect(hookState.mode).toBe("plan"); - expect(calls).toEqual([ - "prepareBeforeSwitchCheck:none", - "session.canStartNewSession:none", - "rollbackBeforeSwitchCheck:plan", - "chatContainer.addChild", - "chatContainer.addChild", - "ui.requestRender", - ]); - }); - - it("clearCommand does not abort compaction when new session approval is denied", async () => { - const { ctx } = createContext({ canStartNewSessionResult: false, isCompacting: true }); - const controller = new CommandController(ctx); - - const result = await controller.handleClearCommand(); - - expect(result).toBe(false); - expect(ctx.session.canStartNewSession).toHaveBeenCalledTimes(1); - expect(ctx.session.abortCompaction).not.toHaveBeenCalled(); - expect(ctx.session.newSession).not.toHaveBeenCalled(); - }); - - it("clearCommand adds a new session started message after clearing chat", async () => { - setSessionTerminalTitleMock.mockReset(); - const { ctx } = createContext(); - const controller = new CommandController(ctx); - - await controller.handleClearCommand(); - - expect(ctx.chatContainer.children).toHaveLength(2); - const spacer = ctx.chatContainer.children[0]; - const message = ctx.chatContainer.children[1]; - if (!(spacer instanceof Spacer)) { - throw new Error("Expected spacer after clearing chat"); - } - if (!(message instanceof Text)) { - throw new Error("Expected new session message"); - } - expect(message.render(120).join("\n")).toContain("New session started"); - }); -}); diff --git a/packages/coding-agent/test/modes/controllers/selector-controller-tree.test.ts b/packages/coding-agent/test/modes/controllers/selector-controller-tree.test.ts deleted file mode 100644 index a8cb17f8c..000000000 --- a/packages/coding-agent/test/modes/controllers/selector-controller-tree.test.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { beforeAll, describe, expect, it, vi } from "bun:test"; -import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; -import { TreeSelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/tree-selector"; -import { SelectorController } from "@oh-my-pi/pi-coding-agent/modes/controllers/selector-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 { SessionMessageEntry, SessionTreeNode } from "@oh-my-pi/pi-coding-agent/session/session-manager"; -import { type Component, Spacer, Text } from "@oh-my-pi/pi-tui"; - -interface TestEditorContainer { - children: unknown[]; - clear(): void; - addChild(child: unknown): void; -} - -interface TestEditor { - readonly kind: "editor"; - invalidate(): void; - render(width: number): string[]; -} - -type TestContext = InteractiveModeContext & { - chatContainer: { - children: Component[]; - addChild(child: Component): void; - }; - lastStatusSpacer: Spacer | undefined; - lastStatusText: Text | undefined; - editor: TestEditor; - editorContainer: TestEditorContainer; -}; - -function createTreeNode( - id: string, - parentId: string | null, - text: string, - children: SessionTreeNode[] = [], -): SessionTreeNode { - const entry: SessionMessageEntry = { - type: "message", - id, - parentId, - timestamp: "2025-01-01T00:00:00Z", - message: { - role: "user", - content: text, - timestamp: 1, - }, - }; - - return { entry, children }; -} - -function createContext(tree: SessionTreeNode[], leafId: string | null) { - const calls: string[] = []; - const editor: TestEditor = { - kind: "editor", - invalidate() {}, - render() { - return []; - }, - }; - const chatContainer = { - children: [] as Component[], - addChild(child: Component) { - this.children.push(child); - calls.push("chatContainer.addChild"); - }, - }; - const editorContainer: TestEditorContainer = { - children: [], - clear() { - this.children = []; - calls.push("editorContainer.clear"); - }, - addChild(child: unknown) { - this.children.push(child); - calls.push("editorContainer.addChild"); - }, - }; - const setFocus = vi.fn((component: unknown) => { - calls.push(component === editor ? "ui.setFocus.editor" : "ui.setFocus.selector"); - }); - const requestRender = vi.fn(() => { - calls.push("ui.requestRender"); - }); - const showStatus = vi.fn((message: string) => { - calls.push(`showStatus:${message}`); - const spacer = new Spacer(1); - const text = new Text(message, 1, 0); - chatContainer.addChild(spacer); - chatContainer.addChild(text); - ctx.lastStatusSpacer = spacer; - ctx.lastStatusText = text; - requestRender(); - }); - - const ctx = { - chatContainer, - lastStatusSpacer: undefined, - lastStatusText: undefined, - editor, - editorContainer, - ui: { - setFocus, - requestRender, - terminal: { rows: 40 }, - }, - sessionManager: { - getTree: vi.fn(() => tree), - getLeafId: vi.fn(() => leafId), - appendLabelChange: vi.fn(), - }, - showStatus, - } as unknown as TestContext; - - return { ctx, calls, editor, setFocus, requestRender, showStatus }; -} - -beforeAll(async () => { - _resetSettingsForTest(); - await Settings.init({ inMemory: true }); - initTheme(); -}); - -describe("SelectorController tree selector", () => { - it("showTreeSelector opens the tree selector in the editor container", () => { - const tree = [createTreeNode("entry-1", null, "hello")]; - const { ctx, calls, setFocus, requestRender } = createContext(tree, "entry-1"); - const controller = new SelectorController(ctx); - - controller.showTreeSelector(); - - expect(ctx.editorContainer.children).toHaveLength(1); - const selector = ctx.editorContainer.children[0]; - expect(selector).toBeInstanceOf(TreeSelectorComponent); - expect(setFocus).toHaveBeenCalledWith(selector); - expect(requestRender).toHaveBeenCalledTimes(1); - expect(calls).toEqual([ - "editorContainer.clear", - "editorContainer.addChild", - "ui.setFocus.selector", - "ui.requestRender", - ]); - }); - - it("showTreeSelector with an empty tree shows a status message", () => { - const { ctx, calls, showStatus, setFocus, requestRender } = createContext([], null); - const controller = new SelectorController(ctx); - - controller.showTreeSelector(); - - expect(showStatus).toHaveBeenCalledWith("No entries in session"); - expect(ctx.editorContainer.children).toEqual([]); - expect(ctx.chatContainer.children).toHaveLength(2); - const statusMessage = ctx.chatContainer.children[1]; - if (!(statusMessage instanceof Text)) { - throw new Error("Expected empty-tree status message"); - } - expect(statusMessage.render(120).join("\n")).toContain("No entries in session"); - expect(setFocus).not.toHaveBeenCalled(); - expect(requestRender).toHaveBeenCalledTimes(1); - expect(calls).toEqual([ - "showStatus:No entries in session", - "chatContainer.addChild", - "chatContainer.addChild", - "ui.requestRender", - ]); - }); - - it("selecting the current leaf runs done() and restores the editor before the status render", () => { - const tree = [createTreeNode("entry-1", null, "hello")]; - const { ctx, calls, editor, setFocus, requestRender, showStatus } = createContext(tree, "entry-1"); - const controller = new SelectorController(ctx); - - controller.showTreeSelector(); - const selector = ctx.editorContainer.children[0]; - if (!(selector instanceof TreeSelectorComponent)) { - throw new Error("Expected tree selector component"); - } - - selector.handleInput("\n"); - - expect(ctx.editorContainer.children).toEqual([editor]); - expect(setFocus).toHaveBeenLastCalledWith(editor); - expect(showStatus).toHaveBeenCalledWith("Already at this point"); - expect(ctx.chatContainer.children).toHaveLength(2); - const statusMessage = ctx.chatContainer.children[1]; - if (!(statusMessage instanceof Text)) { - throw new Error("Expected current-leaf status message"); - } - expect(statusMessage.render(120).join("\n")).toContain("Already at this point"); - expect(requestRender).toHaveBeenCalledTimes(2); - expect(calls).toEqual([ - "editorContainer.clear", - "editorContainer.addChild", - "ui.setFocus.selector", - "ui.requestRender", - "editorContainer.clear", - "editorContainer.addChild", - "ui.setFocus.editor", - "showStatus:Already at this point", - "chatContainer.addChild", - "chatContainer.addChild", - "ui.requestRender", - ]); - }); -}); diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index c127c3039..7f3d39b8d 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -230,8 +230,6 @@ export class TUI extends Container { #maxLinesRendered = 0; // High-water line count used for clear-on-shrink policy #fullRedrawCount = 0; #stopped = false; - #forceFullRepaint = false; // One-shot flag: skip diff rendering and use the correct full-repaint mode - #terminalStateTrusted = false; // stop() and shell output can desync the real terminal from our cached frame // Overlay stack for modal components rendered on top of base content overlayStack: { @@ -546,37 +544,31 @@ export class TUI extends Container { stop(): void { this.#clearSixelProbeState(); this.#stopped = true; - // Move cursor just past the visible content so the shell prompt - // appears right below the TUI output without a blank gap. + // Move cursor to the end of the content to prevent overwriting/artifacts on exit if (this.#previousLines.length > 0) { - const height = this.terminal.rows; - // How many content rows are actually visible in the current viewport. - // After shrink paths, viewportTopRow can still point below historical content, - // so total previous line count overstates what is on screen. - const visibleContentRows = Math.max(0, Math.min(this.#previousLines.length - this.#viewportTopRow, height)); - // Screen row of the last visible content line - const lastContentScreenRow = visibleContentRows - 1; - // Screen row where the hardware cursor currently sits - const cursorScreenRow = this.#hardwareCursorRow - this.#viewportTopRow; - // Move to the last visible content row, then print a newline so the shell prompt lands - // immediately below the rendered viewport content without an extra blank row. - const targetScreenRow = Math.max(0, lastContentScreenRow); - const screenDelta = targetScreenRow - cursorScreenRow; - if (screenDelta > 0) { - this.terminal.write(`\x1b[${screenDelta}B`); - } else if (screenDelta < 0) { - this.terminal.write(`\x1b[${-screenDelta}A`); + const targetRow = this.#previousLines.length; // Line after the last content + const lineDiff = targetRow - this.#hardwareCursorRow; + if (lineDiff > 0) { + this.terminal.write(`\x1b[${lineDiff}B`); + } else if (lineDiff < 0) { + this.terminal.write(`\x1b[${-lineDiff}A`); } this.terminal.write("\r\n"); } - this.#terminalStateTrusted = false; + this.terminal.showCursor(); this.terminal.stop(); } requestRender(force = false): void { if (force) { - this.#forceFullRepaint = true; + this.#previousLines = []; + this.#previousWidth = -1; // -1 triggers widthChanged, forcing a full clear + this.#previousHeight = -1; // -1 triggers heightChanged, forcing a full clear + this.#cursorRow = 0; + this.#hardwareCursorRow = 0; + this.#viewportTopRow = 0; + this.#maxLinesRendered = 0; } if (this.#renderRequested) return; this.#renderRequested = true; @@ -1011,182 +1003,71 @@ export class TUI extends Container { const widthChanged = this.#previousWidth !== 0 && this.#previousWidth !== width; const heightChanged = this.#previousHeight !== 0 && this.#previousHeight !== height; - // Consume force flag - const forceRepaint = this.#forceFullRepaint; - this.#forceFullRepaint = false; - const hasPriorFrame = this.#terminalStateTrusted && this.#previousLines.length > 0; - - // Common bookkeeping after any full-repaint path - const finishFullRepaint = (): void => { + // Helper to clear scrollback and viewport and render all new lines + const fullRender = (clear: boolean): void => { this.#fullRedrawCount += 1; + let buffer = "\x1b[?2026h"; // Begin synchronized output + if (clear) buffer += "\x1b[2J\x1b[H\x1b[3J"; // Clear screen, home, then clear scrollback + const reset = SEGMENT_RESET; + for (let i = 0; i < newLines.length; i++) { + if (i > 0) buffer += "\r\n"; + const line = newLines[i]; + buffer += TERMINAL.isImageLine(line) ? line : line + reset; + } + buffer += "\x1b[?2026l"; // End synchronized output + this.terminal.write(buffer); this.#cursorRow = Math.max(0, newLines.length - 1); this.#hardwareCursorRow = this.#cursorRow; - this.#maxLinesRendered = newLines.length; + // Reset max lines when clearing, otherwise track growth + if (clear) { + this.#maxLinesRendered = newLines.length; + } else { + this.#maxLinesRendered = Math.max(this.#maxLinesRendered, newLines.length); + } this.#viewportTopRow = Math.max(0, this.#maxLinesRendered - height); this.#positionHardwareCursor(cursorPos, newLines.length); this.#previousLines = newLines; this.#previousWidth = width; this.#previousHeight = height; - this.#terminalStateTrusted = true; - }; - - const previousVisibleRows = Math.max( - 0, - Math.min(this.#previousHeight, this.#previousLines.length - prevViewportTop), - ); - const seedScrollRows = previousVisibleRows > 0 ? previousVisibleRows : height; - - // First paint: no prior trusted TUI frame exists. Preserve whatever is currently - // visible by scrolling only the rows we know are occupied; otherwise fall back to - // the full viewport for the initial shell->TUI takeover path. - const seedTranscript = (): void => { - let buffer = "\x1b[?2026h"; // Begin synchronized output - if (seedScrollRows > 0) { - // Push existing viewport content into scrollback by scrolling it off. - // Move to the last occupied screen row, then emit newlines for exactly those rows. - buffer += `\x1b[${seedScrollRows};1H`; - buffer += "\n".repeat(seedScrollRows); - } - buffer += "\x1b[H"; // Home cursor - const reset = SEGMENT_RESET; - for (let i = 0; i < newLines.length; i++) { - if (i > 0) buffer += "\r\n"; - buffer += "\x1b[2K"; // Clear this display row before writing - const line = newLines[i]; - buffer += TERMINAL.isImageLine(line) ? line : line + reset; - } - buffer += "\x1b[?2026l"; // End synchronized output - this.terminal.write(buffer); - finishFullRepaint(); - }; - - // Viewport repaint: a prior TUI frame exists. Before overwriting the visible - // display, scroll off the rows that are transitioning from viewport to - // scrollback (the viewport-shift delta). Then overwrite in-place. - const repaintViewport = (): void => { - let buffer = "\x1b[?2026h"; // Begin synchronized output - // Compute how many rows the viewport shifted since the last render. - // These rows were at the top of the old viewport and now belong in scrollback. - const oldVpTop = Math.max(0, this.#previousLines.length - this.#previousHeight); - const newVpTop = Math.max(0, newLines.length - height); - const scrollDelta = Math.max(0, newVpTop - oldVpTop); - if (scrollDelta > 0) { - // Move cursor to the last row that is actually occupied on screen before scrolling. - const curScreenRow = hardwareCursorRow - prevViewportTop; - const usedRows = previousVisibleRows; - const toBottom = usedRows - 1 - curScreenRow; - if (toBottom > 0) buffer += `\x1b[${toBottom}B`; - buffer += "\r\n".repeat(scrollDelta); - } - buffer += "\x1b[H"; // Home cursor - const vpTop = newVpTop; - const vpLines = newLines.length - vpTop; - const reset = SEGMENT_RESET; - for (let i = vpTop; i < newLines.length; i++) { - if (i > vpTop) buffer += "\r\n"; - buffer += "\x1b[2K"; // Clear this display row before writing - const line = newLines[i]; - buffer += TERMINAL.isImageLine(line) ? line : line + reset; - } - // Clear any remaining display rows below the viewport content. - // Use erase-to-end instead of \r\n loops to avoid moving the cursor - // past the content area — cursor drift here would desync stop(). - if (vpLines < height) { - if (vpLines > 0) { - buffer += "\r\n\x1b[J"; // Move to col 0 on next line, erase to end of display - buffer += "\x1b[1A"; // Move cursor back to last content row - } else { - buffer += "\x1b[J"; // No content lines; erase entire viewport from home - } - } - buffer += "\x1b[?2026l"; // End synchronized output - this.terminal.write(buffer); - finishFullRepaint(); }; const debugRedraw = process.env.PI_DEBUG_REDRAW === "1"; const logRedraw = (reason: string): void => { if (!debugRedraw) return; const logPath = getDebugLogPath(); - const msg = `[${new Date().toISOString()}] repaint: ${reason} (prev=${this.#previousLines.length}, new=${newLines.length}, height=${height})\n`; + const msg = `[${new Date().toISOString()}] fullRender: ${reason} (prev=${this.#previousLines.length}, new=${newLines.length}, height=${height})\n`; fs.appendFileSync(logPath, msg); }; - const repaintAfterHeightIncrease = (): void => { - logRedraw(`height increase (${this.#previousHeight} -> ${height})`); - let buffer = "\x1b[?2026h"; // Begin synchronized output - // Scroll only the rows that are actually visible now. - const curScreenRow = hardwareCursorRow - prevViewportTop; - const screenRows = previousVisibleRows; - if (screenRows > 0) { - const toBottom = screenRows - 1 - curScreenRow; - if (toBottom > 0) buffer += `\x1b[${toBottom}B`; - buffer += "\r\n".repeat(screenRows); - } - buffer += "\x1b[H"; // Home cursor - const vpTop = Math.max(0, newLines.length - height); - const vpLines = newLines.length - vpTop; - const reset = SEGMENT_RESET; - for (let i = vpTop; i < newLines.length; i++) { - if (i > vpTop) buffer += "\r\n"; - buffer += "\x1b[2K"; - const line = newLines[i]; - buffer += TERMINAL.isImageLine(line) ? line : line + reset; - } - // Clear any remaining display rows below viewport content. - // Use erase-to-end to avoid moving the cursor past content area. - if (vpLines < height) { - if (vpLines > 0) { - buffer += "\r\n\x1b[J"; - buffer += "\x1b[1A"; - } else { - buffer += "\x1b[J"; - } - } - buffer += "\x1b[?2026l"; // End synchronized output - this.terminal.write(buffer); - finishFullRepaint(); - }; - - // First render — no prior TUI frame, seed the full transcript - if (!hasPriorFrame && !widthChanged && !heightChanged) { + // First render - just output everything without clearing (assumes clean screen) + if (this.#previousLines.length === 0 && !widthChanged && !heightChanged) { logRedraw("first render"); - seedTranscript(); + fullRender(false); return; } - // Forced full repaint (e.g. requestRender(true)) — use viewport repaint if we - // have a prior frame, otherwise seed from scratch - if (forceRepaint) { - logRedraw("forced repaint"); - if (hasPriorFrame) repaintViewport(); - else seedTranscript(); - return; - } - - // Width changed — viewport repaint (line wrapping invalidates all content) + // Width changes always need a full re-render because wrapping changes. if (widthChanged) { - logRedraw(`width changed (${this.#previousWidth} -> ${width})`); - repaintViewport(); + logRedraw(`terminal width changed (${this.#previousWidth} -> ${width})`); + fullRender(true); return; } - // Height decreased — viewport repaint to realign content. - // (Height increases use dedicated repaint paths elsewhere in this method.) - // Termux changes height when the software keyboard shows or hides; - // in that environment, a full redraw causes the entire history to replay on every toggle. - if (heightChanged && height < this.#previousHeight && !isTermuxSession()) { - logRedraw(`terminal height decreased (${this.#previousHeight} -> ${height})`); - repaintViewport(); + // Height changes normally need a full re-render to keep the visible viewport aligned, + // but Termux changes height when the software keyboard shows or hides. + // In that environment, a full redraw causes the entire history to replay on every toggle. + if (heightChanged && !isTermuxSession()) { + logRedraw(`terminal height changed (${this.#previousHeight} -> ${height})`); + fullRender(true); return; } - // Content shrunk below the working area and no overlays — viewport repaint to clear empty rows + // Content shrunk below the working area and no overlays - re-render to clear empty rows // (overlays need the padding, so only do this when no overlays are active) // Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK=0 env var if (this.#clearOnShrink && newLines.length < this.#maxLinesRendered && this.overlayStack.length === 0) { logRedraw(`clearOnShrink (maxLinesRendered=${this.#maxLinesRendered})`); - repaintViewport(); + fullRender(true); return; } @@ -1214,32 +1095,10 @@ export class TUI extends Container { } const appendStart = appendedLines && firstChanged === this.#previousLines.length && firstChanged > 0; - // When the terminal grows and the UI still does not fill the new viewport, - // newly revealed rows can contain shell history. If content also changed in the - // same tick, diff rendering would only touch the changed range and leave those - // revealed rows visible. Repaint the viewport from scratch before diffing. - if ( - heightChanged && - height > this.#previousHeight && - this.#previousHeight > 0 && - newLines.length < height && - firstChanged !== -1 && - !isTermuxSession() - ) { - repaintAfterHeightIncrease(); - return; - } - - // No line-level changes detected + // No changes - but still need to update hardware cursor position if it moved if (firstChanged === -1) { - if (height > this.#previousHeight && this.#previousHeight > 0 && !isTermuxSession()) { - repaintAfterHeightIncrease(); - return; - } - this.#previousHeight = height; this.#positionHardwareCursor(cursorPos, newLines.length); this.#viewportTopRow = Math.max(0, this.#maxLinesRendered - height); - this.#terminalStateTrusted = true; return; } @@ -1257,7 +1116,7 @@ export class TUI extends Container { const extraLines = this.#previousLines.length - newLines.length; if (extraLines > height) { logRedraw(`extraLines > height (${extraLines} > ${height})`); - repaintViewport(); + fullRender(true); return; } const clearStartOffset = newLines.length > 0 && extraLines > 0 ? 1 : 0; @@ -1282,7 +1141,6 @@ export class TUI extends Container { this.#previousWidth = width; this.#previousHeight = height; this.#viewportTopRow = Math.max(0, this.#maxLinesRendered - height); - this.#terminalStateTrusted = true; return; } @@ -1292,7 +1150,7 @@ export class TUI extends Container { if (firstChanged < previousContentViewportTop) { // First change is above previous viewport - need full re-render logRedraw(`firstChanged < viewportTop (${firstChanged} < ${previousContentViewportTop})`); - repaintViewport(); + fullRender(true); return; } @@ -1431,7 +1289,6 @@ export class TUI extends Container { this.#previousLines = newLines; this.#previousWidth = width; this.#previousHeight = height; - this.#terminalStateTrusted = true; } /** diff --git a/packages/tui/test/overlay-scroll.test.ts b/packages/tui/test/overlay-scroll.test.ts index 7680255ac..198dd1776 100644 --- a/packages/tui/test/overlay-scroll.test.ts +++ b/packages/tui/test/overlay-scroll.test.ts @@ -104,7 +104,7 @@ describe("TUI overlays", () => { expect(term.getScrollBuffer().length).toBeLessThan(200); }); - it("preserves preexisting terminal scrollback across startup full redraw", async () => { + it("clears preexisting terminal scrollback on startup full redraw", async () => { const term = new VirtualTerminal(40, 4); term.write("shell-0\r\nshell-1\r\nshell-2\r\nshell-3\r\nshell-4\r\n"); await term.flush(); @@ -116,15 +116,13 @@ describe("TUI overlays", () => { tui.start(); await Bun.sleep(0); await term.flush(); + term.resize(39, 4); await Bun.sleep(0); await term.flush(); - const viewport = term.getViewport().join("\n"); - expect(viewport.includes("shell-")).toBeFalsy(); const scrollback = term.getScrollBuffer().join("\n"); - expect(scrollback.includes("shell-0")).toBeTruthy(); - expect(scrollback.includes("shell-4")).toBeTruthy(); + expect(scrollback.includes("shell-0")).toBeFalsy(); tui.stop(); }); @@ -151,38 +149,7 @@ describe("TUI overlays", () => { tui.stop(); }); - it("preserves shell output written while stopped across restart forced redraw", async () => { - const term = new VirtualTerminal(40, 4); - const tui = new TUI(term); - tui.addChild(new MutableContentComponent(["ui-0", "ui-1", "ui-2", "ui-3", "ui-4", "ui-5"])); - - tui.start(); - await Bun.sleep(0); - await term.flush(); - - tui.stop(); - await term.flush(); - term.write("shell-a\r\nshell-b\r\n"); - await term.flush(); - expect(term.getViewport().join("\n").includes("shell-")).toBeTruthy(); - - tui.start(); - await Bun.sleep(0); - await term.flush(); - - const viewport = term.getViewport().join("\n"); - expect(viewport.includes("ui-2")).toBeTruthy(); - expect(viewport.includes("ui-5")).toBeTruthy(); - expect(viewport.includes("shell-")).toBeFalsy(); - - const scrollback = term.getScrollBuffer().join("\n"); - expect(scrollback.includes("shell-a")).toBeTruthy(); - expect(scrollback.includes("shell-b")).toBeTruthy(); - - tui.stop(); - }); - - it("fully redraws on height increase without wiping shell scrollback", async () => { + it("fully redraws on height increase to avoid stale viewport rows", async () => { const term = new VirtualTerminal(40, 4); term.write("shell-0\r\nshell-1\r\nshell-2\r\nshell-3\r\nshell-4\r\n"); await term.flush(); @@ -201,40 +168,9 @@ describe("TUI overlays", () => { const viewport = term.getViewport().join("\n"); expect(viewport.includes("shell-")).toBeFalsy(); - const scrollback = term.getScrollBuffer().join("\n"); - expect(scrollback.includes("shell-0")).toBeTruthy(); - expect(scrollback.includes("shell-4")).toBeTruthy(); tui.stop(); }); - it("fully redraws on height increase when content changes in the same tick", async () => { - const term = new VirtualTerminal(40, 4); - term.write("shell-0\r\nshell-1\r\nshell-2\r\nshell-3\r\nshell-4\r\nshell-5\r\n"); - await term.flush(); - - const tui = new TUI(term); - const component = new MutableContentComponent(["ui-0", "ui-1", "ui-2", "ui-3"]); - tui.addChild(component); - - tui.start(); - await Bun.sleep(0); - await term.flush(); - - component.setLines(["ui-0", "ui-1", "ui-2", "ui-3*"]); - term.resize(40, 8); - await Bun.sleep(0); - await term.flush(); - - const viewport = term.getViewport().join("\n"); - expect(viewport.includes("shell-")).toBeFalsy(); - expect(viewport.includes("ui-3*")).toBeTruthy(); - const scrollback = term.getScrollBuffer().join("\n"); - expect(scrollback.includes("shell-0")).toBeTruthy(); - expect(scrollback.includes("shell-5")).toBeTruthy(); - - tui.stop(); - }); - it("renders viewport-only on resize when content size is stable", async () => { const term = new VirtualTerminal(60, 8); const tui = new TUI(term); @@ -279,7 +215,7 @@ describe("TUI overlays", () => { } }); - it("keeps shell scrollback on viewport-only resize redraw", async () => { + it("keeps scrollback on viewport-only resize redraw", async () => { const term = new VirtualTerminal(40, 4); term.write("shell-0\r\nshell-1\r\nshell-2\r\nshell-3\r\n"); await term.flush(); @@ -292,11 +228,8 @@ describe("TUI overlays", () => { term.resize(39, 4); await Bun.sleep(0); await term.flush(); - const viewport = term.getViewport().join("\n"); - expect(viewport.includes("shell-")).toBeFalsy(); const scrollback = term.getScrollBuffer().join("\n"); - expect(scrollback.includes("shell-0")).toBeTruthy(); - expect(scrollback.includes("shell-3")).toBeTruthy(); + expect(scrollback.includes("shell-0")).toBeFalsy(); } finally { tui.stop(); } @@ -528,106 +461,4 @@ describe("TUI overlays", () => { tui.stop(); } }); - - it("exit after startup scrollback seeding does not leave long blank run", async () => { - const term = new VirtualTerminal(40, 6); - term.write("shell-0\r\nshell-1\r\nshell-2\r\nshell-3\r\nshell-4\r\n"); - await term.flush(); - - const tui = new TUI(term); - const component = new MutableContentComponent(buildRows(20)); - tui.addChild(component); - - tui.start(); - await Bun.sleep(0); - await term.flush(); - - tui.stop(); - await term.flush(); - - const scrollback = term.getScrollBuffer(); - // Shell history should survive - expect(scrollback.join("\n").includes("shell-0")).toBeTruthy(); - // No large blank gap from exit — viewport should still have content - const viewport = term.getViewport().map(l => l.trimEnd()); - const contentLines = viewport.filter(l => l.trim().length > 0); - expect(contentLines.length).toBeGreaterThanOrEqual(4); - }); - - it("shrink after preexisting shell history does not flood viewport with blanks", async () => { - const term = new VirtualTerminal(40, 8); - term.write("shell-0\r\nshell-1\r\nshell-2\r\n"); - await term.flush(); - - const tui = new TUI(term); - const component = new MutableContentComponent(buildRows(40)); - tui.addChild(component); - - try { - tui.start(); - await Bun.sleep(0); - await term.flush(); - - // Shrink to tiny content (like /new) - component.setLines(["New session"]); - tui.requestRender(true); - await Bun.sleep(0); - await term.flush(); - - const viewport = term.getViewport().map(l => l.trimEnd()); - // Content should be at the top, not at the bottom - expect(viewport[0]?.trim()).toBe("New session"); - for (let i = 1; i < 8; i++) { - expect(viewport[i]?.trim()).toBe(""); - } - - // Scrollback should not have a big blank gap - const scrollback = term.getScrollBuffer(); - expect(longestBlankRun(scrollback)).toBeLessThan(10); - } finally { - tui.stop(); - } - }); - - it("overlay dismissal after historical scrollback does not create gap", async () => { - const term = new VirtualTerminal(40, 8); - term.write("shell-0\r\nshell-1\r\nshell-2\r\n"); - await term.flush(); - - const tui = new TUI(term); - const component = new MutableContentComponent(buildRows(20)); - tui.addChild(component); - - try { - tui.start(); - await Bun.sleep(0); - await term.flush(); - - // Show overlay - const handle = tui.showOverlay(new LineComponent("over-", 4), { anchor: "center" }); - await Bun.sleep(0); - await term.flush(); - - // Dismiss - handle.hide(); - await Bun.sleep(0); - await term.flush(); - - // Viewport should show the tail of base content - const viewport = term.getViewport().map(l => l.trimEnd()); - expect(viewport.at(-1)?.trim()).toBe("row-19"); - // No large blank run - expect(longestBlankRun(viewport)).toBeLessThan(2); - - tui.stop(); - await term.flush(); - - // Stop after overlay dismissal should not create gap - const afterStopViewport = term.getViewport().map(l => l.trimEnd()); - const contentLines = afterStopViewport.filter(l => l.trim().length > 0); - expect(contentLines.length).toBeGreaterThanOrEqual(5); - } finally { - if (!tui.fullRedraws) tui.stop(); - } - }); }); diff --git a/packages/tui/test/render-regressions.test.ts b/packages/tui/test/render-regressions.test.ts index d71be7745..264aed4fe 100644 --- a/packages/tui/test/render-regressions.test.ts +++ b/packages/tui/test/render-regressions.test.ts @@ -41,35 +41,6 @@ function countMatches(lines: string[], pattern: RegExp): number { return count; } -function longestBlankRun(lines: string[]): number { - let longest = 0; - let current = 0; - for (const line of lines) { - if (line.trim().length === 0) { - current += 1; - longest = Math.max(longest, current); - } else { - current = 0; - } - } - return longest; -} - -/** Count blank lines at the end of the buffer (after the last content line). */ -function trailingBlanks(lines: string[]): number { - let count = 0; - for (let i = lines.length - 1; i >= 0; i--) { - if (lines[i].trim().length === 0) count++; - else break; - } - return count; -} - -function activeBuffer(term: VirtualTerminal): { baseY: number; cursorY: number } { - return (term as unknown as { xterm: { buffer: { active: { baseY: number; cursorY: number } } } }).xterm.buffer - .active; -} - describe("TUI terminal-state regressions", () => { describe("cursor + differential stability", () => { it("keeps stable output across repeated no-op renders", async () => { @@ -170,7 +141,7 @@ describe("TUI terminal-state regressions", () => { }); describe("resize + viewport behavior", () => { - it("preserves preexisting shell rows across startup and resize redraws", async () => { + it("clears preexisting shell rows on startup and resize redraw", async () => { const term = new VirtualTerminal(50, 5); term.write("shell-0\r\nshell-1\r\nshell-2\r\nshell-3\r\nshell-4\r\n"); await settle(term); @@ -187,64 +158,7 @@ describe("TUI terminal-state regressions", () => { await settle(term); const buffer = term.getScrollBuffer().join("\n"); - expect(buffer.includes("shell-0")).toBeTruthy(); - expect(buffer.includes("shell-4")).toBeTruthy(); - expect(visible(term).join("\n").includes("shell-")).toBeFalsy(); - } finally { - tui.stop(); - } - }); - - it("Termux no-op height increase does not replay overflowing viewport rows into scrollback", async () => { - const previousTermuxVersion = process.env.TERMUX_VERSION; - process.env.TERMUX_VERSION = "1"; - const term = new VirtualTerminal(40, 4); - const tui = new TUI(term); - const component = new MutableLinesComponent(rows("ui-", 12)); - tui.addChild(component); - - try { - tui.start(); - await settle(term); - expect(countMatches(term.getScrollBuffer(), /\bui-6\b/)).toBe(1); - expect(countMatches(term.getScrollBuffer(), /\bui-11\b/)).toBe(1); - - term.resize(40, 6); - await settle(term); - - expect(countMatches(term.getScrollBuffer(), /\bui-6\b/)).toBe(1); - expect(countMatches(term.getScrollBuffer(), /\bui-7\b/)).toBe(1); - expect(countMatches(term.getScrollBuffer(), /\bui-8\b/)).toBe(1); - expect(countMatches(term.getScrollBuffer(), /\bui-9\b/)).toBe(1); - expect(countMatches(term.getScrollBuffer(), /\bui-10\b/)).toBe(1); - expect(countMatches(term.getScrollBuffer(), /\bui-11\b/)).toBe(1); - } finally { - if (previousTermuxVersion === undefined) delete process.env.TERMUX_VERSION; - else process.env.TERMUX_VERSION = previousTermuxVersion; - tui.stop(); - } - }); - - it("height increase after content shrink scrolls only the visible rows", async () => { - const term = new VirtualTerminal(40, 10); - const tui = new TUI(term); - const component = new MutableLinesComponent(rows("ui-", 30)); - tui.addChild(component); - - try { - tui.start(); - await settle(term); - - component.setLines(rows("ui-", 3)); - tui.requestRender(); - await settle(term); - - const beforeBaseY = activeBuffer(term).baseY; - term.resize(40, 12); - await settle(term); - - expect(activeBuffer(term).baseY).toBe(beforeBaseY); - expect(visible(term).slice(0, 3)).toEqual(["ui-0", "ui-1", "ui-2"]); + expect(buffer.includes("shell-")).toBeFalsy(); } finally { tui.stop(); } @@ -693,10 +607,8 @@ describe("TUI terminal-state regressions", () => { }); describe("scrollback integrity", () => { - it("overflowing startup preserves shell scrollback while keeping each row unique across the full buffer", async () => { + it("overflow content appears once across buffer without duplicate row IDs", async () => { const term = new VirtualTerminal(32, 5); - term.write("shell-0\r\nshell-1\r\nshell-2\r\nshell-3\r\nshell-4\r\n"); - await settle(term); const tui = new TUI(term); const component = new MutableLinesComponent(rows("line-", 10)); tui.addChild(component); @@ -706,10 +618,6 @@ describe("TUI terminal-state regressions", () => { await settle(term); const all = term.getScrollBuffer(); - const allText = all.join("\n"); - expect(allText.includes("shell-0")).toBeTruthy(); - expect(allText.includes("shell-4")).toBeTruthy(); - expect(visible(term).join("\n").includes("shell-")).toBeFalsy(); for (let i = 0; i < 10; i++) { const pattern = new RegExp(`\\bline-${i}\\b`); expect(countMatches(all, pattern), `line-${i} should appear exactly once`).toBe(1); @@ -754,10 +662,8 @@ describe("TUI terminal-state regressions", () => { } }); - it("offscreen header changes preserve shell history during overflow growth", async () => { + it("retains append history when offscreen header changes during overflow growth", async () => { const term = new VirtualTerminal(32, 6); - term.write("shell-0\r\nshell-1\r\nshell-2\r\nshell-3\r\nshell-4\r\n"); - await settle(term); const tui = new TUI(term); const logLines = rows("line-", 6); let tick = 0; @@ -777,9 +683,6 @@ describe("TUI terminal-state regressions", () => { } const scrollback = term.getScrollBuffer(); - const scrollbackText = scrollback.join("\n"); - expect(scrollbackText.includes("shell-0")).toBeTruthy(); - expect(scrollbackText.includes("shell-4")).toBeTruthy(); for (let i = 0; i < 70; i++) { expect(countMatches(scrollback, new RegExp(`\\bline-${i}\\b`))).toBe(1); } @@ -788,7 +691,6 @@ describe("TUI terminal-state regressions", () => { } const viewport = visible(term).map(line => line.trim()); - expect(viewport.join("\n").includes("shell-")).toBeFalsy(); expect(viewport.at(-1)).toBe("line-69"); for (let i = 1; i < viewport.length; i++) { const prev = Number.parseInt(viewport[i - 1]!.slice(5), 10); @@ -799,41 +701,6 @@ describe("TUI terminal-state regressions", () => { tui.stop(); } }); - it("large delete fallback preserves shell scrollback without stale rows", async () => { - const term = new VirtualTerminal(32, 5); - term.write("shell-0\r\nshell-1\r\nshell-2\r\nshell-3\r\nshell-4\r\n"); - await settle(term); - const tui = new TUI(term); - tui.setClearOnShrink(false); - const component = new MutableLinesComponent(rows("row-", 18)); - tui.addChild(component); - - try { - tui.start(); - await settle(term); - - component.setLines(rows("row-", 4)); - tui.requestRender(); - await settle(term); - - const buffer = term.getScrollBuffer(); - const bufferText = buffer.join("\n"); - expect(bufferText.includes("shell-0")).toBeTruthy(); - expect(bufferText.includes("shell-4")).toBeTruthy(); - expect(visible(term).join("\n").includes("shell-")).toBeFalsy(); - expect(visible(term).filter(line => line.trim().length > 0)).toEqual(["row-0", "row-1", "row-2", "row-3"]); - const viewportRows = visible(term).filter(line => line.trim().length > 0); - for (let i = 0; i < 4; i++) { - expect( - viewportRows.filter(r => r === `row-${i}`).length, - `viewport row-${i} should appear exactly once`, - ).toBe(1); - } - } finally { - tui.stop(); - } - }); - it("updates visible tail line when appending during overflow", async () => { const term = new VirtualTerminal(32, 5); const tui = new TUI(term); @@ -860,12 +727,10 @@ describe("TUI terminal-state regressions", () => { tui.stop(); } }); - it("forced full redraws preserve shell history without duplicating overflowing content", async () => { + it("forced full redraws do not duplicate persistent content", async () => { const term = new VirtualTerminal(40, 5); - term.write("shell-0\r\nshell-1\r\nshell-2\r\nshell-3\r\nshell-4\r\n"); - await settle(term); const tui = new TUI(term); - const component = new MutableLinesComponent(rows("line-", 14)); + const component = new MutableLinesComponent(["alpha", "beta", "gamma"]); tui.addChild(component); try { @@ -877,15 +742,10 @@ describe("TUI terminal-state regressions", () => { await settle(term); } - const all = term.getScrollBuffer(); - const allText = all.join("\n"); - expect(allText.includes("shell-0")).toBeTruthy(); - expect(allText.includes("shell-4")).toBeTruthy(); - expect(visible(term).join("\n").includes("shell-")).toBeFalsy(); - for (let i = 0; i < 14; i++) { - expect(countMatches(all, new RegExp(`\\bline-${i}\\b`)), `line-${i} should appear exactly once`).toBe(1); - } - expect(visible(term).at(-1)?.trim()).toBe("line-13"); + const allText = term.getScrollBuffer().join("\n"); + expect((allText.match(/alpha/g) ?? []).length).toBe(1); + expect((allText.match(/beta/g) ?? []).length).toBe(1); + expect((allText.match(/gamma/g) ?? []).length).toBe(1); } finally { tui.stop(); } @@ -954,282 +814,4 @@ describe("TUI terminal-state regressions", () => { } }); }); - - describe("exit gap regression", () => { - it("stop after tall content does not leave a large blank gap below content", async () => { - const term = new VirtualTerminal(40, 10); - const tui = new TUI(term); - const component = new MutableLinesComponent(rows("line-", 60)); - tui.addChild(component); - - tui.start(); - await settle(term); - expect(visible(term).at(-1)?.trim()).toBe("line-59"); - - tui.stop(); - await settle(term); - - // After exit, the viewport should still show content with at most - // 1-2 trailing blank rows (for the shell prompt boundary). - const viewport = visible(term); - const contentLines = viewport.filter(l => l.trim().length > 0); - expect(contentLines.length).toBeGreaterThanOrEqual(8); - expect(trailingBlanks(viewport)).toBeLessThanOrEqual(2); - }); - - it("stop after overflowing content with shell history does not add blank rows", async () => { - const term = new VirtualTerminal(40, 8); - term.write("shell-0\r\nshell-1\r\nshell-2\r\nshell-3\r\n"); - await settle(term); - - const tui = new TUI(term); - const component = new MutableLinesComponent(rows("line-", 30)); - tui.addChild(component); - - tui.start(); - await settle(term); - - tui.stop(); - await settle(term); - - const scrollback = term.getScrollBuffer(); - // Shell history should survive - expect(scrollback.join("\n").includes("shell-0")).toBeTruthy(); - // After stop, the viewport should have content, not a big blank gap - const viewport = visible(term); - expect(trailingBlanks(viewport)).toBeLessThanOrEqual(2); - }); - - it("stop after shrink does not push content off screen", async () => { - const term = new VirtualTerminal(40, 10); - const tui = new TUI(term); - const component = new MutableLinesComponent(rows("line-", 40)); - tui.addChild(component); - - tui.start(); - await settle(term); - - // Shrink content dramatically - component.setLines(["New session started"]); - tui.requestRender(true); - await settle(term); - - tui.stop(); - await settle(term); - - // After exit, the viewport should still show the shrunken content - const viewport = visible(term); - expect(viewport[0]?.trim()).toBe("New session started"); - // Content should not be scrolled off by exit - expect(viewport.filter(l => l.trim().length > 0).length).toBeGreaterThanOrEqual(1); - }); - }); - - describe("content shrink regression", () => { - it("shrink from tall to tiny anchors prompt near content, not at terminal bottom", async () => { - const term = new VirtualTerminal(40, 20); - const tui = new TUI(term); - const component = new MutableLinesComponent(rows("line-", 80)); - tui.addChild(component); - - try { - tui.start(); - await settle(term); - - // Simulate /new: content collapses to just a few lines - component.setLines(["New session started", "prompt>"]); - tui.requestRender(true); - await settle(term); - - const viewport = visible(term); - // Content should be at the top of the viewport, not at the bottom - expect(viewport[0]?.trim()).toBe("New session started"); - expect(viewport[1]?.trim()).toBe("prompt>"); - // The rest should be blank - no long blank run ABOVE the content - for (let i = 2; i < 20; i++) { - expect(viewport[i]?.trim()).toBe(""); - } - } finally { - tui.stop(); - } - }); - - it("repeated shrink cycles do not accumulate blank lines", async () => { - const term = new VirtualTerminal(40, 12); - const tui = new TUI(term); - const component = new MutableLinesComponent(rows("line-", 50)); - tui.addChild(component); - - try { - tui.start(); - await settle(term); - - for (let cycle = 0; cycle < 5; cycle++) { - // Shrink to tiny - component.setLines([`session-${cycle}`]); - tui.requestRender(true); - await settle(term); - - // Grow back to overflowing - component.setLines(rows("line-", 50)); - tui.requestRender(); - await settle(term); - } - - // After cycles, viewport should show the tail of content - const viewport = visible(term); - expect(viewport.at(-1)?.trim()).toBe("line-49"); - - const scrollback = term.getScrollBuffer(); - // No giant blank run from accumulated drift - expect(longestBlankRun(scrollback)).toBeLessThan(15); - } finally { - tui.stop(); - } - }); - }); - - describe("overlay dismiss cursor recovery", () => { - it("overlay dismiss restores viewport without gap below content", async () => { - const term = new VirtualTerminal(40, 12); - const tui = new TUI(term); - const component = new MutableLinesComponent(rows("base-", 8)); - tui.addChild(component); - - try { - tui.start(); - await settle(term); - - // Show overlay taller than remaining viewport space - const overlay = new MutableLinesComponent(rows("over-", 6)); - const handle = tui.showOverlay(overlay, { anchor: "center" }); - await settle(term); - - // Dismiss overlay - handle.hide(); - await settle(term); - - // After dismiss, viewport should show base content without gaps - const viewport = visible(term); - expect(viewport[0]?.trim()).toBe("base-0"); - expect(viewport[7]?.trim()).toBe("base-7"); - // No content rows should be pushed below the viewport - for (let i = 8; i < 12; i++) { - expect(viewport[i]?.trim()).toBe(""); - } - } finally { - tui.stop(); - } - }); - - it("repeated overlay show/hide does not drift the cursor", async () => { - const term = new VirtualTerminal(40, 10); - const tui = new TUI(term); - const component = new MutableLinesComponent(rows("base-", 10)); - tui.addChild(component); - - try { - tui.start(); - await settle(term); - const baseViewport = visible(term); - - for (let i = 0; i < 10; i++) { - const handle = tui.showOverlay(new MutableLinesComponent([`overlay-${i}`]), { - anchor: "center", - }); - await settle(term); - handle.hide(); - await settle(term); - } - - // After 10 show/hide cycles, viewport must match the original base - expect(visible(term)).toEqual(baseViewport); - } finally { - tui.stop(); - } - }); - - it("stop after content shrink moves the prompt to the visible content boundary", async () => { - const term = new VirtualTerminal(40, 10); - const tui = new TUI(term); - const component = new MutableLinesComponent(rows("base-", 30)); - tui.addChild(component); - - try { - tui.start(); - await settle(term); - - component.setLines(rows("base-", 4)); - tui.requestRender(); - await settle(term); - - tui.stop(); - await settle(term); - - const viewport = visible(term); - expect(viewport[0]?.trim()).toBe("base-0"); - expect(viewport[3]?.trim()).toBe("base-3"); - const active = (term as unknown as { xterm: { buffer: { active: { cursorY: number; baseY: number } } } }) - .xterm.buffer.active; - expect(active.baseY).toBe(30); - expect(active.cursorY).toBe(4); - } finally { - // stop() already ran in the main flow; keep finally for symmetry if the test fails early - } - }); - - it("restart after content shrink preserves history without seeding blank scrollback", async () => { - const term = new VirtualTerminal(40, 10); - const tui = new TUI(term); - const component = new MutableLinesComponent(rows("base-", 30)); - tui.addChild(component); - - try { - tui.start(); - await settle(term); - - component.setLines(rows("base-", 3)); - tui.requestRender(); - await settle(term); - - tui.stop(); - await settle(term); - - const beforeBaseY = activeBuffer(term).baseY; - tui.start(); - await settle(term); - - expect(activeBuffer(term).baseY).toBe(beforeBaseY); - expect(visible(term).slice(0, 3)).toEqual(["base-0", "base-1", "base-2"]); - } finally { - tui.stop(); - } - }); - - it("stop after overlay dismissal does not create scrollback gap", async () => { - const term = new VirtualTerminal(40, 10); - const tui = new TUI(term); - const component = new MutableLinesComponent(rows("base-", 30)); - tui.addChild(component); - - tui.start(); - await settle(term); - - const handle = tui.showOverlay(new MutableLinesComponent(rows("over-", 5)), { - anchor: "center", - }); - await settle(term); - - handle.hide(); - await settle(term); - - tui.stop(); - await settle(term); - - // After stop, viewport should still have content - const viewport = visible(term); - expect(trailingBlanks(viewport)).toBeLessThanOrEqual(2); - expect(viewport.filter(l => l.trim().length > 0).length).toBeGreaterThanOrEqual(8); - }); - }); });