mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-19 12:04:08 +00:00
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:
parent
2ec8a27222
commit
192d8d2600
24 changed files with 356 additions and 2910 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue