diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 8e968902..82055a31 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -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 const providers: Record>> = {}; for (const model of allModels) { diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 30e36486..7a8bdc99 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -364,6 +364,23 @@ export const MODELS = { contextWindow: 1048576, maxTokens: 65536, } 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": { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash", @@ -653,23 +670,6 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 8192, } 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: { "gpt-4.1-nano": { @@ -1925,7 +1925,7 @@ export const MODELS = { openrouter: { "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", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -5068,22 +5068,22 @@ export const MODELS = { contextWindow: 128000, maxTokens: 16384, } satisfies Model<"openai-completions">, - "meta-llama/llama-3.1-8b-instruct": { - id: "meta-llama/llama-3.1-8b-instruct", - name: "Meta: Llama 3.1 8B Instruct", + "meta-llama/llama-3.1-70b-instruct": { + id: "meta-llama/llama-3.1-70b-instruct", + name: "Meta: Llama 3.1 70B Instruct", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: false, input: ["text"], cost: { - input: 0.02, - output: 0.03, + input: 0.39999999999999997, + output: 0.39999999999999997, cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 16384, + maxTokens: 4096, } satisfies Model<"openai-completions">, "meta-llama/llama-3.1-405b-instruct": { id: "meta-llama/llama-3.1-405b-instruct", @@ -5102,22 +5102,22 @@ export const MODELS = { contextWindow: 130815, maxTokens: 4096, } satisfies Model<"openai-completions">, - "meta-llama/llama-3.1-70b-instruct": { - id: "meta-llama/llama-3.1-70b-instruct", - name: "Meta: Llama 3.1 70B Instruct", + "meta-llama/llama-3.1-8b-instruct": { + id: "meta-llama/llama-3.1-8b-instruct", + name: "Meta: Llama 3.1 8B Instruct", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: false, input: ["text"], cost: { - input: 0.39999999999999997, - output: 0.39999999999999997, + input: 0.02, + output: 0.03, cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 4096, + maxTokens: 16384, } satisfies Model<"openai-completions">, "mistralai/mistral-nemo": { id: "mistralai/mistral-nemo", @@ -5255,23 +5255,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4096, } 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": { id: "openai/gpt-4o", name: "OpenAI: GPT-4o", @@ -5306,6 +5289,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 64000, } 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": { id: "meta-llama/llama-3-70b-instruct", name: "Meta: Llama 3 70B Instruct", @@ -5425,23 +5425,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4096, } 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": { id: "openai/gpt-4-turbo-preview", name: "OpenAI: GPT-4 Turbo Preview", @@ -5459,6 +5442,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4096, } 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": { id: "mistralai/mistral-small", name: "Mistral Small", @@ -5527,23 +5527,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4096, } 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": { id: "openai/gpt-3.5-turbo-16k", name: "OpenAI: GPT-3.5 Turbo 16k", @@ -5578,23 +5561,6 @@ export const MODELS = { contextWindow: 8191, maxTokens: 4096, } 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": { id: "openai/gpt-3.5-turbo", name: "OpenAI: GPT-3.5 Turbo", @@ -5612,6 +5578,23 @@ export const MODELS = { contextWindow: 16385, maxTokens: 4096, } 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": { id: "openrouter/auto", name: "OpenRouter: Auto Router", diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index a50cae89..07a3643f 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -1,7 +1,7 @@ import { Chalk } from "chalk"; import { marked, type Token } from "marked"; 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 const colorChalk = new Chalk({ level: 3 }); @@ -89,7 +89,7 @@ export class Markdown implements Component { // Wrap lines to fit content width const wrappedLines: string[] = []; for (const line of renderedLines) { - wrappedLines.push(...this.wrapLine(line, contentWidth)); + wrappedLines.push(...wrapTextWithAnsi(line, contentWidth)); } // Add padding and apply background color if specified @@ -381,115 +381,6 @@ export class Markdown implements Component { 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 */ diff --git a/packages/tui/src/components/text.ts b/packages/tui/src/components/text.ts index 436a3a1c..bd10f064 100644 --- a/packages/tui/src/components/text.ts +++ b/packages/tui/src/components/text.ts @@ -1,6 +1,6 @@ import chalk from "chalk"; 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 @@ -66,53 +66,8 @@ export class Text implements Component { // Replace tabs with 3 spaces for consistent rendering const normalizedText = this.text.replace(/\t/g, " "); - const lines: string[] = []; - const textLines = normalizedText.split("\n"); - - 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); - } - } - } + // Use shared ANSI-aware word wrapping + const lines = wrapTextWithAnsi(normalizedText, contentWidth); // Add padding to each line const leftPad = " ".repeat(this.paddingX); diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index 59ba5ac9..62089719 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -13,3 +13,306 @@ export function visibleWidth(str: string): number { const normalized = str.replace(/\t/g, " "); 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; +}