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;