mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 06:04:51 +00:00
Add ANSI-aware word wrapping to TUI components
- Created shared wrapTextWithAnsi() function in utils.ts - Handles word-based wrapping while preserving ANSI escape codes - Properly tracks active ANSI codes across wrapped lines - Supports multi-byte characters (emoji, surrogate pairs) - Updated Markdown and Text components to use shared wrapping - Removed duplicate wrapping logic (158 lines total)
This commit is contained in:
parent
22d8a0ae4a
commit
38ac29acfb
5 changed files with 389 additions and 278 deletions
|
|
@ -380,27 +380,6 @@ async function generateModels() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add Gemini 3
|
|
||||||
if (!allModels.some(m => m.provider === "google" && m.id === "gemini-3-pro-preview")) {
|
|
||||||
allModels.push({
|
|
||||||
id: "gemini-3-pro-preview",
|
|
||||||
name: "Gemini 3 Pro Preview",
|
|
||||||
api: "google-generative-ai",
|
|
||||||
provider: "google",
|
|
||||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
|
|
||||||
reasoning: true,
|
|
||||||
input: ["text", "image"],
|
|
||||||
cost: {
|
|
||||||
input: 1.25,
|
|
||||||
output: 10,
|
|
||||||
cacheRead: 0.31,
|
|
||||||
cacheWrite: 0,
|
|
||||||
},
|
|
||||||
contextWindow: 1048576,
|
|
||||||
maxTokens: 65536,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group by provider and deduplicate by model ID
|
// Group by provider and deduplicate by model ID
|
||||||
const providers: Record<string, Record<string, Model<any>>> = {};
|
const providers: Record<string, Record<string, Model<any>>> = {};
|
||||||
for (const model of allModels) {
|
for (const model of allModels) {
|
||||||
|
|
|
||||||
|
|
@ -364,6 +364,23 @@ export const MODELS = {
|
||||||
contextWindow: 1048576,
|
contextWindow: 1048576,
|
||||||
maxTokens: 65536,
|
maxTokens: 65536,
|
||||||
} satisfies Model<"google-generative-ai">,
|
} satisfies Model<"google-generative-ai">,
|
||||||
|
"gemini-3-pro-preview": {
|
||||||
|
id: "gemini-3-pro-preview",
|
||||||
|
name: "Gemini 3 Pro Preview",
|
||||||
|
api: "google-generative-ai",
|
||||||
|
provider: "google",
|
||||||
|
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
|
||||||
|
reasoning: true,
|
||||||
|
input: ["text", "image"],
|
||||||
|
cost: {
|
||||||
|
input: 2,
|
||||||
|
output: 12,
|
||||||
|
cacheRead: 0.2,
|
||||||
|
cacheWrite: 0,
|
||||||
|
},
|
||||||
|
contextWindow: 1000000,
|
||||||
|
maxTokens: 64000,
|
||||||
|
} satisfies Model<"google-generative-ai">,
|
||||||
"gemini-2.5-flash": {
|
"gemini-2.5-flash": {
|
||||||
id: "gemini-2.5-flash",
|
id: "gemini-2.5-flash",
|
||||||
name: "Gemini 2.5 Flash",
|
name: "Gemini 2.5 Flash",
|
||||||
|
|
@ -653,23 +670,6 @@ export const MODELS = {
|
||||||
contextWindow: 1000000,
|
contextWindow: 1000000,
|
||||||
maxTokens: 8192,
|
maxTokens: 8192,
|
||||||
} satisfies Model<"google-generative-ai">,
|
} satisfies Model<"google-generative-ai">,
|
||||||
"gemini-3-pro-preview": {
|
|
||||||
id: "gemini-3-pro-preview",
|
|
||||||
name: "Gemini 3 Pro Preview",
|
|
||||||
api: "google-generative-ai",
|
|
||||||
provider: "google",
|
|
||||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
|
|
||||||
reasoning: true,
|
|
||||||
input: ["text", "image"],
|
|
||||||
cost: {
|
|
||||||
input: 1.25,
|
|
||||||
output: 10,
|
|
||||||
cacheRead: 0.31,
|
|
||||||
cacheWrite: 0,
|
|
||||||
},
|
|
||||||
contextWindow: 1048576,
|
|
||||||
maxTokens: 65536,
|
|
||||||
} satisfies Model<"google-generative-ai">,
|
|
||||||
},
|
},
|
||||||
openai: {
|
openai: {
|
||||||
"gpt-4.1-nano": {
|
"gpt-4.1-nano": {
|
||||||
|
|
@ -1925,7 +1925,7 @@ export const MODELS = {
|
||||||
openrouter: {
|
openrouter: {
|
||||||
"google/gemini-3-pro-preview": {
|
"google/gemini-3-pro-preview": {
|
||||||
id: "google/gemini-3-pro-preview",
|
id: "google/gemini-3-pro-preview",
|
||||||
name: "Gemini 3 Pro Preview",
|
name: "Google: Gemini 3 Pro Preview",
|
||||||
api: "openai-completions",
|
api: "openai-completions",
|
||||||
provider: "openrouter",
|
provider: "openrouter",
|
||||||
baseUrl: "https://openrouter.ai/api/v1",
|
baseUrl: "https://openrouter.ai/api/v1",
|
||||||
|
|
@ -5068,22 +5068,22 @@ export const MODELS = {
|
||||||
contextWindow: 128000,
|
contextWindow: 128000,
|
||||||
maxTokens: 16384,
|
maxTokens: 16384,
|
||||||
} satisfies Model<"openai-completions">,
|
} satisfies Model<"openai-completions">,
|
||||||
"meta-llama/llama-3.1-8b-instruct": {
|
"meta-llama/llama-3.1-70b-instruct": {
|
||||||
id: "meta-llama/llama-3.1-8b-instruct",
|
id: "meta-llama/llama-3.1-70b-instruct",
|
||||||
name: "Meta: Llama 3.1 8B Instruct",
|
name: "Meta: Llama 3.1 70B Instruct",
|
||||||
api: "openai-completions",
|
api: "openai-completions",
|
||||||
provider: "openrouter",
|
provider: "openrouter",
|
||||||
baseUrl: "https://openrouter.ai/api/v1",
|
baseUrl: "https://openrouter.ai/api/v1",
|
||||||
reasoning: false,
|
reasoning: false,
|
||||||
input: ["text"],
|
input: ["text"],
|
||||||
cost: {
|
cost: {
|
||||||
input: 0.02,
|
input: 0.39999999999999997,
|
||||||
output: 0.03,
|
output: 0.39999999999999997,
|
||||||
cacheRead: 0,
|
cacheRead: 0,
|
||||||
cacheWrite: 0,
|
cacheWrite: 0,
|
||||||
},
|
},
|
||||||
contextWindow: 131072,
|
contextWindow: 131072,
|
||||||
maxTokens: 16384,
|
maxTokens: 4096,
|
||||||
} satisfies Model<"openai-completions">,
|
} satisfies Model<"openai-completions">,
|
||||||
"meta-llama/llama-3.1-405b-instruct": {
|
"meta-llama/llama-3.1-405b-instruct": {
|
||||||
id: "meta-llama/llama-3.1-405b-instruct",
|
id: "meta-llama/llama-3.1-405b-instruct",
|
||||||
|
|
@ -5102,22 +5102,22 @@ export const MODELS = {
|
||||||
contextWindow: 130815,
|
contextWindow: 130815,
|
||||||
maxTokens: 4096,
|
maxTokens: 4096,
|
||||||
} satisfies Model<"openai-completions">,
|
} satisfies Model<"openai-completions">,
|
||||||
"meta-llama/llama-3.1-70b-instruct": {
|
"meta-llama/llama-3.1-8b-instruct": {
|
||||||
id: "meta-llama/llama-3.1-70b-instruct",
|
id: "meta-llama/llama-3.1-8b-instruct",
|
||||||
name: "Meta: Llama 3.1 70B Instruct",
|
name: "Meta: Llama 3.1 8B Instruct",
|
||||||
api: "openai-completions",
|
api: "openai-completions",
|
||||||
provider: "openrouter",
|
provider: "openrouter",
|
||||||
baseUrl: "https://openrouter.ai/api/v1",
|
baseUrl: "https://openrouter.ai/api/v1",
|
||||||
reasoning: false,
|
reasoning: false,
|
||||||
input: ["text"],
|
input: ["text"],
|
||||||
cost: {
|
cost: {
|
||||||
input: 0.39999999999999997,
|
input: 0.02,
|
||||||
output: 0.39999999999999997,
|
output: 0.03,
|
||||||
cacheRead: 0,
|
cacheRead: 0,
|
||||||
cacheWrite: 0,
|
cacheWrite: 0,
|
||||||
},
|
},
|
||||||
contextWindow: 131072,
|
contextWindow: 131072,
|
||||||
maxTokens: 4096,
|
maxTokens: 16384,
|
||||||
} satisfies Model<"openai-completions">,
|
} satisfies Model<"openai-completions">,
|
||||||
"mistralai/mistral-nemo": {
|
"mistralai/mistral-nemo": {
|
||||||
id: "mistralai/mistral-nemo",
|
id: "mistralai/mistral-nemo",
|
||||||
|
|
@ -5255,23 +5255,6 @@ export const MODELS = {
|
||||||
contextWindow: 128000,
|
contextWindow: 128000,
|
||||||
maxTokens: 4096,
|
maxTokens: 4096,
|
||||||
} satisfies Model<"openai-completions">,
|
} satisfies Model<"openai-completions">,
|
||||||
"openai/gpt-4o-2024-05-13": {
|
|
||||||
id: "openai/gpt-4o-2024-05-13",
|
|
||||||
name: "OpenAI: GPT-4o (2024-05-13)",
|
|
||||||
api: "openai-completions",
|
|
||||||
provider: "openrouter",
|
|
||||||
baseUrl: "https://openrouter.ai/api/v1",
|
|
||||||
reasoning: false,
|
|
||||||
input: ["text", "image"],
|
|
||||||
cost: {
|
|
||||||
input: 5,
|
|
||||||
output: 15,
|
|
||||||
cacheRead: 0,
|
|
||||||
cacheWrite: 0,
|
|
||||||
},
|
|
||||||
contextWindow: 128000,
|
|
||||||
maxTokens: 4096,
|
|
||||||
} satisfies Model<"openai-completions">,
|
|
||||||
"openai/gpt-4o": {
|
"openai/gpt-4o": {
|
||||||
id: "openai/gpt-4o",
|
id: "openai/gpt-4o",
|
||||||
name: "OpenAI: GPT-4o",
|
name: "OpenAI: GPT-4o",
|
||||||
|
|
@ -5306,6 +5289,23 @@ export const MODELS = {
|
||||||
contextWindow: 128000,
|
contextWindow: 128000,
|
||||||
maxTokens: 64000,
|
maxTokens: 64000,
|
||||||
} satisfies Model<"openai-completions">,
|
} satisfies Model<"openai-completions">,
|
||||||
|
"openai/gpt-4o-2024-05-13": {
|
||||||
|
id: "openai/gpt-4o-2024-05-13",
|
||||||
|
name: "OpenAI: GPT-4o (2024-05-13)",
|
||||||
|
api: "openai-completions",
|
||||||
|
provider: "openrouter",
|
||||||
|
baseUrl: "https://openrouter.ai/api/v1",
|
||||||
|
reasoning: false,
|
||||||
|
input: ["text", "image"],
|
||||||
|
cost: {
|
||||||
|
input: 5,
|
||||||
|
output: 15,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
},
|
||||||
|
contextWindow: 128000,
|
||||||
|
maxTokens: 4096,
|
||||||
|
} satisfies Model<"openai-completions">,
|
||||||
"meta-llama/llama-3-70b-instruct": {
|
"meta-llama/llama-3-70b-instruct": {
|
||||||
id: "meta-llama/llama-3-70b-instruct",
|
id: "meta-llama/llama-3-70b-instruct",
|
||||||
name: "Meta: Llama 3 70B Instruct",
|
name: "Meta: Llama 3 70B Instruct",
|
||||||
|
|
@ -5425,23 +5425,6 @@ export const MODELS = {
|
||||||
contextWindow: 128000,
|
contextWindow: 128000,
|
||||||
maxTokens: 4096,
|
maxTokens: 4096,
|
||||||
} satisfies Model<"openai-completions">,
|
} satisfies Model<"openai-completions">,
|
||||||
"openai/gpt-3.5-turbo-0613": {
|
|
||||||
id: "openai/gpt-3.5-turbo-0613",
|
|
||||||
name: "OpenAI: GPT-3.5 Turbo (older v0613)",
|
|
||||||
api: "openai-completions",
|
|
||||||
provider: "openrouter",
|
|
||||||
baseUrl: "https://openrouter.ai/api/v1",
|
|
||||||
reasoning: false,
|
|
||||||
input: ["text"],
|
|
||||||
cost: {
|
|
||||||
input: 1,
|
|
||||||
output: 2,
|
|
||||||
cacheRead: 0,
|
|
||||||
cacheWrite: 0,
|
|
||||||
},
|
|
||||||
contextWindow: 4095,
|
|
||||||
maxTokens: 4096,
|
|
||||||
} satisfies Model<"openai-completions">,
|
|
||||||
"openai/gpt-4-turbo-preview": {
|
"openai/gpt-4-turbo-preview": {
|
||||||
id: "openai/gpt-4-turbo-preview",
|
id: "openai/gpt-4-turbo-preview",
|
||||||
name: "OpenAI: GPT-4 Turbo Preview",
|
name: "OpenAI: GPT-4 Turbo Preview",
|
||||||
|
|
@ -5459,6 +5442,23 @@ export const MODELS = {
|
||||||
contextWindow: 128000,
|
contextWindow: 128000,
|
||||||
maxTokens: 4096,
|
maxTokens: 4096,
|
||||||
} satisfies Model<"openai-completions">,
|
} satisfies Model<"openai-completions">,
|
||||||
|
"openai/gpt-3.5-turbo-0613": {
|
||||||
|
id: "openai/gpt-3.5-turbo-0613",
|
||||||
|
name: "OpenAI: GPT-3.5 Turbo (older v0613)",
|
||||||
|
api: "openai-completions",
|
||||||
|
provider: "openrouter",
|
||||||
|
baseUrl: "https://openrouter.ai/api/v1",
|
||||||
|
reasoning: false,
|
||||||
|
input: ["text"],
|
||||||
|
cost: {
|
||||||
|
input: 1,
|
||||||
|
output: 2,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
},
|
||||||
|
contextWindow: 4095,
|
||||||
|
maxTokens: 4096,
|
||||||
|
} satisfies Model<"openai-completions">,
|
||||||
"mistralai/mistral-small": {
|
"mistralai/mistral-small": {
|
||||||
id: "mistralai/mistral-small",
|
id: "mistralai/mistral-small",
|
||||||
name: "Mistral Small",
|
name: "Mistral Small",
|
||||||
|
|
@ -5527,23 +5527,6 @@ export const MODELS = {
|
||||||
contextWindow: 128000,
|
contextWindow: 128000,
|
||||||
maxTokens: 4096,
|
maxTokens: 4096,
|
||||||
} satisfies Model<"openai-completions">,
|
} satisfies Model<"openai-completions">,
|
||||||
"mistralai/mistral-7b-instruct-v0.1": {
|
|
||||||
id: "mistralai/mistral-7b-instruct-v0.1",
|
|
||||||
name: "Mistral: Mistral 7B Instruct v0.1",
|
|
||||||
api: "openai-completions",
|
|
||||||
provider: "openrouter",
|
|
||||||
baseUrl: "https://openrouter.ai/api/v1",
|
|
||||||
reasoning: false,
|
|
||||||
input: ["text"],
|
|
||||||
cost: {
|
|
||||||
input: 0.11,
|
|
||||||
output: 0.19,
|
|
||||||
cacheRead: 0,
|
|
||||||
cacheWrite: 0,
|
|
||||||
},
|
|
||||||
contextWindow: 2824,
|
|
||||||
maxTokens: 4096,
|
|
||||||
} satisfies Model<"openai-completions">,
|
|
||||||
"openai/gpt-3.5-turbo-16k": {
|
"openai/gpt-3.5-turbo-16k": {
|
||||||
id: "openai/gpt-3.5-turbo-16k",
|
id: "openai/gpt-3.5-turbo-16k",
|
||||||
name: "OpenAI: GPT-3.5 Turbo 16k",
|
name: "OpenAI: GPT-3.5 Turbo 16k",
|
||||||
|
|
@ -5578,23 +5561,6 @@ export const MODELS = {
|
||||||
contextWindow: 8191,
|
contextWindow: 8191,
|
||||||
maxTokens: 4096,
|
maxTokens: 4096,
|
||||||
} satisfies Model<"openai-completions">,
|
} satisfies Model<"openai-completions">,
|
||||||
"openai/gpt-4": {
|
|
||||||
id: "openai/gpt-4",
|
|
||||||
name: "OpenAI: GPT-4",
|
|
||||||
api: "openai-completions",
|
|
||||||
provider: "openrouter",
|
|
||||||
baseUrl: "https://openrouter.ai/api/v1",
|
|
||||||
reasoning: false,
|
|
||||||
input: ["text"],
|
|
||||||
cost: {
|
|
||||||
input: 30,
|
|
||||||
output: 60,
|
|
||||||
cacheRead: 0,
|
|
||||||
cacheWrite: 0,
|
|
||||||
},
|
|
||||||
contextWindow: 8191,
|
|
||||||
maxTokens: 4096,
|
|
||||||
} satisfies Model<"openai-completions">,
|
|
||||||
"openai/gpt-3.5-turbo": {
|
"openai/gpt-3.5-turbo": {
|
||||||
id: "openai/gpt-3.5-turbo",
|
id: "openai/gpt-3.5-turbo",
|
||||||
name: "OpenAI: GPT-3.5 Turbo",
|
name: "OpenAI: GPT-3.5 Turbo",
|
||||||
|
|
@ -5612,6 +5578,23 @@ export const MODELS = {
|
||||||
contextWindow: 16385,
|
contextWindow: 16385,
|
||||||
maxTokens: 4096,
|
maxTokens: 4096,
|
||||||
} satisfies Model<"openai-completions">,
|
} satisfies Model<"openai-completions">,
|
||||||
|
"openai/gpt-4": {
|
||||||
|
id: "openai/gpt-4",
|
||||||
|
name: "OpenAI: GPT-4",
|
||||||
|
api: "openai-completions",
|
||||||
|
provider: "openrouter",
|
||||||
|
baseUrl: "https://openrouter.ai/api/v1",
|
||||||
|
reasoning: false,
|
||||||
|
input: ["text"],
|
||||||
|
cost: {
|
||||||
|
input: 30,
|
||||||
|
output: 60,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
},
|
||||||
|
contextWindow: 8191,
|
||||||
|
maxTokens: 4096,
|
||||||
|
} satisfies Model<"openai-completions">,
|
||||||
"openrouter/auto": {
|
"openrouter/auto": {
|
||||||
id: "openrouter/auto",
|
id: "openrouter/auto",
|
||||||
name: "OpenRouter: Auto Router",
|
name: "OpenRouter: Auto Router",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Chalk } from "chalk";
|
import { Chalk } from "chalk";
|
||||||
import { marked, type Token } from "marked";
|
import { marked, type Token } from "marked";
|
||||||
import type { Component } from "../tui.js";
|
import type { Component } from "../tui.js";
|
||||||
import { visibleWidth } from "../utils.js";
|
import { visibleWidth, wrapTextWithAnsi } from "../utils.js";
|
||||||
|
|
||||||
// Use a chalk instance with color level 3 for consistent ANSI output
|
// Use a chalk instance with color level 3 for consistent ANSI output
|
||||||
const colorChalk = new Chalk({ level: 3 });
|
const colorChalk = new Chalk({ level: 3 });
|
||||||
|
|
@ -89,7 +89,7 @@ export class Markdown implements Component {
|
||||||
// Wrap lines to fit content 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, contentWidth));
|
wrappedLines.push(...wrapTextWithAnsi(line, contentWidth));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add padding and apply background color if specified
|
// Add padding and apply background color if specified
|
||||||
|
|
@ -381,115 +381,6 @@ export class Markdown implements Component {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private wrapLine(line: string, width: number): string[] {
|
|
||||||
// Handle ANSI escape codes properly when wrapping
|
|
||||||
const wrapped: string[] = [];
|
|
||||||
|
|
||||||
// Handle undefined or null lines
|
|
||||||
if (!line) {
|
|
||||||
return [""];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Split by newlines first - wrap each line individually
|
|
||||||
const splitLines = line.split("\n");
|
|
||||||
for (const splitLine of splitLines) {
|
|
||||||
const visibleLength = visibleWidth(splitLine);
|
|
||||||
|
|
||||||
if (visibleLength <= width) {
|
|
||||||
wrapped.push(splitLine);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// This line needs wrapping
|
|
||||||
wrapped.push(...this.wrapSingleLine(splitLine, width));
|
|
||||||
}
|
|
||||||
|
|
||||||
return wrapped.length > 0 ? wrapped : [""];
|
|
||||||
}
|
|
||||||
|
|
||||||
private wrapSingleLine(line: string, width: number): string[] {
|
|
||||||
const wrapped: string[] = [];
|
|
||||||
|
|
||||||
// Track active ANSI codes to preserve them across wrapped lines
|
|
||||||
const activeAnsiCodes: string[] = [];
|
|
||||||
let currentLine = "";
|
|
||||||
let currentLength = 0;
|
|
||||||
let i = 0;
|
|
||||||
|
|
||||||
while (i < line.length) {
|
|
||||||
if (line[i] === "\x1b" && line[i + 1] === "[") {
|
|
||||||
// ANSI escape sequence - parse and track it
|
|
||||||
let j = i + 2;
|
|
||||||
while (j < line.length && line[j] && !/[mGKHJ]/.test(line[j]!)) {
|
|
||||||
j++;
|
|
||||||
}
|
|
||||||
if (j < line.length) {
|
|
||||||
const ansiCode = line.substring(i, j + 1);
|
|
||||||
currentLine += ansiCode;
|
|
||||||
|
|
||||||
// Track styling codes (ending with 'm')
|
|
||||||
if (line[j] === "m") {
|
|
||||||
// Reset code
|
|
||||||
if (ansiCode === "\x1b[0m" || ansiCode === "\x1b[m") {
|
|
||||||
activeAnsiCodes.length = 0;
|
|
||||||
} else {
|
|
||||||
// Add to active codes (replacing similar ones)
|
|
||||||
activeAnsiCodes.push(ansiCode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
i = j + 1;
|
|
||||||
} else {
|
|
||||||
// Incomplete ANSI sequence at end - don't include it
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Regular character - extract full grapheme cluster
|
|
||||||
// Handle multi-byte characters (emoji, surrogate pairs, etc.)
|
|
||||||
let char: string;
|
|
||||||
let charByteLength: number;
|
|
||||||
|
|
||||||
// Check for surrogate pair (emoji and other multi-byte chars)
|
|
||||||
const codePoint = line.charCodeAt(i);
|
|
||||||
if (codePoint >= 0xd800 && codePoint <= 0xdbff && i + 1 < line.length) {
|
|
||||||
// High surrogate - get the pair
|
|
||||||
char = line.substring(i, i + 2);
|
|
||||||
charByteLength = 2;
|
|
||||||
} else {
|
|
||||||
// Regular character
|
|
||||||
char = line[i];
|
|
||||||
charByteLength = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const charWidth = visibleWidth(char);
|
|
||||||
|
|
||||||
// Check if adding this character would exceed width
|
|
||||||
if (currentLength + charWidth > width) {
|
|
||||||
// Need to wrap - close current line with reset if needed
|
|
||||||
if (activeAnsiCodes.length > 0) {
|
|
||||||
wrapped.push(currentLine + "\x1b[0m");
|
|
||||||
// Start new line with active codes
|
|
||||||
currentLine = activeAnsiCodes.join("");
|
|
||||||
} else {
|
|
||||||
wrapped.push(currentLine);
|
|
||||||
currentLine = "";
|
|
||||||
}
|
|
||||||
currentLength = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
currentLine += char;
|
|
||||||
currentLength += charWidth;
|
|
||||||
i += charByteLength;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentLine) {
|
|
||||||
wrapped.push(currentLine);
|
|
||||||
}
|
|
||||||
|
|
||||||
return wrapped.length > 0 ? wrapped : [""];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render a list with proper nesting support
|
* Render a list with proper nesting support
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import type { Component } from "../tui.js";
|
import type { Component } from "../tui.js";
|
||||||
import { visibleWidth } from "../utils.js";
|
import { visibleWidth, wrapTextWithAnsi } from "../utils.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Text component - displays multi-line text with word wrapping
|
* Text component - displays multi-line text with word wrapping
|
||||||
|
|
@ -66,53 +66,8 @@ export class Text implements Component {
|
||||||
// Replace tabs with 3 spaces for consistent rendering
|
// Replace tabs with 3 spaces for consistent rendering
|
||||||
const normalizedText = this.text.replace(/\t/g, " ");
|
const normalizedText = this.text.replace(/\t/g, " ");
|
||||||
|
|
||||||
const lines: string[] = [];
|
// Use shared ANSI-aware word wrapping
|
||||||
const textLines = normalizedText.split("\n");
|
const lines = wrapTextWithAnsi(normalizedText, contentWidth);
|
||||||
|
|
||||||
for (const line of textLines) {
|
|
||||||
// Measure visible length (strip ANSI codes)
|
|
||||||
const visibleLineLength = visibleWidth(line);
|
|
||||||
|
|
||||||
if (visibleLineLength <= contentWidth) {
|
|
||||||
lines.push(line);
|
|
||||||
} else {
|
|
||||||
// Word wrap
|
|
||||||
const words = line.split(" ");
|
|
||||||
let currentLine = "";
|
|
||||||
|
|
||||||
for (const word of words) {
|
|
||||||
const currentVisible = visibleWidth(currentLine);
|
|
||||||
const wordVisible = visibleWidth(word);
|
|
||||||
|
|
||||||
// If word is too long, truncate it
|
|
||||||
let finalWord = word;
|
|
||||||
if (wordVisible > contentWidth) {
|
|
||||||
// Truncate word to fit
|
|
||||||
let truncated = "";
|
|
||||||
for (const char of word) {
|
|
||||||
if (visibleWidth(truncated + char) > contentWidth) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
truncated += char;
|
|
||||||
}
|
|
||||||
finalWord = truncated;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentVisible === 0) {
|
|
||||||
currentLine = finalWord;
|
|
||||||
} else if (currentVisible + 1 + visibleWidth(finalWord) <= contentWidth) {
|
|
||||||
currentLine += " " + finalWord;
|
|
||||||
} else {
|
|
||||||
lines.push(currentLine);
|
|
||||||
currentLine = finalWord;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentLine.length > 0) {
|
|
||||||
lines.push(currentLine);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add padding to each line
|
// Add padding to each line
|
||||||
const leftPad = " ".repeat(this.paddingX);
|
const leftPad = " ".repeat(this.paddingX);
|
||||||
|
|
|
||||||
|
|
@ -13,3 +13,306 @@ export function visibleWidth(str: string): number {
|
||||||
const normalized = str.replace(/\t/g, " ");
|
const normalized = str.replace(/\t/g, " ");
|
||||||
return stringWidth(normalized);
|
return stringWidth(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract ANSI escape sequences from a string at the given position.
|
||||||
|
* Returns the ANSI code and the length consumed, or null if no ANSI code found.
|
||||||
|
*/
|
||||||
|
function extractAnsiCode(str: string, pos: number): { code: string; length: number } | null {
|
||||||
|
if (pos >= str.length || str[pos] !== "\x1b" || str[pos + 1] !== "[") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let j = pos + 2;
|
||||||
|
while (j < str.length && str[j] && !/[mGKHJ]/.test(str[j]!)) {
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (j < str.length) {
|
||||||
|
return {
|
||||||
|
code: str.substring(pos, j + 1),
|
||||||
|
length: j + 1 - pos,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track and manage active ANSI codes for preserving styling across wrapped lines.
|
||||||
|
*/
|
||||||
|
class AnsiCodeTracker {
|
||||||
|
private activeAnsiCodes: string[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process an ANSI code and update the active codes.
|
||||||
|
*/
|
||||||
|
process(ansiCode: string): void {
|
||||||
|
// Check if it's a styling code (ends with 'm')
|
||||||
|
if (!ansiCode.endsWith("m")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset code clears all active codes
|
||||||
|
if (ansiCode === "\x1b[0m" || ansiCode === "\x1b[m") {
|
||||||
|
this.activeAnsiCodes.length = 0;
|
||||||
|
} else {
|
||||||
|
// Add to active codes
|
||||||
|
this.activeAnsiCodes.push(ansiCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all active ANSI codes as a single string.
|
||||||
|
*/
|
||||||
|
getActiveCodes(): string {
|
||||||
|
return this.activeAnsiCodes.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if there are any active codes.
|
||||||
|
*/
|
||||||
|
hasActiveCodes(): boolean {
|
||||||
|
return this.activeAnsiCodes.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the reset code.
|
||||||
|
*/
|
||||||
|
getResetCode(): string {
|
||||||
|
return "\x1b[0m";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap text lines with word-based wrapping while preserving ANSI escape codes.
|
||||||
|
* This function properly handles:
|
||||||
|
* - ANSI escape codes (preserved and tracked across lines)
|
||||||
|
* - Word-based wrapping (breaks at spaces when possible)
|
||||||
|
* - Multi-byte characters (emoji, surrogate pairs)
|
||||||
|
* - Newlines within text
|
||||||
|
*
|
||||||
|
* @param text - The text to wrap (can contain ANSI codes and newlines)
|
||||||
|
* @param width - The maximum width in terminal columns
|
||||||
|
* @returns Array of wrapped lines with ANSI codes preserved
|
||||||
|
*/
|
||||||
|
export function wrapTextWithAnsi(text: string, width: number): string[] {
|
||||||
|
if (!text) {
|
||||||
|
return [""];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle newlines by processing each line separately
|
||||||
|
const inputLines = text.split("\n");
|
||||||
|
const result: string[] = [];
|
||||||
|
|
||||||
|
for (const inputLine of inputLines) {
|
||||||
|
result.push(...wrapSingleLineWithAnsi(inputLine, width));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.length > 0 ? result : [""];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap a single line (no newlines) with word-based wrapping while preserving ANSI codes.
|
||||||
|
*/
|
||||||
|
function wrapSingleLineWithAnsi(line: string, width: number): string[] {
|
||||||
|
if (!line) {
|
||||||
|
return [""];
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleLength = visibleWidth(line);
|
||||||
|
if (visibleLength <= width) {
|
||||||
|
return [line];
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrapped: string[] = [];
|
||||||
|
const tracker = new AnsiCodeTracker();
|
||||||
|
|
||||||
|
// First, split the line into words while preserving ANSI codes with their words
|
||||||
|
const words = splitIntoWordsWithAnsi(line);
|
||||||
|
|
||||||
|
let currentLine = "";
|
||||||
|
let currentVisibleLength = 0;
|
||||||
|
|
||||||
|
for (const word of words) {
|
||||||
|
const wordVisibleLength = visibleWidth(word);
|
||||||
|
|
||||||
|
// If the word itself is longer than the width, we need to break it character by character
|
||||||
|
if (wordVisibleLength > width) {
|
||||||
|
// Flush current line if any
|
||||||
|
if (currentLine) {
|
||||||
|
wrapped.push(closeLineAndPrepareNext(currentLine, tracker));
|
||||||
|
currentLine = tracker.getActiveCodes();
|
||||||
|
currentVisibleLength = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Break the long word
|
||||||
|
const brokenLines = breakLongWordWithAnsi(word, width, tracker);
|
||||||
|
wrapped.push(...brokenLines.slice(0, -1));
|
||||||
|
currentLine = brokenLines[brokenLines.length - 1];
|
||||||
|
currentVisibleLength = visibleWidth(currentLine);
|
||||||
|
} else {
|
||||||
|
// Check if adding this word would exceed the width
|
||||||
|
const spaceNeeded = currentVisibleLength > 0 ? 1 : 0; // Space before word if not at line start
|
||||||
|
const totalNeeded = currentVisibleLength + spaceNeeded + wordVisibleLength;
|
||||||
|
|
||||||
|
if (totalNeeded > width) {
|
||||||
|
// Word doesn't fit, wrap to next line
|
||||||
|
if (currentLine) {
|
||||||
|
wrapped.push(closeLineAndPrepareNext(currentLine, tracker));
|
||||||
|
}
|
||||||
|
currentLine = tracker.getActiveCodes() + word;
|
||||||
|
currentVisibleLength = wordVisibleLength;
|
||||||
|
} else {
|
||||||
|
// Word fits, add it
|
||||||
|
if (currentVisibleLength > 0) {
|
||||||
|
currentLine += " " + word;
|
||||||
|
currentVisibleLength += 1 + wordVisibleLength;
|
||||||
|
} else {
|
||||||
|
currentLine += word;
|
||||||
|
currentVisibleLength = wordVisibleLength;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update tracker with ANSI codes from this word
|
||||||
|
updateTrackerFromText(word, tracker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add final line
|
||||||
|
if (currentLine) {
|
||||||
|
wrapped.push(currentLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
return wrapped.length > 0 ? wrapped : [""];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close current line with reset code if needed, and prepare the next line with active codes.
|
||||||
|
*/
|
||||||
|
function closeLineAndPrepareNext(line: string, tracker: AnsiCodeTracker): string {
|
||||||
|
if (tracker.hasActiveCodes()) {
|
||||||
|
return line + tracker.getResetCode();
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the ANSI code tracker by scanning through text.
|
||||||
|
*/
|
||||||
|
function updateTrackerFromText(text: string, tracker: AnsiCodeTracker): void {
|
||||||
|
let i = 0;
|
||||||
|
while (i < text.length) {
|
||||||
|
const ansiResult = extractAnsiCode(text, i);
|
||||||
|
if (ansiResult) {
|
||||||
|
tracker.process(ansiResult.code);
|
||||||
|
i += ansiResult.length;
|
||||||
|
} else {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split text into words while keeping ANSI codes attached to their words.
|
||||||
|
*/
|
||||||
|
function splitIntoWordsWithAnsi(text: string): string[] {
|
||||||
|
const words: string[] = [];
|
||||||
|
let currentWord = "";
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
while (i < text.length) {
|
||||||
|
const char = text[i];
|
||||||
|
|
||||||
|
// Check for ANSI code
|
||||||
|
const ansiResult = extractAnsiCode(text, i);
|
||||||
|
if (ansiResult) {
|
||||||
|
currentWord += ansiResult.code;
|
||||||
|
i += ansiResult.length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for space (word boundary)
|
||||||
|
if (char === " ") {
|
||||||
|
if (currentWord) {
|
||||||
|
words.push(currentWord);
|
||||||
|
currentWord = "";
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular character
|
||||||
|
currentWord += char;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add final word
|
||||||
|
if (currentWord) {
|
||||||
|
words.push(currentWord);
|
||||||
|
}
|
||||||
|
|
||||||
|
return words;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Break a long word that doesn't fit on a single line, character by character.
|
||||||
|
*/
|
||||||
|
function breakLongWordWithAnsi(word: string, width: number, tracker: AnsiCodeTracker): string[] {
|
||||||
|
const lines: string[] = [];
|
||||||
|
let currentLine = tracker.getActiveCodes();
|
||||||
|
let currentVisibleLength = 0;
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
while (i < word.length) {
|
||||||
|
// Check for ANSI code
|
||||||
|
const ansiResult = extractAnsiCode(word, i);
|
||||||
|
if (ansiResult) {
|
||||||
|
currentLine += ansiResult.code;
|
||||||
|
tracker.process(ansiResult.code);
|
||||||
|
i += ansiResult.length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get character (handle surrogate pairs)
|
||||||
|
const codePoint = word.charCodeAt(i);
|
||||||
|
let char: string;
|
||||||
|
let charByteLength: number;
|
||||||
|
|
||||||
|
if (codePoint >= 0xd800 && codePoint <= 0xdbff && i + 1 < word.length) {
|
||||||
|
// High surrogate - get the pair
|
||||||
|
char = word.substring(i, i + 2);
|
||||||
|
charByteLength = 2;
|
||||||
|
} else {
|
||||||
|
// Regular character
|
||||||
|
char = word[i];
|
||||||
|
charByteLength = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const charWidth = visibleWidth(char);
|
||||||
|
|
||||||
|
// Check if adding this character would exceed width
|
||||||
|
if (currentVisibleLength + charWidth > width) {
|
||||||
|
// Need to wrap
|
||||||
|
if (tracker.hasActiveCodes()) {
|
||||||
|
lines.push(currentLine + tracker.getResetCode());
|
||||||
|
currentLine = tracker.getActiveCodes();
|
||||||
|
} else {
|
||||||
|
lines.push(currentLine);
|
||||||
|
currentLine = "";
|
||||||
|
}
|
||||||
|
currentVisibleLength = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentLine += char;
|
||||||
|
currentVisibleLength += charWidth;
|
||||||
|
i += charByteLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add final line (don't close it, let the caller handle that)
|
||||||
|
if (currentLine || lines.length === 0) {
|
||||||
|
lines.push(currentLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue