revert: 535 due to TUI regressions

This commit is contained in:
can1357
2026-03-26 19:51:43 +01:00
parent 18fdcde6ea
commit 8e5cc87f00
11 changed files with 152 additions and 1511 deletions
@@ -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);
@@ -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 <view|clear|reset|enqueue|rebuild>");
}
async handleClearCommand(options?: ClearCommandOptions): Promise<boolean> {
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<void> {
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<void> {
@@ -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<void> {
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://<title>.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> {
+1 -9
View File
@@ -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>;
@@ -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();
@@ -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. */
@@ -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");
});
});
@@ -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",
]);
});
});
+51 -194
View File
@@ -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;
}
/**
+6 -175
View File
@@ -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();
}
});
});
+10 -428
View File
@@ -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);
});
});
});