fix(tui): Container change detection for proper differential rendering

Fixed rendering artifact where duplicate bottom borders appeared when components
dynamically shifted positions (e.g., Ctrl+C in agent clearing status container).

Root cause: Container wasn't reporting as "changed" when cleared (0 children),
causing differential renderer to skip re-rendering that area.

Solution: Container now tracks previousChildCount and reports changed when
child count changes, ensuring proper re-rendering when containers are cleared.

- Added comprehensive test reproducing the layout shift artifact
- Fixed Container to track and report child count changes
- All tests pass including new layout shift artifact test
This commit is contained in:
Mario Zechner 2025-08-11 02:31:49 +02:00
parent 2ec8a27222
commit 192d8d2600
24 changed files with 356 additions and 2910 deletions

View file

@ -2,7 +2,6 @@ import { readdirSync, statSync } from "fs";
import mimeTypes from "mime-types";
import { homedir } from "os";
import { basename, dirname, extname, join } from "path";
import { logger } from "./logger.js";
function isAttachableFile(filePath: string): boolean {
const mimeType = mimeTypes.lookup(filePath);
@ -142,12 +141,6 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
cursorLine: number,
cursorCol: number,
): { items: AutocompleteItem[]; prefix: string } | null {
logger.debug("CombinedAutocompleteProvider", "getSuggestions called", {
lines,
cursorLine,
cursorCol,
});
const currentLine = lines[cursorLine] || "";
const textBeforeCursor = currentLine.slice(0, cursorCol);
@ -202,10 +195,6 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
// Check for file paths - triggered by Tab or if we detect a path pattern
const pathMatch = this.extractPathPrefix(textBeforeCursor, false);
logger.debug("CombinedAutocompleteProvider", "Path match check", {
textBeforeCursor,
pathMatch,
});
if (pathMatch !== null) {
const suggestions = this.getFileSuggestions(pathMatch);
@ -342,11 +331,6 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
// Get file/directory suggestions for a given path prefix
private getFileSuggestions(prefix: string): AutocompleteItem[] {
logger.debug("CombinedAutocompleteProvider", "getFileSuggestions called", {
prefix,
basePath: this.basePath,
});
try {
let searchDir: string;
let searchPrefix: string;
@ -399,11 +383,6 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
searchPrefix = file;
}
logger.debug("CombinedAutocompleteProvider", "Searching directory", {
searchDir,
searchPrefix,
});
const entries = readdirSync(searchDir);
const suggestions: AutocompleteItem[] = [];
@ -479,17 +458,9 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
return a.label.localeCompare(b.label);
});
logger.debug("CombinedAutocompleteProvider", "Returning suggestions", {
count: suggestions.length,
firstFew: suggestions.slice(0, 3).map((s) => s.label),
});
return suggestions.slice(0, 10); // Limit to 10 suggestions
} catch (e) {
// Directory doesn't exist or not accessible
logger.error("CombinedAutocompleteProvider", "Error reading directory", {
error: e instanceof Error ? e.message : String(e),
});
return [];
}
}
@ -500,12 +471,6 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
cursorLine: number,
cursorCol: number,
): { items: AutocompleteItem[]; prefix: string } | null {
logger.debug("CombinedAutocompleteProvider", "getForceFileSuggestions called", {
lines,
cursorLine,
cursorCol,
});
const currentLine = lines[cursorLine] || "";
const textBeforeCursor = currentLine.slice(0, cursorCol);
@ -516,11 +481,6 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
// Force extract path prefix - this will always return something
const pathMatch = this.extractPathPrefix(textBeforeCursor, true);
logger.debug("CombinedAutocompleteProvider", "Forced path match", {
textBeforeCursor,
pathMatch,
});
if (pathMatch !== null) {
const suggestions = this.getFileSuggestions(pathMatch);
if (suggestions.length === 0) return null;

View file

@ -1,6 +1,5 @@
import chalk from "chalk";
import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete.js";
import { logger } from "../logger.js";
import { type Component, type ComponentRenderResult, getNextComponentId } from "../tui.js";
import { SelectList } from "./select-list.js";
@ -44,12 +43,10 @@ export class TextEditor implements Component {
if (config) {
this.config = { ...this.config, ...config };
}
logger.componentLifecycle("TextEditor", "created", { config: this.config });
}
configure(config: Partial<TextEditorConfig>): void {
this.config = { ...this.config, ...config };
logger.info("TextEditor", "Configuration updated", { config: this.config });
}
setAutocompleteProvider(provider: AutocompleteProvider): void {
@ -127,48 +124,22 @@ export class TextEditor implements Component {
}
handleInput(data: string): void {
logger.keyInput("TextEditor", data);
logger.debug("TextEditor", "Current state before input", {
lines: this.state.lines,
cursorLine: this.state.cursorLine,
cursorCol: this.state.cursorCol,
});
// Handle special key combinations first
// Ctrl+C - Exit (let parent handle this)
if (data.charCodeAt(0) === 3) {
logger.debug("TextEditor", "Ctrl+C received, returning to parent");
return;
}
// Handle paste - detect when we get a lot of text at once
const isPaste = data.length > 10 || (data.length > 2 && data.includes("\n"));
logger.debug("TextEditor", "Paste detection", {
dataLength: data.length,
includesNewline: data.includes("\n"),
includesTabs: data.includes("\t"),
tabCount: (data.match(/\t/g) || []).length,
isPaste,
data: JSON.stringify(data),
charCodes: Array.from(data).map((c) => c.charCodeAt(0)),
});
if (isPaste) {
logger.info("TextEditor", "Handling as paste");
this.handlePaste(data);
return;
}
// Handle autocomplete special keys first (but don't block other input)
if (this.isAutocompleting && this.autocompleteList) {
logger.debug("TextEditor", "Autocomplete active, handling input", {
data,
charCode: data.charCodeAt(0),
isEscape: data === "\x1b",
isArrowOrEnter: data === "\x1b[A" || data === "\x1b[B" || data === "\r",
});
// Escape - cancel autocomplete
if (data === "\x1b") {
this.cancelAutocomplete();
@ -216,15 +187,10 @@ export class TextEditor implements Component {
}
// For other keys (like regular typing), DON'T return here
// Let them fall through to normal character handling
logger.debug("TextEditor", "Autocomplete active but falling through to normal handling");
}
// Tab key - context-aware completion (but not when already autocompleting)
if (data === "\t" && !this.isAutocompleting) {
logger.debug("TextEditor", "Tab key pressed, determining context", {
isAutocompleting: this.isAutocompleting,
hasProvider: !!this.autocompleteProvider,
});
this.handleTabCompletion();
return;
}
@ -263,12 +229,6 @@ export class TextEditor implements Component {
// Plain Enter = submit
const result = this.state.lines.join("\n").trim();
logger.info("TextEditor", "Submit triggered", {
result,
rawResult: JSON.stringify(this.state.lines.join("\n")),
lines: this.state.lines,
resultLines: result.split("\n"),
});
// Reset editor
this.state = {
@ -283,10 +243,7 @@ export class TextEditor implements Component {
}
if (this.onSubmit) {
logger.info("TextEditor", "Calling onSubmit callback", { result });
this.onSubmit(result);
} else {
logger.warn("TextEditor", "No onSubmit callback set");
}
}
// Backspace
@ -322,13 +279,7 @@ export class TextEditor implements Component {
}
// Regular characters (printable ASCII)
else if (data.charCodeAt(0) >= 32 && data.charCodeAt(0) <= 126) {
logger.debug("TextEditor", "Inserting character", { char: data, charCode: data.charCodeAt(0) });
this.insertCharacter(data);
} else {
logger.warn("TextEditor", "Unhandled input", {
data,
charCodes: Array.from(data).map((c) => c.charCodeAt(0)),
});
}
}
@ -458,12 +409,6 @@ export class TextEditor implements Component {
}
private handlePaste(pastedText: string): void {
logger.debug("TextEditor", "Processing paste", {
pastedText: JSON.stringify(pastedText),
hasTab: pastedText.includes("\t"),
tabCount: (pastedText.match(/\t/g) || []).length,
});
// Clean the pasted text
const cleanText = pastedText.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
@ -667,11 +612,6 @@ export class TextEditor implements Component {
// Autocomplete methods
private tryTriggerAutocomplete(explicitTab: boolean = false): void {
logger.debug("TextEditor", "tryTriggerAutocomplete called", {
explicitTab,
hasProvider: !!this.autocompleteProvider,
});
if (!this.autocompleteProvider) return;
// Check if we should trigger file completion on Tab
@ -680,15 +620,6 @@ export class TextEditor implements Component {
const shouldTrigger =
!provider.shouldTriggerFileCompletion ||
provider.shouldTriggerFileCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol);
logger.debug("TextEditor", "Tab file completion check", {
hasShouldTriggerMethod: !!provider.shouldTriggerFileCompletion,
shouldTrigger,
lines: this.state.lines,
cursorLine: this.state.cursorLine,
cursorCol: this.state.cursorCol,
});
if (!shouldTrigger) {
return;
}
@ -700,12 +631,6 @@ export class TextEditor implements Component {
this.state.cursorCol,
);
logger.debug("TextEditor", "Autocomplete suggestions", {
hasSuggestions: !!suggestions,
itemCount: suggestions?.items.length || 0,
prefix: suggestions?.prefix,
});
if (suggestions && suggestions.items.length > 0) {
this.autocompletePrefix = suggestions.prefix;
this.autocompleteList = new SelectList(suggestions.items, 5);
@ -723,57 +648,16 @@ export class TextEditor implements Component {
// Check if we're in a slash command context
if (beforeCursor.trimStart().startsWith("/")) {
logger.debug("TextEditor", "Tab in slash command context", { beforeCursor });
this.handleSlashCommandCompletion();
} else {
logger.debug("TextEditor", "Tab in file completion context", { beforeCursor });
this.forceFileAutocomplete();
}
}
private handleSlashCommandCompletion(): void {
// For now, fall back to regular autocomplete (slash commands)
// This can be extended later to handle command-specific argument completion
logger.debug("TextEditor", "Handling slash command completion");
this.tryTriggerAutocomplete(true);
}
private forceFileAutocomplete(): void {
logger.debug("TextEditor", "forceFileAutocomplete called", {
hasProvider: !!this.autocompleteProvider,
});
if (!this.autocompleteProvider) return;
// Check if provider has the force method
const provider = this.autocompleteProvider as any;
if (!provider.getForceFileSuggestions) {
logger.debug("TextEditor", "Provider doesn't support forced file completion, falling back to regular");
this.tryTriggerAutocomplete(true);
return;
}
const suggestions = provider.getForceFileSuggestions(
this.state.lines,
this.state.cursorLine,
this.state.cursorCol,
);
logger.debug("TextEditor", "Forced file autocomplete suggestions", {
hasSuggestions: !!suggestions,
itemCount: suggestions?.items.length || 0,
prefix: suggestions?.prefix,
});
if (suggestions && suggestions.items.length > 0) {
this.autocompletePrefix = suggestions.prefix;
this.autocompleteList = new SelectList(suggestions.items, 5);
this.isAutocompleting = true;
} else {
this.cancelAutocomplete();
}
}
private cancelAutocomplete(): void {
this.isAutocompleting = false;
this.autocompleteList = undefined as any;

View file

@ -19,8 +19,6 @@ export { TextComponent } from "./components/text-component.js";
export { TextEditor, type TextEditorConfig } from "./components/text-editor.js";
// Whitespace component
export { WhitespaceComponent } from "./components/whitespace-component.js";
// Logger for debugging
export { type LoggerConfig, logger } from "./logger.js";
// Terminal interface and implementations
export { ProcessTerminal, type Terminal } from "./terminal.js";
export {

View file

@ -1,105 +0,0 @@
import { appendFileSync, writeFileSync } from "fs";
import { join } from "path";
export interface LoggerConfig {
enabled: boolean;
logFile: string;
logLevel: "debug" | "info" | "warn" | "error";
}
class Logger {
private config: LoggerConfig = {
enabled: false,
logFile: "tui-debug.log", // Will be resolved when needed
logLevel: "debug",
};
configure(config: Partial<LoggerConfig>): void {
this.config = { ...this.config, ...config };
if (this.config.enabled) {
// Clear log file on startup
try {
// Resolve log file path when needed
const logFile = this.config.logFile.startsWith("/")
? this.config.logFile
: join(process.cwd(), this.config.logFile);
writeFileSync(logFile, `=== TUI Debug Log Started ${new Date().toISOString()} ===\n`);
} catch (error) {
// Silently fail if we can't write to log file
}
}
}
private shouldLog(level: string): boolean {
if (!this.config.enabled) return false;
const levels = ["debug", "info", "warn", "error"];
const currentLevel = levels.indexOf(this.config.logLevel);
const messageLevel = levels.indexOf(level);
return messageLevel >= currentLevel;
}
private log(level: string, component: string, message: string, data?: any): void {
if (!this.shouldLog(level)) return;
try {
const timestamp = new Date().toISOString();
const dataStr = data ? ` | Data: ${JSON.stringify(data)}` : "";
const logLine = `[${timestamp}] ${level.toUpperCase()} [${component}] ${message}${dataStr}\n`;
// Resolve log file path when needed
const logFile = this.config.logFile.startsWith("/")
? this.config.logFile
: join(process.cwd(), this.config.logFile);
appendFileSync(logFile, logLine);
} catch (error) {
// Silently fail if we can't write to log file
}
}
debug(component: string, message: string, data?: any): void {
this.log("debug", component, message, data);
}
info(component: string, message: string, data?: any): void {
this.log("info", component, message, data);
}
warn(component: string, message: string, data?: any): void {
this.log("warn", component, message, data);
}
error(component: string, message: string, data?: any): void {
this.log("error", component, message, data);
}
// Specific TUI logging methods
keyInput(component: string, keyData: string): void {
this.debug(component, "Key input received", {
keyData,
charCodes: Array.from(keyData).map((c) => c.charCodeAt(0)),
});
}
render(component: string, renderResult: any): void {
this.debug(component, "Render result", renderResult);
}
focus(component: string, focused: boolean): void {
this.info(component, `Focus ${focused ? "gained" : "lost"}`);
}
componentLifecycle(component: string, action: string, details?: any): void {
this.info(component, `Component ${action}`, details);
}
stateChange(component: string, property: string, oldValue: any, newValue: any): void {
this.debug(component, `State change: ${property}`, { oldValue, newValue });
}
}
export const logger = new Logger();

View file

@ -1,5 +1,4 @@
import process from "process";
import { logger } from "./logger.js";
import { ProcessTerminal, type Terminal } from "./terminal.js";
/**
@ -40,8 +39,9 @@ export interface Padding {
*/
export class Container implements Component {
readonly id: number;
protected children: (Component | Container)[] = [];
public children: (Component | Container)[] = [];
private tui?: TUI;
private previousChildCount: number = 0;
constructor() {
this.id = getNextComponentId();
@ -108,6 +108,12 @@ export class Container implements Component {
const lines: string[] = [];
let changed = false;
// Check if the number of children changed (important for detecting clears)
if (this.children.length !== this.previousChildCount) {
changed = true;
this.previousChildCount = this.children.length;
}
for (const child of this.children) {
const result = child.render(width);
lines.push(...result.lines);
@ -162,13 +168,6 @@ export class TUI extends Container {
// Use provided terminal or default to ProcessTerminal
this.terminal = terminal || new ProcessTerminal();
logger.componentLifecycle("TUI", "created");
}
configureLogging(config: Parameters<typeof logger.configure>[0]): void {
logger.configure(config);
logger.info("TUI", "Logging configured", config);
}
setFocus(component: Component): void {
@ -321,11 +320,6 @@ export class TUI extends Container {
// Save what we rendered
this.previousLines = lines;
this.totalLinesRedrawn += lines.length;
logger.debug("TUI", "Initial render", {
commandsExecuted: commands.length,
linesRendered: lines.length,
});
}
private renderDifferentialSurgical(currentCommands: RenderCommand[], termHeight: number): void {
@ -344,7 +338,7 @@ export class TUI extends Container {
let firstChangeOffset = -1;
let hasLineCountChange = false;
let hasStructuralChange = false;
const changedLines: Array<{lineIndex: number, newContent: string}> = [];
const changedLines: Array<{ lineIndex: number; newContent: string }> = [];
let currentLineOffset = 0;
@ -373,15 +367,14 @@ export class TUI extends Container {
// Content change with same line count - track individual line changes
if (current.changed) {
for (let j = 0; j < current.lines.length; j++) {
const oldLine = currentLineOffset + j < this.previousLines.length
? this.previousLines[currentLineOffset + j]
: "";
const oldLine =
currentLineOffset + j < this.previousLines.length ? this.previousLines[currentLineOffset + j] : "";
const newLine = current.lines[j];
if (oldLine !== newLine) {
changedLines.push({
lineIndex: currentLineOffset + j,
newContent: newLine
newContent: newLine,
});
if (firstChangeOffset === -1) {
firstChangeOffset = currentLineOffset + j;
@ -417,7 +410,6 @@ export class TUI extends Container {
if (newLines.length > 0) output += "\r\n";
linesRedrawn = newLines.length;
} else if (hasStructuralChange || hasLineCountChange) {
// Strategy: PARTIAL - changes in viewport but with shifts, clear from change to end
// After rendering with a final newline, cursor is one line below the last content line
@ -433,6 +425,9 @@ export class TUI extends Container {
output += `\x1b[${linesToMoveUp}A`;
}
// Clear from cursor to end of screen
// First ensure we're at the beginning of the line
output += "\r";
output += "\x1b[0J"; // Clear from cursor to end of screen
const linesToRender = newLines.slice(firstChangeOffset);
@ -443,19 +438,11 @@ export class TUI extends Container {
if (linesToRender.length > 0) output += "\r\n";
linesRedrawn = linesToRender.length;
} else {
// Strategy: SURGICAL - only content changes with same line counts, update only changed lines
// The cursor starts at the line after our last content
let currentCursorLine = totalOldLines;
logger.debug("TUI", "SURGICAL strategy", {
totalOldLines,
totalNewLines,
changedLines: changedLines.map(c => ({ line: c.lineIndex, content: c.newContent.substring(0, 30) })),
currentCursorLine
});
for (const change of changedLines) {
// Move cursor to the line that needs updating
const linesToMove = currentCursorLine - change.lineIndex;
@ -497,21 +484,9 @@ export class TUI extends Container {
// Save what we rendered
this.previousLines = newLines;
this.totalLinesRedrawn += linesRedrawn;
logger.debug("TUI", "Surgical differential render", {
strategy: changePositionInViewport < 0 ? "FULL" :
(hasStructuralChange || hasLineCountChange) ? "PARTIAL" : "SURGICAL",
linesRedrawn,
firstChangeOffset,
changePositionInViewport,
hasStructuralChange,
hasLineCountChange,
surgicalChanges: changedLines.length,
totalNewLines,
totalOldLines,
});
}
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: Keeping this around as reference for LLM
private renderDifferential(currentCommands: RenderCommand[], termHeight: number): void {
const viewportHeight = termHeight - 1; // Leave one line for cursor
@ -611,14 +586,6 @@ export class TUI extends Container {
// Save what we rendered
this.previousLines = newLines;
this.totalLinesRedrawn += linesRedrawn;
logger.debug("TUI", "Differential render", {
linesRedrawn,
firstChangedLineOffset,
changePositionInViewport,
totalNewLines,
totalOldLines,
});
}
private handleResize(): void {
@ -628,8 +595,6 @@ export class TUI extends Container {
}
private handleKeypress(data: string): void {
logger.keyInput("TUI", data);
if (this.onGlobalKeyPress) {
const shouldForward = this.onGlobalKeyPress(data);
if (!shouldForward) {
@ -639,16 +604,8 @@ export class TUI extends Container {
}
if (this.focusedComponent?.handleInput) {
logger.debug("TUI", "Forwarding input to focused component", {
componentType: this.focusedComponent.constructor.name,
});
this.focusedComponent.handleInput(data);
this.requestRender();
} else {
logger.warn("TUI", "No focused component to handle input", {
focusedComponent: this.focusedComponent?.constructor.name || "none",
hasHandleInput: this.focusedComponent?.handleInput ? "yes" : "no",
});
}
}
}