Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 55 additions & 54 deletions bun.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"rollup-plugin-visualizer": "^6.0.5",
"shiki": "^3.13.0",
"storybook": "^8.6.14",
"ts-jest": "^29.4.4",
Expand Down
4 changes: 3 additions & 1 deletion src/components/Messages/MarkdownComponents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,13 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ code, language }) => {

async function highlight() {
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const highlighter = await getShikiHighlighter();
const shikiLang = mapToShikiLang(language);

// codeToHtml lazy-loads languages automatically
const result = highlighter.codeToHtml(code, {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
const result: string = highlighter.codeToHtml(code, {
lang: shikiLang,
theme: SHIKI_THEME,
});
Expand Down
1 change: 0 additions & 1 deletion src/services/tools/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@ export const createBashTool: ToolFactory = (config: ToolConfiguration) => {
inputSchema: TOOL_DEFINITIONS.bash.schema,
execute: async ({ script, timeout_secs }, { abortSignal }): Promise<BashToolResult> => {
// Validate script is not empty - likely indicates a malformed tool call
// eslint-disable-next-line @typescript-eslint/prefer-optional-chain
if (!script || script.trim().length === 0) {
return {
success: false,
Expand Down
17 changes: 12 additions & 5 deletions src/utils/highlighting/highlightDiffChunk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,30 +63,37 @@ export async function highlightDiffChunk(
const code = chunk.lines.join("\n");

try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const highlighter = await getShikiHighlighter();
const shikiLang = mapToShikiLang(language);

// Load language on-demand if not already loaded
// This is race-safe: concurrent loads of the same language are idempotent
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
const loadedLangs = highlighter.getLoadedLanguages();
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
if (!loadedLangs.includes(shikiLang)) {
try {
// TypeScript doesn't know shikiLang is valid, but we handle errors gracefully
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
await highlighter.loadLanguage(shikiLang as any);
} catch {
// Dynamically import the language grammar - intentional for lazy-loading
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unsafe-assignment
const langModule = await import(`shiki/langs/${shikiLang}.mjs`);
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
await highlighter.loadLanguage(langModule.default);
} catch (error) {
// Language not available in Shiki bundle - fall back to plain text
console.warn(`Language '${shikiLang}' not available in Shiki, using plain text`);
console.warn(`Language '${shikiLang}' not available in Shiki, using plain text`, error);
return createFallbackChunk(chunk);
}
}

// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
const html = highlighter.codeToHtml(code, {
lang: shikiLang,
theme: SHIKI_THEME,
});

// Parse HTML to extract line contents
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const lines = extractLinesFromHtml(html);

// Validate output (detect broken highlighting)
Expand Down
30 changes: 23 additions & 7 deletions src/utils/highlighting/shikiHighlighter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createHighlighter, type Highlighter } from "shiki";
import { createHighlighterCore, type HighlighterCore } from "shiki/core";
import { createOnigurumaEngine } from "shiki/engine/oniguruma";

// Shiki theme used throughout the application
export const SHIKI_THEME = "min-dark";
Expand All @@ -9,21 +10,36 @@ export const MAX_DIFF_SIZE_BYTES = 32768; // 32kb

// Singleton promise (cached to prevent race conditions)
// Multiple concurrent calls will await the same Promise
let highlighterPromise: Promise<Highlighter> | null = null;
let highlighterPromise: Promise<HighlighterCore> | null = null;

/**
* Get or create Shiki highlighter instance
* Lazy-loads WASM and themes on first call
* Thread-safe: concurrent calls share the same initialization Promise
*/
export async function getShikiHighlighter(): Promise<Highlighter> {
export async function getShikiHighlighter(): Promise<HighlighterCore> {
// Must use if-check instead of ??= to prevent race condition
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
if (!highlighterPromise) {
highlighterPromise = createHighlighter({
themes: [SHIKI_THEME],
langs: [], // Load languages on-demand via highlightDiffChunk
});
highlighterPromise = (async () => {
// Dynamic imports are intentional for lazy-loading WASM and themes
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const [engine, theme] = await Promise.all([
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, no-restricted-syntax
createOnigurumaEngine(import("shiki/wasm")),
// eslint-disable-next-line no-restricted-syntax
import("shiki/themes/min-dark.mjs"),
]);

// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call
return createHighlighterCore({
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
themes: [theme.default],
langs: [], // Load languages on-demand via highlightDiffChunk
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
engine,
});
})();
}
return highlighterPromise;
}
Expand Down
55 changes: 33 additions & 22 deletions src/utils/main/tokenizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import { LRUCache } from "lru-cache";
import CRC32 from "crc-32";
import { getToolSchemas, getAvailableTools } from "@/utils/tools/toolDefinitions";
import * as o200k_base_encoding from "ai-tokenizer/encoding/o200k_base";
import * as claude_encoding from "ai-tokenizer/encoding/claude";

export interface Tokenizer {
encoding: string;
Expand All @@ -22,16 +24,15 @@ interface TokenizerModuleImports {
AITokenizer: typeof import("ai-tokenizer").default;
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
models: typeof import("ai-tokenizer").models;
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
o200k_base: typeof import("ai-tokenizer/encoding/o200k_base");
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
claude: typeof import("ai-tokenizer/encoding/claude");
}

let tokenizerModules: TokenizerModuleImports | null = null;

let tokenizerLoadPromise: Promise<void> | null = null;

// Cache for loaded encodings (loaded on-demand)
const loadedEncodings = new Map<string, unknown>();

/**
* Load tokenizer modules asynchronously
* Dynamic imports are intentional here to defer loading heavy tokenizer modules
Expand All @@ -46,25 +47,34 @@ export async function loadTokenizerModules(): Promise<void> {
tokenizerLoadPromise = (async () => {
// Performance: lazy load tokenizer modules to reduce startup time from ~8.8s to <1s
/* eslint-disable no-restricted-syntax */
const [AITokenizerModule, modelsModule, o200k_base, claude] = await Promise.all([
const [AITokenizerModule, modelsModule] = await Promise.all([
import("ai-tokenizer"),
import("ai-tokenizer"),
import("ai-tokenizer/encoding/o200k_base"),
import("ai-tokenizer/encoding/claude"),
]);
/* eslint-enable no-restricted-syntax */

tokenizerModules = {
AITokenizer: AITokenizerModule.default,
models: modelsModule.models,
o200k_base,
claude,
};
})();

return tokenizerLoadPromise;
}

/**
* Get encoding module (pre-loaded at startup)
* Returns the appropriate encoding for the given name
*/
function getEncoding(encodingName: string): unknown {
const cached = loadedEncodings.get(encodingName);
if (cached) return cached;

const encoding = encodingName === "claude" ? claude_encoding : o200k_base_encoding;
loadedEncodings.set(encodingName, encoding);
return encoding;
}

/**
* LRU cache for token counts by text checksum
* Avoids re-tokenizing identical strings (system messages, tool definitions, etc.)
Expand Down Expand Up @@ -167,8 +177,9 @@ function countTokensWithLoadedModules(
): number {
const encodingName = getTokenizerEncoding(modelString, modules);

const encoding = encodingName === "claude" ? modules.claude : modules.o200k_base;
const tokenizer = new modules.AITokenizer(encoding);
const encoding = getEncoding(encodingName);
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any
const tokenizer = new modules.AITokenizer(encoding as any);
return tokenizer.count(text);
}

Expand All @@ -187,20 +198,20 @@ export function getTokenizerForModel(modelString: string): Tokenizer {
return getTokenizerEncoding(modelString, tokenizerModules);
},
countTokens: (text: string) => {
// If tokenizer already loaded, use synchronous path for accurate counts
// Try synchronous path if modules are already loaded
if (tokenizerModules) {
return countTokensCached(text, () => {
try {
return countTokensWithLoadedModules(text, modelString, tokenizerModules!);
} catch (error) {
// Unexpected error during tokenization, fallback to approximation
console.error("Failed to tokenize, falling back to approximation:", error);
return Math.ceil(text.length / 4);
}
});
try {
return countTokensCached(text, () =>
countTokensWithLoadedModules(text, modelString, tokenizerModules!)
);
} catch (error) {
// Unexpected error during tokenization, fallback to approximation
console.error("Failed to tokenize, falling back to approximation:", error);
return Math.ceil(text.length / 4);
}
}

// Tokenizer not yet loaded - use async path (returns approximation immediately)
// Fallback to async path for first-time loading
return countTokensCached(text, async () => {
await loadTokenizerModules();
try {
Expand Down
1 change: 0 additions & 1 deletion src/utils/validation/workspaceValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
* - Pattern: [a-z0-9_-]{1,64}
*/
export function validateWorkspaceName(name: string): { valid: boolean; error?: string } {
// eslint-disable-next-line @typescript-eslint/prefer-optional-chain
if (!name || name.length === 0) {
return { valid: false, error: "Workspace name cannot be empty" };
}
Expand Down
11 changes: 10 additions & 1 deletion vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import topLevelAwait from "vite-plugin-top-level-await";
import svgr from "vite-plugin-svgr";
import path from "path";
import { fileURLToPath } from "url";
import { visualizer } from "rollup-plugin-visualizer";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const disableMermaid = process.env.VITE_DISABLE_MERMAID === "1";
Expand Down Expand Up @@ -46,7 +47,15 @@ export default defineConfig(({ mode }) => ({
plugins:
mode === "development"
? [...basePlugins, topLevelAwait()]
: basePlugins,
: [
...basePlugins,
visualizer({
filename: "dist/stats.html",
open: false,
gzipSize: true,
brotliSize: true,
}),
],
resolve: {
alias,
},
Expand Down