mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 05:00:16 +00:00
Fix terminal rendering and add improvements
- Enable bracketed paste mode for handling large pastes - Fix editor duplicate line rendering bug at terminal width - Add padding support to Markdown component (paddingX/Y) - Add Spacer component for vertical spacing - Update chat-simple with spacers between messages
This commit is contained in:
parent
97c730c874
commit
5f19dd62c7
5 changed files with 148 additions and 61 deletions
|
|
@ -38,6 +38,10 @@ export class Editor implements Component {
|
||||||
private pastes: Map<number, string> = new Map();
|
private pastes: Map<number, string> = new Map();
|
||||||
private pasteCounter: number = 0;
|
private pasteCounter: number = 0;
|
||||||
|
|
||||||
|
// Bracketed paste mode buffering
|
||||||
|
private pasteBuffer: string = "";
|
||||||
|
private isInPaste: boolean = false;
|
||||||
|
|
||||||
public onSubmit?: (text: string) => void;
|
public onSubmit?: (text: string) => void;
|
||||||
public onChange?: (text: string) => void;
|
public onChange?: (text: string) => void;
|
||||||
public disableSubmit: boolean = false;
|
public disableSubmit: boolean = false;
|
||||||
|
|
@ -84,11 +88,23 @@ export class Editor implements Component {
|
||||||
displayText = before + cursor + restAfter;
|
displayText = before + cursor + restAfter;
|
||||||
// visibleLength stays the same - we're replacing, not adding
|
// visibleLength stays the same - we're replacing, not adding
|
||||||
} else {
|
} else {
|
||||||
// Cursor is at the end - add highlighted space
|
// Cursor is at the end - check if we have room for the space
|
||||||
const cursor = "\x1b[7m \x1b[0m";
|
if (layoutLine.text.length < width) {
|
||||||
displayText = before + cursor;
|
// We have room - add highlighted space
|
||||||
// visibleLength increases by 1 - we're adding a space
|
const cursor = "\x1b[7m \x1b[0m";
|
||||||
visibleLength = layoutLine.text.length + 1;
|
displayText = before + cursor;
|
||||||
|
// visibleLength increases by 1 - we're adding a space
|
||||||
|
visibleLength = layoutLine.text.length + 1;
|
||||||
|
} else {
|
||||||
|
// Line is at full width - use reverse video on last character if possible
|
||||||
|
// or just show cursor at the end without adding space
|
||||||
|
if (before.length > 0) {
|
||||||
|
const lastChar = before[before.length - 1];
|
||||||
|
const cursor = `\x1b[7m${lastChar}\x1b[0m`;
|
||||||
|
displayText = before.slice(0, -1) + cursor;
|
||||||
|
}
|
||||||
|
// visibleLength stays the same
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -112,6 +128,49 @@ export class Editor implements Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
handleInput(data: string): void {
|
handleInput(data: string): void {
|
||||||
|
// Handle bracketed paste mode
|
||||||
|
// Start of paste: \x1b[200~
|
||||||
|
// End of paste: \x1b[201~
|
||||||
|
|
||||||
|
// Check if we're starting a bracketed paste
|
||||||
|
if (data.includes("\x1b[200~")) {
|
||||||
|
this.isInPaste = true;
|
||||||
|
this.pasteBuffer = "";
|
||||||
|
// Remove the start marker and keep the rest
|
||||||
|
data = data.replace("\x1b[200~", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're in a paste, buffer the data
|
||||||
|
if (this.isInPaste) {
|
||||||
|
// Append data to buffer first (end marker could be split across chunks)
|
||||||
|
this.pasteBuffer += data;
|
||||||
|
|
||||||
|
// Check if the accumulated buffer contains the end marker
|
||||||
|
const endIndex = this.pasteBuffer.indexOf("\x1b[201~");
|
||||||
|
if (endIndex !== -1) {
|
||||||
|
// Extract content before the end marker
|
||||||
|
const pasteContent = this.pasteBuffer.substring(0, endIndex);
|
||||||
|
|
||||||
|
// Process the complete paste
|
||||||
|
this.handlePaste(pasteContent);
|
||||||
|
|
||||||
|
// Reset paste state
|
||||||
|
this.isInPaste = false;
|
||||||
|
|
||||||
|
// Process any remaining data after the end marker
|
||||||
|
const remaining = this.pasteBuffer.substring(endIndex + 6); // 6 = length of \x1b[201~
|
||||||
|
this.pasteBuffer = "";
|
||||||
|
|
||||||
|
if (remaining.length > 0) {
|
||||||
|
this.handleInput(remaining);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
// Still accumulating, wait for more data
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle special key combinations first
|
// Handle special key combinations first
|
||||||
|
|
||||||
// Ctrl+C - Exit (let parent handle this)
|
// Ctrl+C - Exit (let parent handle this)
|
||||||
|
|
@ -119,13 +178,6 @@ export class Editor implements Component {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle paste - detect when we get a lot of text at once
|
|
||||||
const isPaste = data.length > 10 || (data.length > 2 && data.includes("\n"));
|
|
||||||
if (isPaste) {
|
|
||||||
this.handlePaste(data);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle autocomplete special keys first (but don't block other input)
|
// Handle autocomplete special keys first (but don't block other input)
|
||||||
if (this.isAutocompleting && this.autocompleteList) {
|
if (this.isAutocompleting && this.autocompleteList) {
|
||||||
// Escape - cancel autocomplete
|
// Escape - cancel autocomplete
|
||||||
|
|
@ -321,7 +373,7 @@ export class Editor implements Component {
|
||||||
const chunkStart = chunkIndex * maxLineLength;
|
const chunkStart = chunkIndex * maxLineLength;
|
||||||
const chunkEnd = chunkStart + chunk.length;
|
const chunkEnd = chunkStart + chunk.length;
|
||||||
const cursorPos = this.state.cursorCol;
|
const cursorPos = this.state.cursorCol;
|
||||||
const hasCursorInChunk = isCurrentLine && cursorPos >= chunkStart && cursorPos < chunkEnd;
|
const hasCursorInChunk = isCurrentLine && cursorPos >= chunkStart && cursorPos <= chunkEnd;
|
||||||
|
|
||||||
if (hasCursorInChunk) {
|
if (hasCursorInChunk) {
|
||||||
layoutLines.push({
|
layoutLines.push({
|
||||||
|
|
|
||||||
|
|
@ -28,17 +28,28 @@ export class Markdown implements Component {
|
||||||
private bgColor?: Color;
|
private bgColor?: Color;
|
||||||
private fgColor?: Color;
|
private fgColor?: Color;
|
||||||
private customBgRgb?: { r: number; g: number; b: number };
|
private customBgRgb?: { r: number; g: number; b: number };
|
||||||
|
private paddingX: number; // Left/right padding
|
||||||
|
private paddingY: number; // Top/bottom padding
|
||||||
|
|
||||||
// Cache for rendered output
|
// Cache for rendered output
|
||||||
private cachedText?: string;
|
private cachedText?: string;
|
||||||
private cachedWidth?: number;
|
private cachedWidth?: number;
|
||||||
private cachedLines?: string[];
|
private cachedLines?: string[];
|
||||||
|
|
||||||
constructor(text: string = "", bgColor?: Color, fgColor?: Color, customBgRgb?: { r: number; g: number; b: number }) {
|
constructor(
|
||||||
|
text: string = "",
|
||||||
|
bgColor?: Color,
|
||||||
|
fgColor?: Color,
|
||||||
|
customBgRgb?: { r: number; g: number; b: number },
|
||||||
|
paddingX: number = 1,
|
||||||
|
paddingY: number = 1,
|
||||||
|
) {
|
||||||
this.text = text;
|
this.text = text;
|
||||||
this.bgColor = bgColor;
|
this.bgColor = bgColor;
|
||||||
this.fgColor = fgColor;
|
this.fgColor = fgColor;
|
||||||
this.customBgRgb = customBgRgb;
|
this.customBgRgb = customBgRgb;
|
||||||
|
this.paddingX = paddingX;
|
||||||
|
this.paddingY = paddingY;
|
||||||
}
|
}
|
||||||
|
|
||||||
setText(text: string): void {
|
setText(text: string): void {
|
||||||
|
|
@ -71,6 +82,9 @@ export class Markdown implements Component {
|
||||||
return this.cachedLines;
|
return this.cachedLines;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate available width for content (subtract horizontal padding)
|
||||||
|
const contentWidth = Math.max(1, width - this.paddingX * 2);
|
||||||
|
|
||||||
// Parse markdown to HTML-like tokens
|
// Parse markdown to HTML-like tokens
|
||||||
const tokens = marked.lexer(this.text);
|
const tokens = marked.lexer(this.text);
|
||||||
|
|
||||||
|
|
@ -80,54 +94,79 @@ export class Markdown implements Component {
|
||||||
for (let i = 0; i < tokens.length; i++) {
|
for (let i = 0; i < tokens.length; i++) {
|
||||||
const token = tokens[i];
|
const token = tokens[i];
|
||||||
const nextToken = tokens[i + 1];
|
const nextToken = tokens[i + 1];
|
||||||
const tokenLines = this.renderToken(token, width, nextToken?.type);
|
const tokenLines = this.renderToken(token, contentWidth, nextToken?.type);
|
||||||
renderedLines.push(...tokenLines);
|
renderedLines.push(...tokenLines);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wrap lines to fit width
|
// Wrap lines to fit content width
|
||||||
const wrappedLines: string[] = [];
|
const wrappedLines: string[] = [];
|
||||||
for (const line of renderedLines) {
|
for (const line of renderedLines) {
|
||||||
wrappedLines.push(...this.wrapLine(line, width));
|
wrappedLines.push(...this.wrapLine(line, contentWidth));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply background and foreground colors if specified
|
// Add padding and apply colors
|
||||||
let result: string[];
|
const leftPad = " ".repeat(this.paddingX);
|
||||||
if (this.bgColor || this.fgColor || this.customBgRgb) {
|
const paddedLines: string[] = [];
|
||||||
const coloredLines: string[] = [];
|
|
||||||
for (const line of wrappedLines) {
|
|
||||||
// Calculate visible length (strip ANSI codes)
|
|
||||||
const visibleLength = stripVTControlCharacters(line).length;
|
|
||||||
const padding = " ".repeat(Math.max(0, width - visibleLength));
|
|
||||||
|
|
||||||
// Apply colors
|
for (const line of wrappedLines) {
|
||||||
let coloredLine = line + padding;
|
// Calculate visible length (strip ANSI codes)
|
||||||
|
const visibleLength = stripVTControlCharacters(line).length;
|
||||||
|
// Right padding to fill to width (accounting for left padding)
|
||||||
|
const rightPadLength = Math.max(0, width - visibleLength - this.paddingX * 2);
|
||||||
|
const rightPad = " ".repeat(rightPadLength);
|
||||||
|
|
||||||
// Apply foreground color first if specified
|
// Add left padding, content, and right padding
|
||||||
if (this.fgColor) {
|
let paddedLine = leftPad + line + rightPad;
|
||||||
coloredLine = (chalk as any)[this.fgColor](coloredLine);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply background color if specified
|
// Apply foreground color if specified
|
||||||
if (this.customBgRgb) {
|
if (this.fgColor) {
|
||||||
// Use custom RGB background
|
paddedLine = (chalk as any)[this.fgColor](paddedLine);
|
||||||
coloredLine = chalk.bgRgb(this.customBgRgb.r, this.customBgRgb.g, this.customBgRgb.b)(coloredLine);
|
|
||||||
} else if (this.bgColor) {
|
|
||||||
coloredLine = (chalk as any)[this.bgColor](coloredLine);
|
|
||||||
}
|
|
||||||
|
|
||||||
coloredLines.push(coloredLine);
|
|
||||||
}
|
}
|
||||||
result = coloredLines.length > 0 ? coloredLines : [""];
|
|
||||||
} else {
|
// Apply background color if specified
|
||||||
result = wrappedLines.length > 0 ? wrappedLines : [""];
|
if (this.customBgRgb) {
|
||||||
|
paddedLine = chalk.bgRgb(this.customBgRgb.r, this.customBgRgb.g, this.customBgRgb.b)(paddedLine);
|
||||||
|
} else if (this.bgColor) {
|
||||||
|
paddedLine = (chalk as any)[this.bgColor](paddedLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
paddedLines.push(paddedLine);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add top padding (empty lines)
|
||||||
|
const emptyLine = " ".repeat(width);
|
||||||
|
const topPadding: string[] = [];
|
||||||
|
for (let i = 0; i < this.paddingY; i++) {
|
||||||
|
let emptyPaddedLine = emptyLine;
|
||||||
|
if (this.customBgRgb) {
|
||||||
|
emptyPaddedLine = chalk.bgRgb(this.customBgRgb.r, this.customBgRgb.g, this.customBgRgb.b)(emptyPaddedLine);
|
||||||
|
} else if (this.bgColor) {
|
||||||
|
emptyPaddedLine = (chalk as any)[this.bgColor](emptyPaddedLine);
|
||||||
|
}
|
||||||
|
topPadding.push(emptyPaddedLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add bottom padding (empty lines)
|
||||||
|
const bottomPadding: string[] = [];
|
||||||
|
for (let i = 0; i < this.paddingY; i++) {
|
||||||
|
let emptyPaddedLine = emptyLine;
|
||||||
|
if (this.customBgRgb) {
|
||||||
|
emptyPaddedLine = chalk.bgRgb(this.customBgRgb.r, this.customBgRgb.g, this.customBgRgb.b)(emptyPaddedLine);
|
||||||
|
} else if (this.bgColor) {
|
||||||
|
emptyPaddedLine = (chalk as any)[this.bgColor](emptyPaddedLine);
|
||||||
|
}
|
||||||
|
bottomPadding.push(emptyPaddedLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine top padding, content, and bottom padding
|
||||||
|
const result = [...topPadding, ...paddedLines, ...bottomPadding];
|
||||||
|
|
||||||
// Update cache
|
// Update cache
|
||||||
this.cachedText = this.text;
|
this.cachedText = this.text;
|
||||||
this.cachedWidth = width;
|
this.cachedWidth = width;
|
||||||
this.cachedLines = result;
|
this.cachedLines = result;
|
||||||
|
|
||||||
return result;
|
return result.length > 0 ? result : [""];
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderToken(token: Token, width: number, nextTokenType?: string): string[] {
|
private renderToken(token: Token, width: number, nextTokenType?: string): string[] {
|
||||||
|
|
|
||||||
|
|
@ -48,12 +48,18 @@ export class ProcessTerminal implements Terminal {
|
||||||
process.stdin.setEncoding("utf8");
|
process.stdin.setEncoding("utf8");
|
||||||
process.stdin.resume();
|
process.stdin.resume();
|
||||||
|
|
||||||
|
// Enable bracketed paste mode - terminal will wrap pastes in \x1b[200~ ... \x1b[201~
|
||||||
|
process.stdout.write("\x1b[?2004h");
|
||||||
|
|
||||||
// Set up event handlers
|
// Set up event handlers
|
||||||
process.stdin.on("data", this.inputHandler);
|
process.stdin.on("data", this.inputHandler);
|
||||||
process.stdout.on("resize", this.resizeHandler);
|
process.stdout.on("resize", this.resizeHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
stop(): void {
|
stop(): void {
|
||||||
|
// Disable bracketed paste mode
|
||||||
|
process.stdout.write("\x1b[?2004l");
|
||||||
|
|
||||||
// Remove event handlers
|
// Remove event handlers
|
||||||
if (this.inputHandler) {
|
if (this.inputHandler) {
|
||||||
process.stdin.removeListener("data", this.inputHandler);
|
process.stdin.removeListener("data", this.inputHandler);
|
||||||
|
|
|
||||||
|
|
@ -84,16 +84,12 @@ editor.onSubmit = (value: string) => {
|
||||||
// Insert before the editor (which is last)
|
// Insert before the editor (which is last)
|
||||||
const children = tui.children;
|
const children = tui.children;
|
||||||
children.splice(children.length - 1, 0, userMessage);
|
children.splice(children.length - 1, 0, userMessage);
|
||||||
|
|
||||||
// Add spacer after user message
|
|
||||||
children.splice(children.length - 1, 0, new Spacer());
|
children.splice(children.length - 1, 0, new Spacer());
|
||||||
|
|
||||||
// Add loader
|
// Add loader
|
||||||
const loader = new Loader(tui, "Thinking...");
|
const loader = new Loader(tui, "Thinking...");
|
||||||
children.splice(children.length - 1, 0, loader);
|
|
||||||
|
|
||||||
// Add spacer after loader
|
|
||||||
const loaderSpacer = new Spacer();
|
const loaderSpacer = new Spacer();
|
||||||
|
children.splice(children.length - 1, 0, loader);
|
||||||
children.splice(children.length - 1, 0, loaderSpacer);
|
children.splice(children.length - 1, 0, loaderSpacer);
|
||||||
|
|
||||||
tui.requestRender();
|
tui.requestRender();
|
||||||
|
|
@ -101,15 +97,8 @@ editor.onSubmit = (value: string) => {
|
||||||
// Simulate a 1 second delay
|
// Simulate a 1 second delay
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// Remove loader and its spacer
|
// Remove loader and its spacer
|
||||||
const loaderIndex = children.indexOf(loader);
|
tui.removeChild(loader);
|
||||||
if (loaderIndex !== -1) {
|
tui.removeChild(loaderSpacer);
|
||||||
children.splice(loaderIndex, 1);
|
|
||||||
loader.stop();
|
|
||||||
}
|
|
||||||
const loaderSpacerIndex = children.indexOf(loaderSpacer);
|
|
||||||
if (loaderSpacerIndex !== -1) {
|
|
||||||
children.splice(loaderSpacerIndex, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simulate a response
|
// Simulate a response
|
||||||
const responses = [
|
const responses = [
|
||||||
|
|
@ -127,8 +116,6 @@ editor.onSubmit = (value: string) => {
|
||||||
// Add assistant message with no background (transparent)
|
// Add assistant message with no background (transparent)
|
||||||
const botMessage = new Markdown(randomResponse);
|
const botMessage = new Markdown(randomResponse);
|
||||||
children.splice(children.length - 1, 0, botMessage);
|
children.splice(children.length - 1, 0, botMessage);
|
||||||
|
|
||||||
// Add spacer after assistant message
|
|
||||||
children.splice(children.length - 1, 0, new Spacer());
|
children.splice(children.length - 1, 0, new Spacer());
|
||||||
|
|
||||||
// Re-enable submit
|
// Re-enable submit
|
||||||
|
|
|
||||||
|
|
@ -32,10 +32,13 @@ export class VirtualTerminal implements Terminal {
|
||||||
start(onInput: (data: string) => void, onResize: () => void): void {
|
start(onInput: (data: string) => void, onResize: () => void): void {
|
||||||
this.inputHandler = onInput;
|
this.inputHandler = onInput;
|
||||||
this.resizeHandler = onResize;
|
this.resizeHandler = onResize;
|
||||||
// No need for raw mode in virtual terminal
|
// Enable bracketed paste mode for consistency with ProcessTerminal
|
||||||
|
this.xterm.write("\x1b[?2004h");
|
||||||
}
|
}
|
||||||
|
|
||||||
stop(): void {
|
stop(): void {
|
||||||
|
// Disable bracketed paste mode
|
||||||
|
this.xterm.write("\x1b[?2004l");
|
||||||
this.inputHandler = undefined;
|
this.inputHandler = undefined;
|
||||||
this.resizeHandler = undefined;
|
this.resizeHandler = undefined;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue