tui-double-buffer: Implement smart differential rendering with terminal abstraction

- Create Terminal interface abstracting stdin/stdout operations for dependency injection
- Implement ProcessTerminal for production use with process.stdin/stdout
- Implement VirtualTerminal using @xterm/headless for accurate terminal emulation in tests
- Fix TypeScript imports for @xterm/headless module
- Move all component files to src/components/ directory for better organization
- Add comprehensive test suite with async/await patterns for proper render timing
- Fix critical TUI differential rendering bug when components grow in height
  - Issue: Old content wasn't properly cleared when component line count increased
  - Solution: Clear each old line individually before redrawing, ensure cursor at line start
- Add test verifying terminal content preservation and text editor growth behavior
- Update tsconfig.json to include test files in type checking
- Add benchmark test comparing single vs double buffer performance

The implementation successfully reduces flicker by only updating changed lines
rather than clearing entire sections. Both TUI implementations maintain the
same interface for backward compatibility.
This commit is contained in:
Mario Zechner
2025-08-10 22:33:03 +02:00
parent 923a9e58ab
commit afa807b200
19 changed files with 1591 additions and 344 deletions

View File

@@ -0,0 +1,803 @@
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";
interface EditorState {
lines: string[];
cursorLine: number;
cursorCol: number;
}
interface LayoutLine {
text: string;
hasCursor: boolean;
cursorPos?: number;
}
export interface TextEditorConfig {
// Configuration options for text editor (none currently)
}
export class TextEditor implements Component {
readonly id = getNextComponentId();
private state: EditorState = {
lines: [""],
cursorLine: 0,
cursorCol: 0,
};
private config: TextEditorConfig = {};
// Autocomplete support
private autocompleteProvider?: AutocompleteProvider;
private autocompleteList?: SelectList;
private isAutocompleting: boolean = false;
private autocompletePrefix: string = "";
public onSubmit?: (text: string) => void;
public onChange?: (text: string) => void;
public disableSubmit: boolean = false;
constructor(config?: TextEditorConfig) {
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 {
this.autocompleteProvider = provider;
}
render(width: number): ComponentRenderResult {
// Box drawing characters
const topLeft = chalk.gray("╭");
const topRight = chalk.gray("╮");
const bottomLeft = chalk.gray("╰");
const bottomRight = chalk.gray("╯");
const horizontal = chalk.gray("─");
const vertical = chalk.gray("│");
// Calculate box width - leave 1 char margin to avoid edge wrapping
const boxWidth = width - 1;
const contentWidth = boxWidth - 4; // Account for "│ " and " │"
// Layout the text
const layoutLines = this.layoutText(contentWidth);
const result: string[] = [];
// Render top border
result.push(topLeft + horizontal.repeat(boxWidth - 2) + topRight);
// Render each layout line
for (const layoutLine of layoutLines) {
let displayText = layoutLine.text;
let visibleLength = layoutLine.text.length;
// Add cursor if this line has it
if (layoutLine.hasCursor && layoutLine.cursorPos !== undefined) {
const before = displayText.slice(0, layoutLine.cursorPos);
const after = displayText.slice(layoutLine.cursorPos);
if (after.length > 0) {
// Cursor is on a character - replace it with highlighted version
const cursor = `\x1b[7m${after[0]}\x1b[0m`;
const restAfter = after.slice(1);
displayText = before + cursor + restAfter;
// visibleLength stays the same - we're replacing, not adding
} else {
// Cursor is at the end - add highlighted space
const cursor = "\x1b[7m \x1b[0m";
displayText = before + cursor;
// visibleLength increases by 1 - we're adding a space
visibleLength = layoutLine.text.length + 1;
}
}
// Calculate padding based on actual visible length
const padding = " ".repeat(Math.max(0, contentWidth - visibleLength));
// Render the line
result.push(`${vertical} ${displayText}${padding} ${vertical}`);
}
// Render bottom border
result.push(bottomLeft + horizontal.repeat(boxWidth - 2) + bottomRight);
// Add autocomplete list if active
if (this.isAutocompleting && this.autocompleteList) {
const autocompleteResult = this.autocompleteList.render(width);
result.push(...autocompleteResult.lines);
}
// For interactive components like text editors, always assume changed
// This ensures cursor position updates are always reflected
return {
lines: result,
changed: true,
};
}
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();
return;
}
// Let the autocomplete list handle navigation and selection
else if (data === "\x1b[A" || data === "\x1b[B" || data === "\r" || data === "\t") {
// Only pass arrow keys to the list, not Enter/Tab (we handle those directly)
if (data === "\x1b[A" || data === "\x1b[B") {
this.autocompleteList.handleInput(data);
}
// If Tab was pressed, apply the selection
if (data === "\t") {
const selected = this.autocompleteList.getSelectedItem();
if (selected && this.autocompleteProvider) {
const result = this.autocompleteProvider.applyCompletion(
this.state.lines,
this.state.cursorLine,
this.state.cursorCol,
selected,
this.autocompletePrefix,
);
this.state.lines = result.lines;
this.state.cursorLine = result.cursorLine;
this.state.cursorCol = result.cursorCol;
this.cancelAutocomplete();
if (this.onChange) {
this.onChange(this.getText());
}
}
return;
}
// If Enter was pressed, cancel autocomplete and let it fall through to submission
else if (data === "\r") {
this.cancelAutocomplete();
// Don't return here - let Enter fall through to normal submission handling
} else {
// For other keys, handle normally within autocomplete
return;
}
}
// 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;
}
// Continue with rest of input handling
// Ctrl+K - Delete current line
if (data.charCodeAt(0) === 11) {
this.deleteCurrentLine();
}
// Ctrl+A - Move to start of line
else if (data.charCodeAt(0) === 1) {
this.moveToLineStart();
}
// Ctrl+E - Move to end of line
else if (data.charCodeAt(0) === 5) {
this.moveToLineEnd();
}
// New line shortcuts (but not plain LF/CR which should be submit)
else if (
(data.charCodeAt(0) === 10 && data.length > 1) || // Ctrl+Enter with modifiers
data === "\x1b\r" || // Option+Enter in some terminals
data === "\x1b[13;2~" || // Shift+Enter in some terminals
(data.length > 1 && data.includes("\x1b") && data.includes("\r")) ||
(data === "\n" && data.length === 1) || // Shift+Enter from iTerm2 mapping
data === "\\\r" // Shift+Enter in VS Code terminal
) {
// Modifier + Enter = new line
this.addNewLine();
}
// Plain Enter (char code 13 for CR) - only CR submits, LF adds new line
else if (data.charCodeAt(0) === 13 && data.length === 1) {
// If submit is disabled, do nothing
if (this.disableSubmit) {
return;
}
// 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 = {
lines: [""],
cursorLine: 0,
cursorCol: 0,
};
// Notify that editor is now empty
if (this.onChange) {
this.onChange("");
}
if (this.onSubmit) {
logger.info("TextEditor", "Calling onSubmit callback", { result });
this.onSubmit(result);
} else {
logger.warn("TextEditor", "No onSubmit callback set");
}
}
// Backspace
else if (data.charCodeAt(0) === 127 || data.charCodeAt(0) === 8) {
this.handleBackspace();
}
// Line navigation shortcuts (Home/End keys)
else if (data === "\x1b[H" || data === "\x1b[1~" || data === "\x1b[7~") {
// Home key
this.moveToLineStart();
} else if (data === "\x1b[F" || data === "\x1b[4~" || data === "\x1b[8~") {
// End key
this.moveToLineEnd();
}
// Forward delete (Fn+Backspace or Delete key)
else if (data === "\x1b[3~") {
// Delete key
this.handleForwardDelete();
}
// Arrow keys
else if (data === "\x1b[A") {
// Up
this.moveCursor(-1, 0);
} else if (data === "\x1b[B") {
// Down
this.moveCursor(1, 0);
} else if (data === "\x1b[C") {
// Right
this.moveCursor(0, 1);
} else if (data === "\x1b[D") {
// Left
this.moveCursor(0, -1);
}
// 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)),
});
}
}
private layoutText(contentWidth: number): LayoutLine[] {
const layoutLines: LayoutLine[] = [];
if (this.state.lines.length === 0 || (this.state.lines.length === 1 && this.state.lines[0] === "")) {
// Empty editor
layoutLines.push({
text: "> ",
hasCursor: true,
cursorPos: 2,
});
return layoutLines;
}
// Process each logical line
for (let i = 0; i < this.state.lines.length; i++) {
const line = this.state.lines[i] || "";
const isCurrentLine = i === this.state.cursorLine;
const prefix = i === 0 ? "> " : " ";
const prefixedLine = prefix + line;
const maxLineLength = contentWidth;
if (prefixedLine.length <= maxLineLength) {
// Line fits in one layout line
if (isCurrentLine) {
layoutLines.push({
text: prefixedLine,
hasCursor: true,
cursorPos: prefix.length + this.state.cursorCol,
});
} else {
layoutLines.push({
text: prefixedLine,
hasCursor: false,
});
}
} else {
// Line needs wrapping
const chunks = [];
for (let pos = 0; pos < prefixedLine.length; pos += maxLineLength) {
chunks.push(prefixedLine.slice(pos, pos + maxLineLength));
}
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
const chunk = chunks[chunkIndex];
if (!chunk) continue;
const chunkStart = chunkIndex * maxLineLength;
const chunkEnd = chunkStart + chunk.length;
const cursorPos = prefix.length + this.state.cursorCol;
const hasCursorInChunk = isCurrentLine && cursorPos >= chunkStart && cursorPos < chunkEnd;
if (hasCursorInChunk) {
layoutLines.push({
text: chunk,
hasCursor: true,
cursorPos: cursorPos - chunkStart,
});
} else {
layoutLines.push({
text: chunk,
hasCursor: false,
});
}
}
}
}
return layoutLines;
}
getText(): string {
return this.state.lines.join("\n");
}
setText(text: string): void {
// Split text into lines, handling different line endings
const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
// Ensure at least one empty line
this.state.lines = lines.length === 0 ? [""] : lines;
// Reset cursor to end of text
this.state.cursorLine = this.state.lines.length - 1;
this.state.cursorCol = this.state.lines[this.state.cursorLine]?.length || 0;
// Notify of change
if (this.onChange) {
this.onChange(this.getText());
}
}
// All the editor methods from before...
private insertCharacter(char: string): void {
const line = this.state.lines[this.state.cursorLine] || "";
const before = line.slice(0, this.state.cursorCol);
const after = line.slice(this.state.cursorCol);
this.state.lines[this.state.cursorLine] = before + char + after;
this.state.cursorCol += char.length; // Fix: increment by the length of the inserted string
if (this.onChange) {
this.onChange(this.getText());
}
// Check if we should trigger or update autocomplete
if (!this.isAutocompleting) {
// Auto-trigger for "/" at the start of a line (slash commands)
if (char === "/" && this.isAtStartOfMessage()) {
this.tryTriggerAutocomplete();
}
// Also auto-trigger when typing letters in a slash command context
else if (/[a-zA-Z0-9]/.test(char)) {
const currentLine = this.state.lines[this.state.cursorLine] || "";
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
// Check if we're in a slash command with a space (i.e., typing arguments)
if (textBeforeCursor.startsWith("/") && textBeforeCursor.includes(" ")) {
this.tryTriggerAutocomplete();
}
}
} else {
this.updateAutocomplete();
}
}
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");
// Convert tabs to spaces (4 spaces per tab)
const tabExpandedText = cleanText.replace(/\t/g, " ");
// Filter out non-printable characters except newlines
const filteredText = tabExpandedText
.split("")
.filter((char) => char === "\n" || (char >= " " && char <= "~"))
.join("");
// Split into lines
const pastedLines = filteredText.split("\n");
if (pastedLines.length === 1) {
// Single line - just insert each character
const text = pastedLines[0] || "";
for (const char of text) {
this.insertCharacter(char);
}
return;
}
// Multi-line paste - be very careful with array manipulation
const currentLine = this.state.lines[this.state.cursorLine] || "";
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
const afterCursor = currentLine.slice(this.state.cursorCol);
// Build the new lines array step by step
const newLines: string[] = [];
// Add all lines before current line
for (let i = 0; i < this.state.cursorLine; i++) {
newLines.push(this.state.lines[i] || "");
}
// Add the first pasted line merged with before cursor text
newLines.push(beforeCursor + (pastedLines[0] || ""));
// Add all middle pasted lines
for (let i = 1; i < pastedLines.length - 1; i++) {
newLines.push(pastedLines[i] || "");
}
// Add the last pasted line with after cursor text
newLines.push((pastedLines[pastedLines.length - 1] || "") + afterCursor);
// Add all lines after current line
for (let i = this.state.cursorLine + 1; i < this.state.lines.length; i++) {
newLines.push(this.state.lines[i] || "");
}
// Replace the entire lines array
this.state.lines = newLines;
// Update cursor position to end of pasted content
this.state.cursorLine += pastedLines.length - 1;
this.state.cursorCol = (pastedLines[pastedLines.length - 1] || "").length;
// Notify of change
if (this.onChange) {
this.onChange(this.getText());
}
}
private addNewLine(): void {
const currentLine = this.state.lines[this.state.cursorLine] || "";
const before = currentLine.slice(0, this.state.cursorCol);
const after = currentLine.slice(this.state.cursorCol);
// Split current line
this.state.lines[this.state.cursorLine] = before;
this.state.lines.splice(this.state.cursorLine + 1, 0, after);
// Move cursor to start of new line
this.state.cursorLine++;
this.state.cursorCol = 0;
if (this.onChange) {
this.onChange(this.getText());
}
}
private handleBackspace(): void {
if (this.state.cursorCol > 0) {
// Delete character in current line
const line = this.state.lines[this.state.cursorLine] || "";
const before = line.slice(0, this.state.cursorCol - 1);
const after = line.slice(this.state.cursorCol);
this.state.lines[this.state.cursorLine] = before + after;
this.state.cursorCol--;
} else if (this.state.cursorLine > 0) {
// Merge with previous line
const currentLine = this.state.lines[this.state.cursorLine] || "";
const previousLine = this.state.lines[this.state.cursorLine - 1] || "";
this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;
this.state.lines.splice(this.state.cursorLine, 1);
this.state.cursorLine--;
this.state.cursorCol = previousLine.length;
}
if (this.onChange) {
this.onChange(this.getText());
}
// Update autocomplete after backspace
if (this.isAutocompleting) {
this.updateAutocomplete();
}
}
private moveToLineStart(): void {
this.state.cursorCol = 0;
}
private moveToLineEnd(): void {
const currentLine = this.state.lines[this.state.cursorLine] || "";
this.state.cursorCol = currentLine.length;
}
private handleForwardDelete(): void {
const currentLine = this.state.lines[this.state.cursorLine] || "";
if (this.state.cursorCol < currentLine.length) {
// Delete character at cursor position (forward delete)
const before = currentLine.slice(0, this.state.cursorCol);
const after = currentLine.slice(this.state.cursorCol + 1);
this.state.lines[this.state.cursorLine] = before + after;
} else if (this.state.cursorLine < this.state.lines.length - 1) {
// At end of line - merge with next line
const nextLine = this.state.lines[this.state.cursorLine + 1] || "";
this.state.lines[this.state.cursorLine] = currentLine + nextLine;
this.state.lines.splice(this.state.cursorLine + 1, 1);
}
if (this.onChange) {
this.onChange(this.getText());
}
}
private deleteCurrentLine(): void {
if (this.state.lines.length === 1) {
// Only one line - just clear it
this.state.lines[0] = "";
this.state.cursorCol = 0;
} else {
// Multiple lines - remove current line
this.state.lines.splice(this.state.cursorLine, 1);
// Adjust cursor position
if (this.state.cursorLine >= this.state.lines.length) {
// Was on last line, move to new last line
this.state.cursorLine = this.state.lines.length - 1;
}
// Clamp cursor column to new line length
const newLine = this.state.lines[this.state.cursorLine] || "";
this.state.cursorCol = Math.min(this.state.cursorCol, newLine.length);
}
if (this.onChange) {
this.onChange(this.getText());
}
}
private moveCursor(deltaLine: number, deltaCol: number): void {
if (deltaLine !== 0) {
const newLine = this.state.cursorLine + deltaLine;
if (newLine >= 0 && newLine < this.state.lines.length) {
this.state.cursorLine = newLine;
// Clamp cursor column to new line length
const line = this.state.lines[this.state.cursorLine] || "";
this.state.cursorCol = Math.min(this.state.cursorCol, line.length);
}
}
if (deltaCol !== 0) {
// Move column
const newCol = this.state.cursorCol + deltaCol;
const currentLine = this.state.lines[this.state.cursorLine] || "";
const maxCol = currentLine.length;
this.state.cursorCol = Math.max(0, Math.min(maxCol, newCol));
}
}
// Helper method to check if cursor is at start of message (for slash command detection)
private isAtStartOfMessage(): boolean {
const currentLine = this.state.lines[this.state.cursorLine] || "";
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
// At start if line is empty, only contains whitespace, or is just "/"
return beforeCursor.trim() === "" || beforeCursor.trim() === "/";
}
// 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
if (explicitTab) {
const provider = this.autocompleteProvider as CombinedAutocompleteProvider;
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;
}
}
const suggestions = this.autocompleteProvider.getSuggestions(
this.state.lines,
this.state.cursorLine,
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);
this.isAutocompleting = true;
} else {
this.cancelAutocomplete();
}
}
private handleTabCompletion(): void {
if (!this.autocompleteProvider) return;
const currentLine = this.state.lines[this.state.cursorLine] || "";
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
// 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;
this.autocompletePrefix = "";
}
private updateAutocomplete(): void {
if (!this.isAutocompleting || !this.autocompleteProvider) return;
const suggestions = this.autocompleteProvider.getSuggestions(
this.state.lines,
this.state.cursorLine,
this.state.cursorCol,
);
if (suggestions && suggestions.items.length > 0) {
this.autocompletePrefix = suggestions.prefix;
if (this.autocompleteList) {
// Update the existing list with new items
this.autocompleteList = new SelectList(suggestions.items, 5);
}
} else {
// No more matches, cancel autocomplete
this.cancelAutocomplete();
}
}
}