diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e58dde80c..5bf888673 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,13 +111,15 @@ jobs: - name: Build binaries run: | - cd packages/coding-agent - mkdir -p binaries - bun build --compile --target=bun-darwin-arm64 ./src/cli.ts --outfile binaries/omp-darwin-arm64 - bun build --compile --target=bun-darwin-x64 ./src/cli.ts --outfile binaries/omp-darwin-x64 - bun build --compile --target=bun-linux-x64 ./src/cli.ts --outfile binaries/omp-linux-x64 - bun build --compile --target=bun-linux-arm64 ./src/cli.ts --outfile binaries/omp-linux-arm64 - bun build --compile --target=bun-windows-x64 ./src/cli.ts --outfile binaries/omp-windows-x64.exe + mkdir -p packages/coding-agent/binaries + ENTRYPOINT="./packages/coding-agent/src/cli.ts" + WORKERS="./packages/natives/src/grep/worker.ts ./packages/natives/src/html/worker.ts ./packages/natives/src/image/worker.ts" + DEFINES="--define OMP_COMPILED=true --root ." + bun build --compile $DEFINES --target=bun-darwin-arm64 $ENTRYPOINT $WORKERS --outfile packages/coding-agent/binaries/omp-darwin-arm64 + bun build --compile $DEFINES --target=bun-darwin-x64 $ENTRYPOINT $WORKERS --outfile packages/coding-agent/binaries/omp-darwin-x64 + bun build --compile $DEFINES --target=bun-linux-x64 $ENTRYPOINT $WORKERS --outfile packages/coding-agent/binaries/omp-linux-x64 + bun build --compile $DEFINES --target=bun-linux-arm64 $ENTRYPOINT $WORKERS --outfile packages/coding-agent/binaries/omp-linux-arm64 + bun build --compile $DEFINES --target=bun-windows-x64 $ENTRYPOINT $WORKERS --outfile packages/coding-agent/binaries/omp-windows-x64.exe - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: diff --git a/crates/pi-natives/src/highlight.rs b/crates/pi-natives/src/highlight.rs index 92b0ba6fd..466ecc120 100644 --- a/crates/pi-natives/src/highlight.rs +++ b/crates/pi-natives/src/highlight.rs @@ -1,9 +1,9 @@ //! Syntax highlighting using syntect. //! //! Provides ANSI-colored output for code blocks. Takes theme colors as input -//! and maps syntect scopes to 9 semantic categories: +//! and maps syntect scopes to 11 semantic categories: //! - comment, keyword, function, variable, string, number, type, operator, -//! punctuation +//! punctuation, inserted, deleted use std::sync::OnceLock; @@ -65,6 +65,12 @@ struct ScopeMatchers { variable: Scope, entity_name: Scope, meta_path: Scope, + + // Diff (indices 9, 10) + markup_inserted: Scope, + markup_deleted: Scope, + meta_diff_header: Scope, + meta_diff_range: Scope, } impl ScopeMatchers { @@ -98,6 +104,10 @@ impl ScopeMatchers { variable: Scope::new("variable").unwrap(), entity_name: Scope::new("entity.name").unwrap(), meta_path: Scope::new("meta.path").unwrap(), + markup_inserted: Scope::new("markup.inserted").unwrap(), + markup_deleted: Scope::new("markup.deleted").unwrap(), + meta_diff_header: Scope::new("meta.diff.header").unwrap(), + meta_diff_range: Scope::new("meta.diff.range").unwrap(), } } } @@ -120,6 +130,10 @@ pub struct HighlightColors { pub r#type: String, pub operator: String, pub punctuation: String, + #[serde(default)] + pub inserted: String, + #[serde(default)] + pub deleted: String, } /// Language alias mappings: (aliases, target syntax name). @@ -204,6 +218,21 @@ fn scope_to_color_index(scope: &ScopeStack) -> usize { return 0; } + // Diff inserted (index 9) - check before other scopes + if m.markup_inserted.is_prefix_of(*s) { + return 9; + } + + // Diff deleted (index 10) + if m.markup_deleted.is_prefix_of(*s) { + return 10; + } + + // Diff header/range -> keyword (index 1) + if m.meta_diff_header.is_prefix_of(*s) || m.meta_diff_range.is_prefix_of(*s) { + return 1; + } + // String (index 4) if m.string.is_prefix_of(*s) || m.constant_character.is_prefix_of(*s) @@ -322,6 +351,8 @@ pub fn highlight_code(code: &str, lang: Option, colors: JsValue) -> Stri &colors.r#type, // 6 &colors.operator, // 7 &colors.punctuation, // 8 + &colors.inserted, // 9 + &colors.deleted, // 10 ]; let ss = get_syntax_set(); diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 7c0025c6c..912dde741 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -74,7 +74,7 @@ "scripts": { "check": "tsgo -p tsconfig.json", "format-prompts": "bun scripts/format-prompts.ts", - "build:binary": "bun build --compile ./src/cli.ts --outfile dist/omp", + "build:binary": "cd ../.. && bun build --compile --define OMP_COMPILED=true --root . ./packages/coding-agent/src/cli.ts ./packages/natives/src/grep/worker.ts ./packages/natives/src/html/worker.ts ./packages/natives/src/image/worker.ts --outfile packages/coding-agent/dist/omp", "generate-template": "bun scripts/generate-template.ts", "test": "bun test" }, @@ -97,7 +97,6 @@ "nanoid": "^5.1.6", "node-html-parser": "^7.0.2", "smol-toml": "^1.6.0", - "strip-ansi": "^7.1.2", "zod": "^4.3.6" }, "devDependencies": { diff --git a/packages/coding-agent/src/cli/grep-cli.ts b/packages/coding-agent/src/cli/grep-cli.ts new file mode 100644 index 000000000..507200132 --- /dev/null +++ b/packages/coding-agent/src/cli/grep-cli.ts @@ -0,0 +1,152 @@ +/** + * Grep CLI command handlers. + * + * Handles `omp grep` subcommand for testing grep tool on Windows. + */ +import * as path from "node:path"; +import { grep } from "@oh-my-pi/pi-natives"; +import chalk from "chalk"; +import { APP_NAME } from "../config"; + +export interface GrepCommandArgs { + pattern: string; + path: string; + glob?: string; + limit: number; + context: number; + mode: "content" | "filesWithMatches" | "count"; +} + +/** + * Parse grep subcommand arguments. + * Returns undefined if not a grep command. + */ +export function parseGrepArgs(args: string[]): GrepCommandArgs | undefined { + if (args.length === 0 || args[0] !== "grep") { + return undefined; + } + + const result: GrepCommandArgs = { + pattern: "", + path: ".", + limit: 20, + context: 2, + mode: "content", + }; + + const positional: string[] = []; + + for (let i = 1; i < args.length; i++) { + const arg = args[i]; + if (arg === "--glob" || arg === "-g") { + result.glob = args[++i]; + } else if (arg === "--limit" || arg === "-l") { + result.limit = parseInt(args[++i], 10); + } else if (arg === "--context" || arg === "-C") { + result.context = parseInt(args[++i], 10); + } else if (arg === "--files" || arg === "-f") { + result.mode = "filesWithMatches"; + } else if (arg === "--count" || arg === "-c") { + result.mode = "count"; + } else if (!arg.startsWith("-")) { + positional.push(arg); + } + } + + if (positional.length >= 1) { + result.pattern = positional[0]; + } + if (positional.length >= 2) { + result.path = positional[1]; + } + + return result; +} + +export async function runGrepCommand(cmd: GrepCommandArgs): Promise { + if (!cmd.pattern) { + console.error(chalk.red("Error: Pattern is required")); + process.exit(1); + } + + const searchPath = path.resolve(cmd.path); + console.log(chalk.dim(`Searching in: ${searchPath}`)); + console.log(chalk.dim(`Pattern: ${cmd.pattern}`)); + console.log(chalk.dim(`Mode: ${cmd.mode}, Limit: ${cmd.limit}, Context: ${cmd.context}`)); + + console.log(""); + + try { + const result = await grep({ + pattern: cmd.pattern, + path: searchPath, + glob: cmd.glob, + mode: cmd.mode, + maxCount: cmd.limit, + context: cmd.mode === "content" ? cmd.context : undefined, + hidden: true, + }); + + console.log(chalk.green(`Total matches: ${result.totalMatches}`)); + console.log(chalk.green(`Files with matches: ${result.filesWithMatches}`)); + console.log(chalk.green(`Files searched: ${result.filesSearched}`)); + if (result.limitReached) { + console.log(chalk.yellow(`Limit reached: true`)); + } + console.log(""); + + for (const match of result.matches) { + const displayPath = match.path.replace(/\\/g, "/"); + + if (cmd.mode === "content") { + if (match.contextBefore) { + for (const ctx of match.contextBefore) { + console.log(chalk.dim(`${displayPath}-${ctx.lineNumber}- ${ctx.line}`)); + } + } + console.log(`${chalk.cyan(displayPath)}:${chalk.yellow(String(match.lineNumber))}: ${match.line}`); + if (match.contextAfter) { + for (const ctx of match.contextAfter) { + console.log(chalk.dim(`${displayPath}-${ctx.lineNumber}- ${ctx.line}`)); + } + } + console.log(""); + } else if (cmd.mode === "count") { + console.log(`${chalk.cyan(displayPath)}: ${match.matchCount ?? 0} matches`); + } else { + console.log(chalk.cyan(displayPath)); + } + } + } catch (err) { + console.error(chalk.red(`Error: ${err instanceof Error ? err.message : String(err)}`)); + process.exit(1); + } +} + +export function printGrepHelp(): void { + console.log(`${chalk.bold(`${APP_NAME} grep`)} - Test grep tool + +${chalk.bold("Usage:")} + ${APP_NAME} grep [path] [options] + +${chalk.bold("Arguments:")} + pattern Regex pattern to search for + path Directory or file to search (default: .) + +${chalk.bold("Options:")} + -g, --glob Filter files by glob pattern + -l, --limit Max matches (default: 20) + -C, --context Context lines (default: 2) + -f, --files Output file names only + -c, --count Output match counts per file + -h, --help Show this help + +${chalk.bold("Environment:")} + OMP_GREP_WORKERS=0 Disable worker pool (use single-threaded mode) + +${chalk.bold("Examples:")} + ${APP_NAME} grep "import" src/ + ${APP_NAME} grep "TODO" . --glob "*.ts" + ${APP_NAME} grep "function" --files +`); +} diff --git a/packages/coding-agent/src/lsp/render.ts b/packages/coding-agent/src/lsp/render.ts index 08f7928e5..fd74d8228 100644 --- a/packages/coding-agent/src/lsp/render.ts +++ b/packages/coding-agent/src/lsp/render.ts @@ -306,6 +306,8 @@ function highlightCode(codeText: string, language: string, theme: Theme): string type: theme.getFgAnsi("syntaxType"), operator: theme.getFgAnsi("syntaxOperator"), punctuation: theme.getFgAnsi("syntaxPunctuation"), + inserted: theme.getFgAnsi("toolDiffAdded"), + deleted: theme.getFgAnsi("toolDiffRemoved"), }; return nativeHighlightCode(codeText, validLang, colors).split("\n"); } catch { diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index cab7e6621..87946d5e4 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -14,6 +14,7 @@ import chalk from "chalk"; import { type Args, parseArgs, printHelp } from "./cli/args"; import { parseConfigArgs, printConfigHelp, runConfigCommand } from "./cli/config-cli"; import { processFileArguments } from "./cli/file-processor"; +import { parseGrepArgs, printGrepHelp, runGrepCommand } from "./cli/grep-cli"; import { parseJupyterArgs, printJupyterHelp, runJupyterCommand } from "./cli/jupyter-cli"; import { listModels } from "./cli/list-models"; import { parsePluginArgs, printPluginHelp, runPluginCommand } from "./cli/plugin-cli"; @@ -553,6 +554,17 @@ export async function main(args: string[]) { return; } + // Handle grep subcommand (for testing grep tool) + const grepCmd = parseGrepArgs(args); + if (grepCmd) { + if (args.includes("--help") || args.includes("-h")) { + printGrepHelp(); + return; + } + await runGrepCommand(grepCmd); + return; + } + // Handle commit subcommand const commitCmd = parseCommitArgs(args); if (commitCmd) { diff --git a/packages/coding-agent/src/modes/components/bash-execution.ts b/packages/coding-agent/src/modes/components/bash-execution.ts index 5748c8ce6..8ba869710 100644 --- a/packages/coding-agent/src/modes/components/bash-execution.ts +++ b/packages/coding-agent/src/modes/components/bash-execution.ts @@ -2,7 +2,6 @@ * Component for displaying bash command execution with streaming output. */ import { Container, Loader, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui"; -import stripAnsi from "strip-ansi"; import { getSymbolTheme, theme } from "../../modes/theme/theme"; import type { TruncationMeta } from "../../tools/output-meta"; import { formatSize } from "../../tools/truncate"; @@ -188,7 +187,7 @@ export class BashExecutionComponent extends Container { private normalizeOutput(text: string): string { // Strip ANSI codes and normalize line endings // Note: binary data is already sanitized in tui-renderer.ts executeBashCommand - return stripAnsi(text).replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + return Bun.stripANSI(text).replace(/\r\n/g, "\n").replace(/\r/g, "\n"); } private setOutput(output: string): void { diff --git a/packages/coding-agent/src/modes/components/python-execution.ts b/packages/coding-agent/src/modes/components/python-execution.ts index c9869f97b..d02d4c9f8 100644 --- a/packages/coding-agent/src/modes/components/python-execution.ts +++ b/packages/coding-agent/src/modes/components/python-execution.ts @@ -3,7 +3,6 @@ * Shares the same kernel session as the agent's Python tool. */ import { Container, Loader, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui"; -import stripAnsi from "strip-ansi"; import { getSymbolTheme, highlightCode, theme } from "../../modes/theme/theme"; import type { TruncationMeta } from "../../tools/output-meta"; import { formatSize } from "../../tools/truncate"; @@ -172,7 +171,7 @@ export class PythonExecutionComponent extends Container { } private normalizeOutput(text: string): string { - return stripAnsi(text).replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + return Bun.stripANSI(text).replace(/\r\n/g, "\n").replace(/\r/g, "\n"); } private setOutput(output: string): void { diff --git a/packages/coding-agent/src/modes/theme/theme.ts b/packages/coding-agent/src/modes/theme/theme.ts index f43419ac0..0712a7b8e 100644 --- a/packages/coding-agent/src/modes/theme/theme.ts +++ b/packages/coding-agent/src/modes/theme/theme.ts @@ -2049,6 +2049,8 @@ function getHighlightColors(t: Theme): NativeHighlightColors { type: t.getFgAnsi("syntaxType"), operator: t.getFgAnsi("syntaxOperator"), punctuation: t.getFgAnsi("syntaxPunctuation"), + inserted: t.getFgAnsi("toolDiffAdded"), + deleted: t.getFgAnsi("toolDiffRemoved"), }; } return cachedHighlightColors; diff --git a/packages/coding-agent/test/tools/python-renderer.test.ts b/packages/coding-agent/test/tools/python-renderer.test.ts index a943b64b8..b652f3d2b 100644 --- a/packages/coding-agent/test/tools/python-renderer.test.ts +++ b/packages/coding-agent/test/tools/python-renderer.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "bun:test"; import { getThemeByName } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import { pythonToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/python"; -import stripAnsi from "strip-ansi"; describe("pythonToolRenderer", () => { it("renders truncated output when collapsed and full output when expanded", async () => { @@ -32,13 +31,13 @@ describe("pythonToolRenderer", () => { { expanded: false, isPartial: false, renderContext: { previewLines: 2 } }, uiTheme, ); - const collapsedLines = stripAnsi(collapsed.render(80).join("\n")); + const collapsedLines = Bun.stripANSI(collapsed.render(80).join("\n")); expect(collapsedLines).toContain("line 4"); expect(collapsedLines).not.toContain("line 1"); expect(collapsedLines).toContain("more lines"); const expanded = pythonToolRenderer.renderResult(result, { expanded: true, isPartial: false }, uiTheme); - const expandedLines = stripAnsi(expanded.render(80).join("\n")); + const expandedLines = Bun.stripANSI(expanded.render(80).join("\n")); expect(expandedLines).toContain("line 1"); expect(expandedLines).toContain("line 4"); expect(expandedLines).not.toContain("more lines"); diff --git a/packages/natives/src/grep/file-reader.ts b/packages/natives/src/grep/file-reader.ts new file mode 100644 index 000000000..d764c46b2 --- /dev/null +++ b/packages/natives/src/grep/file-reader.ts @@ -0,0 +1,51 @@ +/** + * Cross-platform file reader for grep. + * + * Uses mmap for files <= 4MB on platforms that support it, + * falls back to reading into a reusable buffer otherwise. + */ +import * as fs from "node:fs/promises"; + +const MAX_MMAP_SIZE = 4 * 1024 * 1024; // 4MB +export class FileReader { + #buffer: Buffer | null = null; + constructor(private readonly maxSize: number = MAX_MMAP_SIZE) {} + #getBuffer(size: number): Buffer { + if (!this.#buffer) { + this.#buffer = Buffer.allocUnsafe(this.maxSize); + } + return this.#buffer.subarray(0, size); + } + + async read(filePath: string): Promise { + let fileSize: number; + try { + const stat = await fs.stat(filePath); + fileSize = stat.size; + } catch { + return null; + } + + // Skip files larger than buffer size (only search first 4MB worth) + const readSize = Math.min(fileSize, this.maxSize); + + // Try mmap for small files (fast path on Linux/macOS) + if (fileSize <= this.maxSize) { + try { + return Bun.mmap(filePath); + } catch { + // mmap not supported (Windows) or failed, fall through to read + } + } + + // Fall back to reading into buffer + try { + await using handle = await fs.open(filePath, "r"); + const buffer = this.#getBuffer(readSize); + const { bytesRead } = await handle.read(buffer, 0, readSize, 0); + return buffer.subarray(0, bytesRead); + } catch { + return null; + } + } +} diff --git a/packages/natives/src/grep/index.ts b/packages/natives/src/grep/index.ts index 926b17ff6..1cda5632e 100644 --- a/packages/natives/src/grep/index.ts +++ b/packages/natives/src/grep/index.ts @@ -14,6 +14,8 @@ import { search as wasmSearch, } from "../../wasm/pi_natives"; import { WorkerPool } from "../pool"; +import { resolveWorkerSpecifier } from "../worker-resolver"; +import { FileReader } from "./file-reader"; import { buildGlobPattern, matchesTypeFilter, resolveTypeFilter } from "./filters"; import type { ContextLine, @@ -97,7 +99,17 @@ async function grepDirect(options: GrepOptions, onMatch?: (match: GrepMatch) => }; } - const content = Bun.mmap(searchPath); + const fileReader = new FileReader(); + const content = await fileReader.read(searchPath); + if (!content) { + return { + matches, + totalMatches, + filesWithMatches, + filesSearched, + limitReached: limitReached || undefined, + }; + } filesSearched = 1; const result = compiledPattern.search_bytes( @@ -148,6 +160,7 @@ async function grepDirect(options: GrepOptions, onMatch?: (match: GrepMatch) => gitignore: true, }); + const fileReader = new FileReader(); for (const relativePath of paths) { if (limitReached) break; if (typeFilter && !matchesTypeFilter(relativePath, typeFilter)) { @@ -157,12 +170,8 @@ async function grepDirect(options: GrepOptions, onMatch?: (match: GrepMatch) => const normalizedPath = relativePath.replace(/\\/g, "/"); const fullPath = path.join(searchPath, normalizedPath); - let content: Uint8Array; - try { - content = Bun.mmap(fullPath); - } catch { - continue; - } + const content = await fileReader.read(fullPath); + if (!content) continue; filesSearched++; @@ -293,7 +302,13 @@ export async function grepPool(options: GrepOptions): Promise { // ============================================================================= const pool = new WorkerPool({ - workerUrl: new URL("./worker.ts", import.meta.url).href, + createWorker: () => + new Worker( + resolveWorkerSpecifier({ + compiled: "./packages/natives/src/grep/worker.ts", + dev: new URL("./worker.ts", import.meta.url), + }), + ), maxWorkers: 4, idleTimeoutMs: 30_000, }); diff --git a/packages/natives/src/grep/worker.ts b/packages/natives/src/grep/worker.ts index 65140b224..e8a6ab63b 100644 --- a/packages/natives/src/grep/worker.ts +++ b/packages/natives/src/grep/worker.ts @@ -7,6 +7,7 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import { globPaths } from "@oh-my-pi/pi-utils"; import { CompiledPattern } from "../../wasm/pi_natives"; +import { FileReader } from "./file-reader"; import { buildGlobPattern, matchesTypeFilter, resolveTypeFilter } from "./filters"; import type { GrepMatch, GrepOptions, GrepResult, WasmSearchResult, WorkerRequest, WorkerResponse } from "./types"; @@ -46,6 +47,7 @@ async function runGrep(request: GrepOptions): Promise { const typeFilter = resolveTypeFilter(request.type); const globPattern = buildGlobPattern(request.glob); + const fileReader = new FileReader(); if (isFile) { if (typeFilter && !matchesTypeFilter(searchPath, typeFilter)) { return { @@ -57,7 +59,16 @@ async function runGrep(request: GrepOptions): Promise { }; } - const content = Bun.mmap(searchPath); + const content = await fileReader.read(searchPath); + if (!content) { + return { + matches, + totalMatches, + filesWithMatches, + filesSearched, + limitReached: limitReached || undefined, + }; + } filesSearched = 1; const result = compiledPattern.search_bytes( @@ -109,12 +120,8 @@ async function runGrep(request: GrepOptions): Promise { const normalizedPath = relativePath.replace(/\\/g, "/"); const fullPath = path.join(searchPath, normalizedPath); - let content: Uint8Array; - try { - content = Bun.mmap(fullPath); - } catch { - continue; - } + const content = await fileReader.read(fullPath); + if (!content) continue; filesSearched++; diff --git a/packages/natives/src/highlight/index.ts b/packages/natives/src/highlight/index.ts index 9f5a5cde5..744c66315 100644 --- a/packages/natives/src/highlight/index.ts +++ b/packages/natives/src/highlight/index.ts @@ -26,6 +26,10 @@ export interface HighlightColors { type: string; operator: string; punctuation: string; + /** Color for diff inserted lines (+). Optional, defaults to no coloring. */ + inserted?: string; + /** Color for diff deleted lines (-). Optional, defaults to no coloring. */ + deleted?: string; } /** diff --git a/packages/natives/src/html/index.ts b/packages/natives/src/html/index.ts index fe810976a..84abfe4fe 100644 --- a/packages/natives/src/html/index.ts +++ b/packages/natives/src/html/index.ts @@ -5,12 +5,19 @@ */ import { type RequestOptions, WorkerPool } from "../pool"; +import { resolveWorkerSpecifier } from "../worker-resolver"; import type { HtmlRequest, HtmlResponse, HtmlToMarkdownOptions } from "./types"; export type { HtmlToMarkdownOptions } from "./types"; const pool = new WorkerPool({ - workerUrl: new URL("./worker.ts", import.meta.url).href, + createWorker: () => + new Worker( + resolveWorkerSpecifier({ + compiled: "./packages/natives/src/html/worker.ts", + dev: new URL("./worker.ts", import.meta.url), + }), + ), maxWorkers: 2, idleTimeoutMs: 30_000, }); diff --git a/packages/natives/src/image/index.ts b/packages/natives/src/image/index.ts index e2f6a8e6e..18104ce33 100644 --- a/packages/natives/src/image/index.ts +++ b/packages/natives/src/image/index.ts @@ -6,13 +6,20 @@ */ import { WorkerPool } from "../pool"; +import { resolveWorkerSpecifier } from "../worker-resolver"; import type { ImageRequest, ImageResponse } from "./types"; // Re-export the enum for filter selection export { SamplingFilter } from "../../wasm/pi_natives"; const pool = new WorkerPool({ - workerUrl: new URL("./worker.ts", import.meta.url).href, + createWorker: () => + new Worker( + resolveWorkerSpecifier({ + compiled: "./packages/natives/src/image/worker.ts", + dev: new URL("./worker.ts", import.meta.url), + }), + ), maxWorkers: 1, idleTimeoutMs: 0, // Keep alive - stateful (image handles) }); diff --git a/packages/natives/src/pool.ts b/packages/natives/src/pool.ts index 041ec59f5..b6a22f2cf 100644 --- a/packages/natives/src/pool.ts +++ b/packages/natives/src/pool.ts @@ -19,8 +19,10 @@ export interface BaseResponse { } export interface WorkerPoolOptions { - /** URL to the worker script. */ - workerUrl: string | URL; + /** URL to the worker script (deprecated: use createWorker for compiled binaries). */ + workerUrl?: string | URL; + /** Factory function to create workers. Required for compiled binaries where Bun needs static analysis. */ + createWorker?: () => Worker; /** Maximum number of workers (default: 4). */ maxWorkers?: number; /** Idle timeout in ms before terminating unused workers (0 = never, default: 30000). */ @@ -61,7 +63,14 @@ interface PendingRequest { * @typeParam TRes - Response message type (must extend BaseResponse) */ export class WorkerPool { - readonly #options: Required; + readonly #options: { + workerUrl?: string | URL; + createWorker?: () => Worker; + maxWorkers: number; + idleTimeoutMs: number; + initTimeoutMs: number; + stuckGracePeriodMs: number; + }; readonly #pool: PooledWorker[] = []; readonly #waiters: Array<(worker: PooledWorker) => void> = []; readonly #pending = new Map>(); @@ -69,8 +78,12 @@ export class WorkerPool { #idleCheckInterval: ReturnType | null = null; constructor(options: WorkerPoolOptions) { + if (!options.workerUrl && !options.createWorker) { + throw new Error("WorkerPool requires either workerUrl or createWorker"); + } this.#options = { workerUrl: options.workerUrl, + createWorker: options.createWorker, maxWorkers: options.maxWorkers ?? 4, idleTimeoutMs: options.idleTimeoutMs ?? 30_000, initTimeoutMs: options.initTimeoutMs ?? 10_000, @@ -152,7 +165,7 @@ export class WorkerPool { } #createWorker(): PooledWorker { - const worker = new Worker(this.#options.workerUrl); + const worker = this.#options.createWorker ? this.#options.createWorker() : new Worker(this.#options.workerUrl!); const pooledWorker: PooledWorker = { worker, @@ -314,6 +327,7 @@ export class WorkerPool { dispose: () => clearTimeout(timeout), } as PendingRequest); + pooledWorker.currentRequestId = id; pooledWorker.worker.postMessage({ type: "init", id } satisfies BaseRequest); return promise; } diff --git a/packages/natives/src/worker-resolver.ts b/packages/natives/src/worker-resolver.ts new file mode 100644 index 000000000..ee34d28b6 --- /dev/null +++ b/packages/natives/src/worker-resolver.ts @@ -0,0 +1,9 @@ +declare const OMP_COMPILED: boolean | undefined; + +export function resolveWorkerSpecifier(options: { compiled: string; dev: URL }): string | URL { + if (typeof OMP_COMPILED !== "undefined" && OMP_COMPILED) { + return options.compiled; + } + + return options.dev; +} diff --git a/packages/natives/wasm/pi_natives_bg.wasm b/packages/natives/wasm/pi_natives_bg.wasm index b678c53ec..dba93bc3e 100644 --- a/packages/natives/wasm/pi_natives_bg.wasm +++ b/packages/natives/wasm/pi_natives_bg.wasm @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:651b67f5d05c8391d6e1590c04b5c74cc9d2cc8e3c5d9f7ebe6fd83dd103e99f -size 3718674 +oid sha256:6b5e30b4f15ac967b02911abb51654e05b7c7033b557ee7c6f0c7fee662a8d6c +size 3720495 diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 4b72f1f3f..1c0194378 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -933,6 +933,9 @@ export class Editor implements Component, Focusable { this.navigateHistory(-1); // Start browsing history } else if (this.historyIndex > -1 && this.isOnFirstVisualLine()) { this.navigateHistory(-1); // Navigate to older history entry + } else if (this.isOnFirstVisualLine()) { + // Already at top - jump to start of line + this.moveToLineStart(); } else { this.moveCursor(-1, 0); // Cursor movement (within text or history entry) } @@ -940,6 +943,9 @@ export class Editor implements Component, Focusable { // Down - history navigation or cursor movement if (this.historyIndex > -1 && this.isOnLastVisualLine()) { this.navigateHistory(1); // Navigate to newer history entry or clear + } else if (this.isOnLastVisualLine()) { + // Already at bottom - jump to end of line + this.moveToLineEnd(); } else { this.moveCursor(1, 0); // Cursor movement (within text or history entry) } diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index 060494d86..3dc88ad4d 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -53,6 +53,11 @@ export interface MarkdownTheme { symbols: SymbolTheme; } +interface InlineStyleContext { + applyText: (text: string) => string; + stylePrefix: string; +} + export class Markdown implements Component { private text: string; private paddingX: number; // Left/right padding @@ -115,7 +120,11 @@ export class Markdown implements Component { } // Replace tabs with 3 spaces for consistent rendering - const normalizedText = this.text.replace(/\t/g, " "); + let normalizedText = this.text.replace(/\t/g, " "); + + // Fix inline code fences: text:```lang or text```lang should have newline before ``` + // This handles malformed markdown from LLM thinking output + normalizedText = normalizedText.replace(/([^\n])```(\w*)\n/g, "$1\n```$2\n"); // Parse markdown to HTML-like tokens const tokens = marked.lexer(normalizedText); @@ -252,6 +261,20 @@ export class Markdown implements Component { return this.defaultStylePrefix; } + private getStylePrefix(styleFn: (text: string) => string): string { + const sentinel = "\u0000"; + const styled = styleFn(sentinel); + const sentinelIndex = styled.indexOf(sentinel); + return sentinelIndex >= 0 ? styled.slice(0, sentinelIndex) : ""; + } + + private getDefaultInlineStyleContext(): InlineStyleContext { + return { + applyText: (text: string) => this.applyDefaultStyle(text), + stylePrefix: this.getDefaultStylePrefix(), + }; + } + private renderToken(token: Token, width: number, nextTokenType?: string): string[] { const lines: string[] = []; @@ -339,13 +362,23 @@ export class Markdown implements Component { } case "blockquote": { - const quoteText = this.renderInlineTokens(token.tokens || []); + const quoteStyle = (text: string) => this.theme.quote(this.theme.italic(text)); + const quoteStyleContext: InlineStyleContext = { + applyText: quoteStyle, + stylePrefix: this.getStylePrefix(quoteStyle), + }; + const quoteText = this.renderInlineTokens(token.tokens || [], quoteStyleContext); const quoteLines = quoteText.split("\n"); + + // Calculate available width for quote content (subtract border + space = 2 chars) + const quoteContentWidth = Math.max(1, width - 2); + for (const quoteLine of quoteLines) { - lines.push( - this.theme.quoteBorder(`${this.theme.symbols.quoteBorder} `) + - this.theme.quote(this.theme.italic(quoteLine)), - ); + // Wrap the styled line, then add border to each wrapped line + const wrappedLines = wrapTextWithAnsi(quoteLine, quoteContentWidth); + for (const wrappedLine of wrappedLines) { + lines.push(this.theme.quoteBorder(`${this.theme.symbols.quoteBorder} `) + wrappedLine); + } } if (nextTokenType !== "space") { lines.push(""); // Add spacing after blockquotes (unless space token follows) @@ -382,51 +415,61 @@ export class Markdown implements Component { return lines; } - private renderInlineTokens(tokens: Token[]): string { + private renderInlineTokens(tokens: Token[], styleContext?: InlineStyleContext): string { let result = ""; + const resolvedStyleContext = styleContext ?? this.getDefaultInlineStyleContext(); + const { applyText, stylePrefix } = resolvedStyleContext; + const applyTextWithNewlines = (text: string): string => { + const segments: string[] = text.split("\n"); + return segments.map((segment: string) => applyText(segment)).join("\n"); + }; for (const token of tokens) { switch (token.type) { case "text": // Text tokens in list items can have nested tokens for inline formatting if (token.tokens && token.tokens.length > 0) { - result += this.renderInlineTokens(token.tokens); + result += this.renderInlineTokens(token.tokens, resolvedStyleContext); } else { - // Apply default style to plain text - result += this.applyDefaultStyle(token.text); + result += applyTextWithNewlines(token.text); } break; + case "paragraph": + // Paragraph tokens contain nested inline tokens + result += this.renderInlineTokens(token.tokens || [], resolvedStyleContext); + break; + case "strong": { - // Apply bold, then reapply default style after - const boldContent = this.renderInlineTokens(token.tokens || []); - result += this.theme.bold(boldContent) + this.getDefaultStylePrefix(); + const boldContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext); + result += this.theme.bold(boldContent) + stylePrefix; break; } case "em": { - // Apply italic, then reapply default style after - const italicContent = this.renderInlineTokens(token.tokens || []); - result += this.theme.italic(italicContent) + this.getDefaultStylePrefix(); + const italicContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext); + result += this.theme.italic(italicContent) + stylePrefix; break; } case "codespan": - // Apply code styling without backticks - result += this.theme.code(token.text) + this.getDefaultStylePrefix(); + result += this.theme.code(token.text) + stylePrefix; break; case "link": { - const linkText = this.renderInlineTokens(token.tokens || []); + const linkText = this.renderInlineTokens(token.tokens || [], resolvedStyleContext); // If link text matches href, only show the link once // Compare raw text (token.text) not styled text (linkText) since linkText has ANSI codes - if (token.text === token.href) { - result += this.theme.link(this.theme.underline(linkText)) + this.getDefaultStylePrefix(); + // For mailto: links, strip the prefix before comparing (autolinked emails have + // text="foo@bar.com" but href="mailto:foo@bar.com") + const hrefForComparison = token.href.startsWith("mailto:") ? token.href.slice(7) : token.href; + if (token.text === token.href || token.text === hrefForComparison) { + result += this.theme.link(this.theme.underline(linkText)) + stylePrefix; } else { result += this.theme.link(this.theme.underline(linkText)) + this.theme.linkUrl(` (${token.href})`) + - this.getDefaultStylePrefix(); + stylePrefix; } break; } @@ -436,22 +479,22 @@ export class Markdown implements Component { break; case "del": { - const delContent = this.renderInlineTokens(token.tokens || []); - result += this.theme.strikethrough(delContent) + this.getDefaultStylePrefix(); + const delContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext); + result += this.theme.strikethrough(delContent) + stylePrefix; break; } case "html": // Render inline HTML as plain text if ("raw" in token && typeof token.raw === "string") { - result += this.applyDefaultStyle(token.raw); + result += applyTextWithNewlines(token.raw); } break; default: // Handle any other inline token types as plain text if ("text" in token && typeof token.text === "string") { - result += this.applyDefaultStyle(token.text); + result += applyTextWithNewlines(token.text); } } } diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index f739f037e..4aa20ae17 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -9,6 +9,8 @@ import { StdinBuffer } from "./stdin-buffer"; // Track active terminal for emergency cleanup on crash let activeTerminal: ProcessTerminal | null = null; +// Track if a terminal was ever started (for emergency restore logic) +let terminalEverStarted = false; /** * Emergency terminal restore - call this from signal/crash handlers @@ -20,8 +22,9 @@ export function emergencyTerminalRestore(): void { if (terminal) { terminal.stop(); terminal.showCursor(); - } else { - // Blind restore if no instance tracked - covers edge cases + } else if (terminalEverStarted) { + // Blind restore only if we know a terminal was started but lost track of it + // This avoids writing escape sequences for non-TUI commands (grep, commit, etc.) process.stdout.write( "\x1b[?2004l" + // Disable bracketed paste "\x1b[ { proc.exited .catch(() => null) .then(async exitCode => { + // Stop pumping streams - process has exited, no more data coming + this.#stop.abort(); + if (this.#exitReasonPending) { this.#exitReason = this.#exitReasonPending; reject(this.#exitReasonPending); @@ -411,6 +414,9 @@ export class ChildProcess { } [Symbol.dispose](): void { + // Don't kill if process already exited - avoids race where dispose runs + // before the proc.exited.then() callback, causing spurious AbortError + if (this.proc.exitCode !== null) return; this.kill(new AbortError("process disposed", this.#stderrBuffer)); } } diff --git a/packages/utils/src/stream.ts b/packages/utils/src/stream.ts index 317e3fcee..0b12b47e2 100644 --- a/packages/utils/src/stream.ts +++ b/packages/utils/src/stream.ts @@ -1,5 +1,4 @@ import { TextDecoderStream } from "node:stream/web"; -import stripAnsi from "strip-ansi"; /** * Sanitize binary output for display/storage. @@ -40,7 +39,7 @@ export function sanitizeBinaryOutput(str: string): string { * Sanitize text output: strip ANSI codes, remove binary garbage, normalize line endings. */ export function sanitizeText(text: string): string { - return sanitizeBinaryOutput(stripAnsi(text)).replace(/\r/g, ""); + return sanitizeBinaryOutput(Bun.stripANSI(text)).replace(/\r/g, ""); } /**