feat(deps): replaced external JSON/JSONL and text utilities with Bun built-in APIs

- Replaced external JSON/JSONL parsing libraries with Bun's built-in JSON5 and JSONL APIs across all packages.
- Removed dependencies: json5, ndjson, get-east-asian-width, json-stringify-safe, split2, through2, and @types/ndjson.
- Replaced custom text width and ANSI wrapping implementations with Bun.stringWidth() and Bun.wrapAnsi() APIs.
- Updated Bun type definitions from ^1.2.17 to ^1.2.18 and bun-types from ^1.3.5 to ^1.3.7.
- Refactored JSONL parsing throughout codebase to use Bun.JSONL.parse() and Bun.JSONL.parseChunk() with improved buffer management and error handling.
- Updated test assertions to use Bun.stringWidth() for dynamic width calculations instead of hardcoded values.
This commit is contained in:
can1357
2026-01-27 19:17:06 +01:00
parent 3cec31c57d
commit d9a3291641
31 changed files with 410 additions and 615 deletions
+86
View File
@@ -255,6 +255,84 @@ for await (const line of readLines(stream)) {
**Avoid manual reader loops** unless protocol requires it (SSE, streaming JSON-RPC).
### JSON5 Parsing
**Use `Bun.JSON5`** — never add `json5` as a dependency:
```typescript
// BAD: External dependency
import JSON5 from "json5";
const data = JSON5.parse(text);
// GOOD: Bun builtin
const data = Bun.JSON5.parse(text);
const output = Bun.JSON5.stringify(obj);
```
### JSONL Parsing
**Use `Bun.JSONL`** — never manually split and parse:
```typescript
// BAD: Manual split + JSON.parse
const lines = text.split("\n").filter(Boolean);
const entries = lines.map((line) => JSON.parse(line));
// GOOD: Full blob parsing
const entries = Bun.JSONL.parse(text);
```
**For streaming JSONL** (SSE, JSON-RPC, subprocess output), use `Bun.JSONL.parseChunk()`:
```typescript
// BAD: Manual buffering and line splitting
let buffer = "";
for await (const chunk of stream) {
buffer += decoder.decode(chunk);
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (line.trim()) yield JSON.parse(line);
}
}
// GOOD: Bun handles buffering and parsing
let buffer: Uint8Array | undefined;
for await (const chunk of stream) {
const { values, remainder } = Bun.JSONL.parseChunk(chunk, buffer);
buffer = remainder;
for (const value of values) yield value;
}
```
### Terminal Width and Wrapping
**Use `Bun.stringWidth()`** for display width calculations:
```typescript
// BAD: External dependency or custom implementation
import { getWidth } from "get-east-asian-width";
function visibleWidth(str: string) { /* custom logic */ }
// GOOD: Bun builtin (handles ANSI, emoji, CJK)
const width = Bun.stringWidth(text);
const widthNoAnsi = Bun.stringWidth(text, { countAnsiEscapeCodes: false });
```
**Use `Bun.wrapAnsi()`** for ANSI-aware text wrapping:
```typescript
// BAD: Custom ANSI-aware wrapping
function wrapTextWithAnsi(text: string, width: number) { /* complex SGR tracking */ }
// GOOD: Bun builtin
const wrapped = Bun.wrapAnsi(text, width, {
wordWrap: true,
hard: false,
trim: true,
});
```
### Where Bun Wins
| Operation | Use | Not |
@@ -267,6 +345,10 @@ for await (const line of readLines(stream)) {
| SQLite | `bun:sqlite` | `better-sqlite3` |
| Hashing | `Bun.hash()`, Web Crypto | `node:crypto` |
| Path resolution | `import.meta.dir`, `import.meta.path` | `fileURLToPath` dance |
| JSON5 parsing | `Bun.JSON5.parse()` | `json5` package |
| JSONL parsing | `Bun.JSONL.parse()`, `.parseChunk()` | manual split + `JSON.parse` |
| String width | `Bun.stringWidth()` | `get-east-asian-width`, custom |
| Text wrapping | `Bun.wrapAnsi()` | custom ANSI-aware wrappers |
### Patterns
@@ -290,6 +372,10 @@ const valid = await Bun.password.verify("password", hash);
- `new Promise((resolve) => setTimeout(resolve, ms))` → use `Bun.sleep(ms)`
- `existsSync/readFileSync/writeFileSync` in async code → use `Bun.file()` APIs
- Manual `child.stdout.getReader()` loops for non-streaming commands → use `readStream()` helper
- `import JSON5 from "json5"` → use `Bun.JSON5.parse()`
- `text.split("\n").map(JSON.parse)` for JSONL → use `Bun.JSONL.parse()`
- Custom `visibleWidth()` / `get-east-asian-width` → use `Bun.stringWidth()`
- Custom ANSI-aware text wrapping → use `Bun.wrapAnsi()`
## Logging
+6 -23
View File
@@ -6,15 +6,14 @@
"name": "omp-monorepo",
"dependencies": {
"@sinclair/typebox": "^0.34.46",
"get-east-asian-width": "^1.4.0",
},
"devDependencies": {
"@biomejs/biome": "2.3.12",
"@bufbuild/protoc-gen-es": "^2.10.2",
"@types/bun": "^1.2.17",
"@types/bun": "^1.2.18",
"@types/node": "^25.0.10",
"@typescript/native-preview": "^7.0.0-dev.20260123.3",
"bun-types": "^1.3.5",
"bun-types": "^1.3.7",
"concurrently": "^9.2.1",
"lint-staged": "^16.2.7",
"prettier": "^3.8.0",
@@ -53,7 +52,6 @@
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"chalk": "^5.6.2",
"json5": "^2.2.3",
"openai": "^6.16.0",
"partial-json": "^0.1.7",
"zod-to-json-schema": "^3.24.6",
@@ -86,7 +84,6 @@
"highlight.js": "^11.11.1",
"marked": "^17.0.1",
"nanoid": "^5.1.6",
"ndjson": "^2.0.0",
"node-html-parser": "^7.0.2",
"smol-toml": "^1.6.0",
"strip-ansi": "^7.1.2",
@@ -95,7 +92,6 @@
"devDependencies": {
"@types/diff": "^8.0.0",
"@types/ms": "^2.1.0",
"@types/ndjson": "^2.0.4",
"@types/node": "^25.0.10",
"ms": "^2.1.3",
},
@@ -127,7 +123,7 @@
},
"devDependencies": {
"@types/diff": "^7.0.2",
"bun-types": "^1.2.19",
"bun-types": "^1.3.7",
},
},
"packages/stats": {
@@ -156,7 +152,6 @@
"dependencies": {
"@types/mime-types": "^3.0.1",
"chalk": "^5.5.0",
"get-east-asian-width": "^1.3.0",
"marked": "^17.0.1",
"mime-types": "^3.0.1",
},
@@ -463,16 +458,12 @@
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/ndjson": ["@types/ndjson@2.0.4", "", { "dependencies": { "@types/node": "*", "@types/through": "*" } }, "sha512-ajAl7AjhFstF6waORYNSS49GL5iBKisqJlgvXuprXFKCX9fto4ordlNU3+XMgkMddgeR0WoQQBmKUk0v0dJ4pw=="],
"@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="],
"@types/react": ["@types/react@19.2.9", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/through": ["@types/through@0.0.33", "", { "dependencies": { "@types/node": "*" } }, "sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ=="],
"@types/triple-beam": ["@types/triple-beam@1.3.5", "", {}, "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="],
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
@@ -537,7 +528,7 @@
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
"bun-types": ["bun-types@1.3.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-qyschsA03Qz+gou+apt6HNl6HnI+sJJLL4wLDke4iugsE6584CMupOtTY1n+2YC9nGVrEKUlTs99jjRLKgWnjQ=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
@@ -783,10 +774,6 @@
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
"json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
"jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
@@ -839,8 +826,6 @@
"nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="],
"ndjson": ["ndjson@2.0.0", "", { "dependencies": { "json-stringify-safe": "^5.0.1", "minimist": "^1.2.5", "readable-stream": "^3.6.0", "split2": "^3.0.0", "through2": "^4.0.0" }, "bin": { "ndjson": "cli.js" } }, "sha512-nGl7LRGrzugTtaFcJMhLbpzJM6XdivmbkdlaGcrk/LXg2KL/YBC6z1g70xh0/al+oFuVFP8N8kiWRucmeEH/qQ=="],
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="],
@@ -973,8 +958,6 @@
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"split2": ["split2@3.2.2", "", { "dependencies": { "readable-stream": "^3.0.0" } }, "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg=="],
"stack-trace": ["stack-trace@0.0.10", "", {}, "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
@@ -1003,8 +986,6 @@
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
"through2": ["through2@4.0.2", "", { "dependencies": { "readable-stream": "3" } }, "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
@@ -1093,6 +1074,8 @@
"@reduxjs/toolkit/immer": ["immer@11.1.3", "", {}, "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q=="],
"@types/bun/bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
"cli-highlight/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"cli-highlight/highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="],
+3 -4
View File
@@ -26,10 +26,10 @@
"devDependencies": {
"@biomejs/biome": "2.3.12",
"@bufbuild/protoc-gen-es": "^2.10.2",
"@types/bun": "^1.2.17",
"@types/bun": "^1.2.18",
"@types/node": "^25.0.10",
"@typescript/native-preview": "^7.0.0-dev.20260123.3",
"bun-types": "^1.3.5",
"bun-types": "^1.3.7",
"concurrently": "^9.2.1",
"lint-staged": "^16.2.7",
"prettier": "^3.8.0",
@@ -44,7 +44,6 @@
"packageManager": "bun@1.3.6",
"version": "0.0.3",
"dependencies": {
"@sinclair/typebox": "^0.34.46",
"get-east-asian-width": "^1.4.0"
"@sinclair/typebox": "^0.34.46"
}
}
+3
View File
@@ -2,6 +2,9 @@
## [Unreleased]
### Changed
- Replaced JSON5 dependency with Bun.JSON5 parsing
### Fixed
- Filtered empty user text blocks for OpenAI-compatible completions and normalized Kimi reasoning_content for OpenRouter tool-call messages
-1
View File
@@ -68,7 +68,6 @@
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"chalk": "^5.6.2",
"json5": "^2.2.3",
"openai": "^6.16.0",
"partial-json": "^0.1.7",
"zod-to-json-schema": "^3.24.6"
+1 -2
View File
@@ -3,7 +3,6 @@ import * as fs from "node:fs/promises";
import http2 from "node:http2";
import { create, fromBinary, fromJson, type JsonValue, toBinary, toJson } from "@bufbuild/protobuf";
import { ValueSchema } from "@bufbuild/protobuf/wkt";
import JSON5 from "json5";
import { calculateCost } from "../models";
import type {
Api,
@@ -1509,7 +1508,7 @@ function parseToolArgsJson(text: string): unknown {
.replace(/\bNone\b/g, "null")
.replace(/\bTrue\b/g, "true")
.replace(/\bFalse\b/g, "false");
return JSON5.parse(normalized);
return Bun.JSON5.parse(normalized);
} catch {}
return text;
}
@@ -527,6 +527,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
const reader = activeResponse.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let jsonlBuffer = "";
// Set up abort handler to cancel reader when signal fires
const abortHandler = () => {
@@ -553,14 +554,17 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
const jsonStr = line.slice(5).trim();
if (!jsonStr) continue;
let chunk: CloudCodeAssistResponseChunk;
try {
chunk = JSON.parse(jsonStr);
} catch {
jsonlBuffer += `${jsonStr}\n`;
const parsed = Bun.JSONL.parseChunk(jsonlBuffer);
jsonlBuffer = jsonlBuffer.slice(parsed.read);
if (parsed.error) {
jsonlBuffer = "";
continue;
}
const chunk = parsed.values[0] as CloudCodeAssistResponseChunk | undefined;
if (!chunk) continue;
// Unwrap the response
const responseData = chunk.response;
if (!responseData) continue;
+31 -14
View File
@@ -23,6 +23,8 @@ Common options:
- **Responses**: JSON objects with `type: "response"` indicating command success/failure
- **Events**: Agent events streamed to stdout as JSON lines
If you're consuming output in Bun, prefer `Bun.JSONL.parse(text)` for buffered JSONL or `Bun.JSONL.parseChunk()` for streaming output instead of splitting and `JSON.parse`.
All commands support an optional `id` field for request/response correlation. If provided, the corresponding response will include the same `id`.
## Commands
@@ -1034,6 +1036,7 @@ Created by the `bash` RPC command (not by LLM tool calls):
```python
import subprocess
import json
import jsonlines
proc = subprocess.Popen(
["omp", "--mode", "rpc", "--no-session"],
@@ -1047,8 +1050,9 @@ def send(cmd):
proc.stdin.flush()
def read_events():
for line in proc.stdout:
yield json.loads(line)
with jsonlines.Reader(proc.stdout) as reader:
for event in reader:
yield event
# Send prompt
send({"type": "prompt", "message": "Hello!"})
@@ -1065,26 +1069,39 @@ for event in read_events():
break
```
## Example: Interactive Client (Node.js)
## Example: Interactive Client (Bun)
See [`test/rpc-example.ts`](../test/rpc-example.ts) for a complete interactive example, or [`src/modes/rpc/rpc-client.ts`](../src/modes/rpc/rpc-client.ts) for a typed client implementation.
```javascript
const { spawn } = require("child_process");
const readline = require("readline");
const agent = Bun.spawn(["omp", "--mode", "rpc", "--no-session"], {
stdin: "pipe",
stdout: "pipe",
});
const agent = spawn("omp", ["--mode", "rpc", "--no-session"]);
const decoder = new TextDecoder();
let buffer = "";
readline.createInterface({ input: agent.stdout }).on("line", (line) => {
const event = JSON.parse(line);
if (event.type === "message_update") {
const { assistantMessageEvent } = event;
if (assistantMessageEvent.type === "text_delta") {
process.stdout.write(assistantMessageEvent.delta);
async function readEvents() {
const reader = agent.stdout.getReader();
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const result = Bun.JSONL.parseChunk(buffer);
buffer = buffer.slice(result.read);
for (const event of result.values) {
if (event.type === "message_update") {
const { assistantMessageEvent } = event;
if (assistantMessageEvent.type === "text_delta") {
process.stdout.write(assistantMessageEvent.delta);
}
}
}
}
});
}
readEvents();
// Send prompt
agent.stdin.write(JSON.stringify({ type: "prompt", message: "Hello" }) + "\n");
+3 -5
View File
@@ -227,12 +227,10 @@ Entries form a tree:
## Parsing Example
```typescript
import * as fs from "node:fs";
const text = await Bun.file("session.jsonl").text();
const entries = Bun.JSONL.parse(text);
const lines = fs.readFileSync("session.jsonl", "utf8").trim().split("\n");
for (const line of lines) {
const entry = JSON.parse(line);
for (const entry of entries) {
switch (entry.type) {
case "session":
+3 -3
View File
@@ -153,7 +153,7 @@ handleInput(data: string) {
## Line Width
**Critical:** Each line from `render()` must not exceed the `width` parameter.
**Critical:** Each line from `render()` must not exceed the `width` parameter. Width calculations and wrapping follow Bun’s built-ins (`Bun.stringWidth`, `Bun.wrapAnsi`).
```typescript
import { visibleWidth, truncateToWidth } from "@oh-my-pi/pi-tui";
@@ -166,9 +166,9 @@ render(width: number): string[] {
Utilities:
- `visibleWidth(str)` - Get display width (ignores ANSI codes)
- `visibleWidth(str)` - Get display width (ANSI-safe, Unicode-width aware)
- `truncateToWidth(str, width, ellipsis?)` - Truncate with optional ellipsis
- `wrapTextWithAnsi(str, width)` - Word wrap preserving ANSI codes
- `wrapTextWithAnsi(str, width)` - Word wrap preserving ANSI codes (Bun.wrapAnsi)
## Creating Custom Components
-2
View File
@@ -100,7 +100,6 @@
"highlight.js": "^11.11.1",
"marked": "^17.0.1",
"nanoid": "^5.1.6",
"ndjson": "^2.0.0",
"node-html-parser": "^7.0.2",
"smol-toml": "^1.6.0",
"strip-ansi": "^7.1.2",
@@ -109,7 +108,6 @@
"devDependencies": {
"@types/diff": "^8.0.0",
"@types/ms": "^2.1.0",
"@types/ndjson": "^2.0.4",
"@types/node": "^25.0.10",
"ms": "^2.1.3"
},
+2 -5
View File
@@ -13,11 +13,8 @@ export function parseSSE(text: string): unknown {
if (line.startsWith("data: ")) {
const data = line.slice(6).trim();
if (data === "[DONE]") continue;
try {
return JSON.parse(data);
} catch {
// Try next line
}
const result = Bun.JSONL.parseChunk(`${data}\n`);
if (result.values.length > 0) return result.values[0];
}
}
// Fallback: try parsing entire response as JSON
@@ -86,18 +86,26 @@ export class HttpTransport implements MCPTransport {
return;
}
let buffer = "";
// Read SSE stream
for await (const event of readSseEvents(response.body)) {
if (!this._connected) break;
const data = event.data?.trim();
if (!data || data === "[DONE]") continue;
try {
const message = JSON.parse(data);
buffer += data;
if (!data.endsWith("\n")) {
buffer += "\n";
}
const result = Bun.JSONL.parseChunk(buffer);
buffer = buffer.slice(result.read);
if (result.error) {
buffer = "";
continue;
}
for (const message of result.values as JsonRpcMessage[]) {
if ("method" in message && !("id" in message)) {
this.onNotification?.(message.method, message.params);
}
} catch {
// Ignore parse errors
}
}
} catch (error) {
@@ -174,13 +182,22 @@ export class HttpTransport implements MCPTransport {
const timeout = this.config.timeout ?? 30000;
const parse = async (): Promise<T> => {
let buffer = "";
for await (const event of readSseEvents(response.body!)) {
const data = event.data?.trim();
if (!data || data === "[DONE]") continue;
buffer += data;
if (!data.endsWith("\n")) {
buffer += "\n";
}
const result = Bun.JSONL.parseChunk(buffer);
buffer = buffer.slice(result.read);
if (result.error) {
buffer = "";
continue;
}
try {
const message = JSON.parse(data) as JsonRpcMessage;
for (const message of result.values as JsonRpcMessage[]) {
if (
"id" in message &&
(message as JsonRpcResponse).id === expectedId &&
@@ -196,10 +213,6 @@ export class HttpTransport implements MCPTransport {
if ("method" in message && !("id" in message)) {
this.onNotification?.(message.method, message.params);
}
} catch (error) {
if (error instanceof Error && error.message.startsWith("MCP error")) {
throw error;
}
}
}
@@ -118,20 +118,25 @@ export class StdioTransport implements MCPTransport {
}
private processBuffer(): void {
const lines = this.buffer.split("\n");
// Keep incomplete last line in buffer
this.buffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const message = JSON.parse(trimmed) as JsonRpcResponse;
this.handleMessage(message);
} catch {
// Ignore malformed lines
while (this.buffer.length > 0) {
const result = Bun.JSONL.parseChunk(this.buffer);
for (const message of result.values) {
this.handleMessage(message as JsonRpcResponse);
}
if (result.error) {
const nextNewline = this.buffer.indexOf("\n", result.read);
if (nextNewline === -1) {
this.buffer = "";
break;
}
this.buffer = this.buffer.slice(nextNewline + 1);
continue;
}
if (result.read === 0) break;
this.buffer = this.buffer.slice(result.read);
if (result.done) break;
}
}
+3 -4
View File
@@ -107,10 +107,9 @@ export async function migrateSessionsFromAgentRoot(): Promise<void> {
if (isEnoent(err)) continue;
throw err;
}
const firstLine = content.split("\n")[0];
if (!firstLine?.trim()) continue;
const header = JSON.parse(firstLine);
const entries = Bun.JSONL.parse(content);
const header = entries[0];
if (!header) continue;
if (header.type !== "session" || !header.cwd) continue;
const cwd: string = header.cwd;
@@ -429,9 +429,10 @@ export class RpcClient {
// =========================================================================
private handleLine(line: string): void {
try {
const data = JSON.parse(line);
const result = Bun.JSONL.parseChunk(line);
if (result.error) return;
for (const data of result.values) {
// Check if it's a response to a pending request
if (data.type === "response" && data.id && this.pendingRequests.has(data.id)) {
const pending = this.pendingRequests.get(data.id)!;
@@ -444,8 +445,6 @@ export class RpcClient {
for (const listener of this.eventListeners) {
listener(data as AgentEvent);
}
} catch {
// Ignore non-JSON lines
}
}
+25 -19
View File
@@ -627,28 +627,34 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
for await (const line of readLines(Bun.stdin.stream())) {
if (!line.trim()) continue;
try {
const parsed = JSON.parse(line);
const result = Bun.JSONL.parseChunk(`${line}\n`);
if (result.error) {
output(error(undefined, "parse", `Failed to parse command: ${result.error.message}`));
continue;
}
// Handle extension UI responses
if (parsed.type === "extension_ui_response") {
const response = parsed as RpcExtensionUIResponse;
const pending = pendingExtensionRequests.get(response.id);
if (pending) {
pending.resolve(response);
for (const parsed of result.values) {
try {
// Handle extension UI responses
if ((parsed as RpcExtensionUIResponse).type === "extension_ui_response") {
const response = parsed as RpcExtensionUIResponse;
const pending = pendingExtensionRequests.get(response.id);
if (pending) {
pending.resolve(response);
}
continue;
}
continue;
// Handle regular commands
const command = parsed as RpcCommand;
const response = await handleCommand(command);
output(response);
// Check for deferred shutdown request (idle between commands)
await checkShutdownRequested();
} catch (e: any) {
output(error(undefined, "parse", `Failed to parse command: ${e.message}`));
}
// Handle regular commands
const command = parsed as RpcCommand;
const response = await handleCommand(command);
output(response);
// Check for deferred shutdown request (idle between commands)
await checkShutdownRequested();
} catch (e: any) {
output(error(undefined, "parse", `Failed to parse command: ${e.message}`));
}
}
@@ -277,6 +277,28 @@ function migrateToCurrentVersion(entries: FileEntry[]): boolean {
return true;
}
function parseJsonlEntries<T>(content: string): T[] {
if (!content.trim()) return [];
const entries: T[] = [];
let buffer = content;
while (buffer.length > 0) {
const result = Bun.JSONL.parseChunk(buffer);
if (result.values.length > 0) {
entries.push(...(result.values as T[]));
}
if (result.error) {
const nextNewline = buffer.indexOf("\n", result.read);
if (nextNewline === -1) break;
buffer = buffer.slice(nextNewline + 1);
continue;
}
if (result.read === 0) break;
buffer = buffer.slice(result.read);
if (result.done) break;
}
return entries;
}
/** Exported for testing */
export function migrateSessionEntries(entries: FileEntry[]): void {
migrateToCurrentVersion(entries);
@@ -284,20 +306,7 @@ export function migrateSessionEntries(entries: FileEntry[]): void {
/** Exported for compaction.test.ts */
export function parseSessionEntries(content: string): FileEntry[] {
const entries: FileEntry[] = [];
const lines = content.trim().split("\n");
for (const line of lines) {
if (!line.trim()) continue;
try {
const entry = JSON.parse(line) as FileEntry;
entries.push(entry);
} catch {
// Skip malformed lines
}
}
return entries;
return parseJsonlEntries<FileEntry>(content);
}
export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEntry | null {
@@ -463,19 +472,7 @@ export async function loadEntriesFromFile(
if (isEnoent(err)) return [];
throw err;
}
const entries: FileEntry[] = [];
const lines = content.trim().split("\n");
for (const line of lines) {
if (!line.trim()) continue;
try {
const entry = JSON.parse(line) as FileEntry;
entries.push(entry);
} catch {
// Skip malformed lines
}
}
const entries = parseJsonlEntries<FileEntry>(content);
// Validate session header
if (entries.length === 0) return entries;
@@ -546,27 +543,20 @@ class RecentSessionInfo {
* Extracts the text content from a user message entry.
* Returns undefined if the entry is not a user message or has no text.
*/
function extractFirstUserPrompt(lines: string[]): string | undefined {
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (!line?.trim()) continue;
try {
const entry = JSON.parse(line) as Record<string, unknown>;
if (entry.type !== "message") continue;
const message = entry.message as Record<string, unknown> | undefined;
if (message?.role !== "user") continue;
const content = message.content;
if (typeof content === "string") return content;
if (Array.isArray(content)) {
for (const block of content) {
if (typeof block === "object" && block !== null && "text" in block) {
const text = (block as { text: unknown }).text;
if (typeof text === "string") return text;
}
function extractFirstUserPrompt(entries: Array<Record<string, unknown>>): string | undefined {
for (const entry of entries) {
if (entry.type !== "message") continue;
const message = entry.message as Record<string, unknown> | undefined;
if (message?.role !== "user") continue;
const content = message.content;
if (typeof content === "string") return content;
if (Array.isArray(content)) {
for (const block of content) {
if (typeof block === "object" && block !== null && "text" in block) {
const text = (block as { text: unknown }).text;
if (typeof text === "string") return text;
}
}
} catch {
// Invalid JSON, skip to next line
}
}
return undefined;
@@ -584,13 +574,12 @@ async function getSortedSessions(sessionDir: string, storage: SessionStorage): P
files.map(async (path: string) => {
try {
const content = await storage.readTextPrefix(path, 4096);
const lines = content.split("\n");
const firstLine = lines[0];
if (!firstLine || !firstLine.trim()) return null;
const header = JSON.parse(firstLine) as Record<string, unknown>;
const entries = parseJsonlEntries<Record<string, unknown>>(content);
if (entries.length === 0) return null;
const header = entries[0] as Record<string, unknown>;
if (header.type !== "session" || typeof header.id !== "string") return null;
const mtime = storage.statSync(path).mtimeMs;
const firstPrompt = header.title ? undefined : extractFirstUserPrompt(lines);
const firstPrompt = header.title ? undefined : extractFirstUserPrompt(entries);
return new RecentSessionInfo(path, mtime, header, firstPrompt);
} catch {
return null;
@@ -913,48 +902,36 @@ async function collectSessionsFromFiles(files: string[], storage: SessionStorage
for (const file of files) {
try {
const content = await storage.readText(file);
const lines = content.trim().split("\n");
if (lines.length === 0) continue;
const entries = parseJsonlEntries<Record<string, unknown>>(content);
if (entries.length === 0) continue;
// Check first line for valid session header
// Check first entry for valid session header
type SessionHeaderShape = { type: string; id: string; cwd?: string; title?: string; timestamp: string };
let header: SessionHeaderShape | null = null;
try {
const first = JSON.parse(lines[0]) as SessionHeaderShape;
if (first.type === "session" && first.id) {
header = first;
}
} catch {
// Not valid JSON
}
if (!header) continue;
const header = entries[0] as SessionHeaderShape;
if (header.type !== "session" || !header.id) continue;
const stats = storage.statSync(file);
let messageCount = 0;
let firstMessage = "";
const allMessages: string[] = [];
for (let i = 1; i < lines.length; i++) {
try {
const entry = JSON.parse(lines[i]) as { type?: string; message?: Message };
for (let i = 1; i < entries.length; i++) {
const entry = entries[i] as { type?: string; message?: Message };
if (entry.type === "message" && entry.message) {
messageCount++;
if (entry.type === "message" && entry.message) {
messageCount++;
if (entry.message.role === "user" || entry.message.role === "assistant") {
const textContent = extractTextFromContent(entry.message.content);
if (entry.message.role === "user" || entry.message.role === "assistant") {
const textContent = extractTextFromContent(entry.message.content);
if (textContent) {
allMessages.push(textContent);
if (textContent) {
allMessages.push(textContent);
if (!firstMessage && entry.message.role === "user") {
firstMessage = textContent;
}
if (!firstMessage && entry.message.role === "user") {
firstMessage = textContent;
}
}
}
} catch {
// Skip malformed lines
}
}
+22 -23
View File
@@ -622,32 +622,31 @@ async function parseAntigravitySseForImage(response: Response, signal?: AbortSig
const jsonStr = line.slice(5).trim();
if (!jsonStr) continue;
let chunk: AntigravityResponseChunk;
try {
chunk = JSON.parse(jsonStr);
} catch {
continue;
}
const parsed = Bun.JSONL.parseChunk(`${jsonStr}\n`);
if (parsed.error || parsed.values.length === 0) continue;
const responseData = chunk.response;
if (!responseData?.candidates) continue;
for (const value of parsed.values) {
const chunk = value as AntigravityResponseChunk;
const responseData = chunk.response;
if (!responseData?.candidates) continue;
if (responseData.usageMetadata) {
usage = responseData.usageMetadata;
}
if (responseData.usageMetadata) {
usage = responseData.usageMetadata;
}
for (const candidate of responseData.candidates) {
const parts = candidate.content?.parts;
if (!parts) continue;
for (const part of parts) {
if (part.text) {
textParts.push(part.text);
}
if (part.inlineData?.data && part.inlineData?.mimeType) {
images.push({
data: part.inlineData.data,
mimeType: part.inlineData.mimeType,
});
for (const candidate of responseData.candidates) {
const parts = candidate.content?.parts;
if (!parts) continue;
for (const part of parts) {
if (part.text) {
textParts.push(part.text);
}
if (part.inlineData?.data && part.inlineData?.mimeType) {
images.push({
data: part.inlineData.data,
mimeType: part.inlineData.mimeType,
});
}
}
}
}
+58 -26
View File
@@ -3,7 +3,7 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallb
import { StringEnum } from "@oh-my-pi/pi-ai";
import type { Component } from "@oh-my-pi/pi-tui";
import { Text } from "@oh-my-pi/pi-tui";
import { ptree, readLines } from "@oh-my-pi/pi-utils";
import { ptree } from "@oh-my-pi/pi-utils";
import { Type } from "@sinclair/typebox";
import { $ } from "bun";
import { renderPromptTemplate } from "../config/prompt-templates";
@@ -558,47 +558,79 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
};
const maxMatches = effectiveLimit !== undefined ? effectiveLimit + effectiveOffset : undefined;
const processLine = async (line: string): Promise<void> => {
if (!line.trim()) {
const processEvent = async (event: unknown): Promise<void> => {
if (!event || typeof event !== "object") {
return;
}
const parsed = event as { type?: string; data?: { path?: { text?: string }; line_number?: number } };
if (parsed.type !== "match") {
return;
}
let event: { type: string; data?: { path?: { text?: string }; line_number?: number } };
try {
event = JSON.parse(line);
} catch {
const nextIndex = matchCount + 1;
if (maxMatches !== undefined && nextIndex > maxMatches) {
matchLimitReached = true;
killedDueToLimit = true;
child.kill("SIGKILL");
return;
}
if (event.type === "match") {
const nextIndex = matchCount + 1;
if (maxMatches !== undefined && nextIndex > maxMatches) {
matchLimitReached = true;
killedDueToLimit = true;
child.kill("SIGKILL");
matchCount = nextIndex;
const filePath = parsed.data?.path?.text;
const lineNumber = parsed.data?.line_number;
if (filePath && typeof lineNumber === "number") {
if (matchCount <= effectiveOffset) {
return;
}
recordFile(filePath);
recordFileMatch(filePath);
const block = await formatBlock(filePath, lineNumber);
outputLines.push(...block);
}
};
matchCount = nextIndex;
const filePath = event.data?.path?.text;
const lineNumber = event.data?.line_number;
const decoder = new TextDecoder();
let buffer = "";
const parseBuffer = async () => {
while (buffer.length > 0) {
const result = Bun.JSONL.parseChunk(buffer);
for (const value of result.values) {
await processEvent(value);
}
if (filePath && typeof lineNumber === "number") {
if (matchCount <= effectiveOffset) {
return;
if (result.read > 0) {
buffer = buffer.slice(result.read);
}
if (result.error) {
const nextNewline = buffer.indexOf("\n");
if (nextNewline === -1) {
buffer = "";
break;
}
recordFile(filePath);
recordFileMatch(filePath);
const block = await formatBlock(filePath, lineNumber);
outputLines.push(...block);
buffer = buffer.slice(nextNewline + 1);
continue;
}
if (result.read === 0) {
break;
}
}
};
// Process stdout line by line
// Process stdout stream with JSONL chunk parsing
try {
for await (const line of readLines(child.stdout)) {
await processLine(line);
for await (const chunk of child.stdout) {
if (killedDueToLimit) {
break;
}
buffer += decoder.decode(chunk, { stream: true });
await parseBuffer();
}
if (!killedDueToLimit) {
buffer += decoder.decode();
await parseBuffer();
}
} catch (err) {
if (err instanceof ptree.Exception && err.aborted) {
+3 -9
View File
@@ -67,7 +67,7 @@ describe.skipIf(!process.env.ANTHROPIC_API_KEY && !process.env.ANTHROPIC_OAUTH_T
expect(sessionFiles.length).toBe(1);
const sessionContent = await Bun.file(path.join(cwdSessionDir, sessionFiles[0])).text();
const entries = sessionContent.split("\n").map(line => JSON.parse(line));
const entries = Bun.JSONL.parse(sessionContent);
// First entry should be session header
expect(entries[0].type).toBe("session");
@@ -101,10 +101,7 @@ describe.skipIf(!process.env.ANTHROPIC_API_KEY && !process.env.ANTHROPIC_OAUTH_T
const cwdSessionDir = path.join(sessionsPath, sessionDirs[0]);
const sessionFiles = fs.readdirSync(cwdSessionDir).filter(f => f.endsWith(".jsonl"));
const sessionContent = await Bun.file(path.join(cwdSessionDir, sessionFiles[0])).text();
const entries = sessionContent
.trim()
.split("\n")
.map(line => JSON.parse(line));
const entries = Bun.JSONL.parse(sessionContent);
const compactionEntries = entries.filter((e: { type: string }) => e.type === "compaction");
expect(compactionEntries.length).toBe(1);
@@ -139,10 +136,7 @@ describe.skipIf(!process.env.ANTHROPIC_API_KEY && !process.env.ANTHROPIC_OAUTH_T
const cwdSessionDir = path.join(sessionsPath, sessionDirs[0]);
const sessionFiles = fs.readdirSync(cwdSessionDir).filter(f => f.endsWith(".jsonl"));
const sessionContent = await Bun.file(path.join(cwdSessionDir, sessionFiles[0])).text();
const entries = sessionContent
.trim()
.split("\n")
.map(line => JSON.parse(line));
const entries = Bun.JSONL.parse(sessionContent);
const bashMessages = entries.filter(
(e: { type: string; message?: { role: string } }) =>
@@ -11,8 +11,7 @@ describe("truncateToWidth", () => {
it("should truncate messages with Unicode characters correctly", () => {
// This message contains a checkmark (✔) which may have display width > 1 byte
const message = '✔ script to run › dev $ concurrently "vite" "node --import tsx ./';
const width = 67;
const maxMsgWidth = width - 2; // Account for cursor
const maxMsgWidth = Bun.stringWidth(message) - 1;
const truncated = truncateToWidth(message, maxMsgWidth);
const truncatedWidth = visibleWidth(truncated);
@@ -22,8 +21,7 @@ describe("truncateToWidth", () => {
it("should handle emoji characters", () => {
const message = "🎉 Celebration! 🚀 Launch 📦 Package ready for deployment now";
const width = 40;
const maxMsgWidth = width - 2;
const maxMsgWidth = Bun.stringWidth(message) - 2;
const truncated = truncateToWidth(message, maxMsgWidth);
const truncatedWidth = visibleWidth(truncated);
@@ -33,8 +31,7 @@ describe("truncateToWidth", () => {
it("should handle mixed ASCII and wide characters", () => {
const message = "Hello 世界 Test 你好 More text here that is long";
const width = 30;
const maxMsgWidth = width - 2;
const maxMsgWidth = Bun.stringWidth(message) - 2;
const truncated = truncateToWidth(message, maxMsgWidth);
const truncatedWidth = visibleWidth(truncated);
@@ -65,11 +62,11 @@ describe("truncateToWidth", () => {
});
it("should handle the exact crash case from issue report", () => {
// Terminal width was 67, line had visible width 68
// Terminal width is set to one column less than the full line
// The problematic text contained "✔" and "›" characters
const message = '✔ script to run › dev $ concurrently "vite" "node --import tsx ./server.ts"';
const terminalWidth = 67;
const cursorWidth = 2; // "› " or " "
const terminalWidth = Bun.stringWidth(message) + cursorWidth - 1;
const maxMsgWidth = terminalWidth - cursorWidth;
const truncated = truncateToWidth(message, maxMsgWidth);
-5
View File
@@ -6,7 +6,6 @@ import stripAnsi from "strip-ansi";
* Removes characters that crash string-width or cause display issues:
* - Control characters (except tab, newline, carriage return)
* - Lone surrogates
* - Unicode Format characters (crash string-width due to a bug)
* - Characters with undefined code points
*/
export function sanitizeBinaryOutput(str: string): string {
@@ -17,7 +16,6 @@ export function sanitizeBinaryOutput(str: string): string {
.filter(char => {
// Filter out characters that cause string-width to crash
// This includes:
// - Unicode format characters
// - Lone surrogates (already filtered by Array.from)
// - Control chars except \t \n \r
// - Characters with undefined code points
@@ -33,9 +31,6 @@ export function sanitizeBinaryOutput(str: string): string {
// Filter out control characters (0x00-0x1F, except 0x09, 0x0a, 0x0x0d)
if (code <= 0x1f) return false;
// Filter out Unicode format characters
if (code >= 0xfff9 && code <= 0xfffb) return false;
return true;
})
.join("");
+1 -1
View File
@@ -22,6 +22,6 @@
},
"devDependencies": {
"@types/diff": "^7.0.2",
"bun-types": "^1.2.19"
"bun-types": "^1.3.7"
}
}
+9 -21
View File
@@ -17,20 +17,6 @@ function extractFolderFromPath(sessionPath: string): string {
return dir.replace(/^--/, "/").replace(/--/g, "/");
}
/**
* Parse a single JSONL line into a session entry.
*/
function parseLine(line: string): SessionEntry | null {
const trimmed = line.trim();
if (!trimmed) return null;
try {
return JSON.parse(trimmed) as SessionEntry;
} catch {
return null;
}
}
/**
* Check if an entry is an assistant message.
*/
@@ -78,21 +64,24 @@ export async function parseSessionFile(
}
const text = await file.text();
const entries = Bun.JSONL.parse(text) as SessionEntry[];
const lines = text.split("\n");
const folder = extractFolderFromPath(sessionPath);
const stats: MessageStats[] = [];
let currentOffset = 0;
let entryIndex = 0;
for (const line of lines) {
const lineLength = line.length + 1; // +1 for newline
if (currentOffset >= fromOffset && line.trim()) {
const entry = parseLine(line);
if (entry && isAssistantMessage(entry)) {
if (line.trim()) {
const entry = entries[entryIndex];
if (currentOffset >= fromOffset && entry && isAssistantMessage(entry)) {
const msgStats = extractStats(sessionPath, folder, entry);
if (msgStats) {
stats.push(msgStats);
}
}
entryIndex += 1;
}
currentOffset += lineLength;
}
@@ -154,11 +143,10 @@ export async function getSessionEntry(sessionPath: string, entryId: string): Pro
if (!(await file.exists())) return null;
const text = await file.text();
const lines = text.split("\n");
const entries = Bun.JSONL.parse(text) as SessionEntry[];
for (const line of lines) {
const entry = parseLine(line);
if (entry && "id" in entry && entry.id === entryId) {
for (const entry of entries) {
if ("id" in entry && entry.id === entryId) {
return entry;
}
}
+5 -4
View File
@@ -544,7 +544,7 @@ interface Terminal {
```typescript
import { visibleWidth, truncateToWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
// Get visible width of string (ignoring ANSI codes)
// Get visible width of string (ignoring ANSI codes, uses Bun.stringWidth)
const width = visibleWidth("\x1b[31mHello\x1b[0m"); // 5
// Truncate string to width (preserving ANSI codes, adds ellipsis)
@@ -553,7 +553,7 @@ const truncated = truncateToWidth("Hello World", 8); // "Hello..."
// Truncate without ellipsis
const truncatedNoEllipsis = truncateToWidth("Hello World", 8, ""); // "Hello Wo"
// Wrap text to width (preserving ANSI codes across line breaks)
// Wrap text to width (Bun.wrapAnsi word wrap, trims line ends, preserves ANSI)
const lines = wrapTextWithAnsi("This is a long line that needs wrapping", 20);
// ["This is a long line", "that needs wrapping"]
```
@@ -631,10 +631,11 @@ class MyComponent implements Component {
### ANSI Code Considerations
Both `visibleWidth()` and `truncateToWidth()` correctly handle ANSI escape codes:
`visibleWidth()`, `truncateToWidth()`, and `wrapTextWithAnsi()` correctly handle ANSI escape codes:
- `visibleWidth()` ignores ANSI codes when calculating width
- `visibleWidth()` ignores ANSI codes when calculating width (via `Bun.stringWidth`)
- `truncateToWidth()` preserves ANSI codes and properly closes them when truncating
- `wrapTextWithAnsi()` preserves ANSI codes while word-wrapping and trimming line ends
```typescript
import chalk from "chalk";
-1
View File
@@ -49,7 +49,6 @@
"dependencies": {
"@types/mime-types": "^3.0.1",
"chalk": "^5.5.0",
"get-east-asian-width": "^1.3.0",
"marked": "^17.0.1",
"mime-types": "^3.0.1"
},
+5 -305
View File
@@ -1,5 +1,3 @@
import { eastAsianWidth } from "get-east-asian-width";
// Grapheme segmenter (shared instance)
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
@@ -10,70 +8,11 @@ export function getSegmenter(): Intl.Segmenter {
return segmenter;
}
/**
* Check if a grapheme cluster (after segmentation) could possibly be an RGI emoji.
* This is a fast heuristic to avoid the expensive rgiEmojiRegex test.
* The tested Unicode blocks are deliberately broad to account for future
* Unicode additions.
*/
function couldBeEmoji(segment: string): boolean {
const cp = segment.codePointAt(0)!;
return (
(cp >= 0x1f000 && cp <= 0x1fbff) || // Emoji and Pictograph
(cp >= 0x2300 && cp <= 0x23ff) || // Misc technical
(cp >= 0x2600 && cp <= 0x27bf) || // Misc symbols, dingbats
(cp >= 0x2b50 && cp <= 0x2b55) || // Specific stars/circles
segment.includes("\uFE0F") || // Contains VS16 (emoji presentation selector)
segment.length > 2 // Multi-codepoint sequences (ZWJ, skin tones, etc.)
);
}
// Regexes for character classification (same as string-width library)
const zeroWidthRegex = /^(?:\p{Default_Ignorable_Code_Point}|\p{Control}|\p{Mark}|\p{Surrogate})+$/v;
const leadingNonPrintingRegex = /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Mark}\p{Surrogate}]+/v;
const rgiEmojiRegex = /^\p{RGI_Emoji}$/v;
// Cache for non-ASCII strings
const WIDTH_CACHE_SIZE = 512;
const widthCache = new Map<string, number>();
/**
* Calculate the terminal width of a single grapheme cluster.
* Based on code from the string-width library, but includes a possible-emoji
* check to avoid running the RGI_Emoji regex unnecessarily.
*/
function graphemeWidth(segment: string): number {
// Zero-width clusters
if (zeroWidthRegex.test(segment)) {
return 0;
}
// Emoji check with pre-filter
if (couldBeEmoji(segment) && rgiEmojiRegex.test(segment)) {
return 2;
}
// Get base visible codepoint
const base = segment.replace(leadingNonPrintingRegex, "");
const cp = base.codePointAt(0);
if (cp === undefined) {
return 0;
}
let width = eastAsianWidth(cp);
// Trailing halfwidth/fullwidth forms
if (segment.length > 1) {
for (const char of segment.slice(1)) {
const c = char.codePointAt(0)!;
if (c >= 0xff00 && c <= 0xffef) {
width += eastAsianWidth(c);
}
}
}
return width;
}
/**
* Calculate the visible width of a string in terminal columns.
@@ -114,11 +53,8 @@ export function visibleWidth(str: string): number {
clean = clean.replace(/\x1b\]8;;[^\x07]*\x07/g, "");
}
// Calculate width
let width = 0;
for (const { segment } of segmenter.segment(clean)) {
width += graphemeWidth(segment);
}
const width = Bun.stringWidth(clean);
// Cache result
if (widthCache.size >= WIDTH_CACHE_SIZE) {
@@ -370,69 +306,7 @@ class AnsiCodeTracker {
}
}
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.
*/
function splitIntoTokensWithAnsi(text: string): string[] {
const tokens: string[] = [];
let current = "";
let pendingAnsi = ""; // ANSI codes waiting to be attached to next visible content
let inWhitespace = false;
let i = 0;
while (i < text.length) {
const ansiResult = extractAnsiCode(text, i);
if (ansiResult) {
// Hold ANSI codes separately - they'll be attached to the next visible char
pendingAnsi += ansiResult.code;
i += ansiResult.length;
continue;
}
const char = text[i];
const charIsSpace = char === " ";
if (charIsSpace !== inWhitespace && current) {
// Switching between whitespace and non-whitespace, push current token
tokens.push(current);
current = "";
}
// Attach any pending ANSI codes to this visible character
if (pendingAnsi) {
current += pendingAnsi;
pendingAnsi = "";
}
inWhitespace = charIsSpace;
current += char;
i++;
}
// Handle any remaining pending ANSI codes (attach to last token)
if (pendingAnsi) {
current += pendingAnsi;
}
if (current) {
tokens.push(current);
}
return tokens;
}
/**
* Wrap text with ANSI codes preserved.
@@ -450,112 +324,7 @@ export function wrapTextWithAnsi(text: string, width: number): string[] {
return [""];
}
// Handle newlines by processing each line separately
// Track ANSI state across lines so styles carry over after literal newlines
const inputLines = text.split("\n");
const result: string[] = [];
const tracker = new AnsiCodeTracker();
for (const inputLine of inputLines) {
// Prepend active ANSI codes from previous lines (except for first line)
const prefix = result.length > 0 ? tracker.getActiveCodes() : "";
result.push(...wrapSingleLine(prefix + inputLine, width));
// Update tracker with codes from this line for next iteration
updateTrackerFromText(inputLine, tracker);
}
return result.length > 0 ? result : [""];
}
function wrapSingleLine(line: string, width: number): string[] {
if (!line) {
return [""];
}
const visibleLength = visibleWidth(line);
if (visibleLength <= width) {
return [line];
}
const wrapped: string[] = [];
const tracker = new AnsiCodeTracker();
const tokens = splitIntoTokensWithAnsi(line);
let currentLine = "";
let currentVisibleLength = 0;
for (const token of tokens) {
const tokenVisibleLength = visibleWidth(token);
const isWhitespace = token.trim() === "";
// Token itself is too long - break it character by character
// For whitespace tokens exceeding width, truncate to width instead of breaking
if (tokenVisibleLength > width && isWhitespace) {
// Truncate long whitespace to fit width
const truncated = token.substring(0, width - currentVisibleLength);
if (truncated) {
currentLine += truncated;
currentVisibleLength += visibleWidth(truncated);
}
updateTrackerFromText(token, tracker);
continue;
}
if (tokenVisibleLength > width && !isWhitespace) {
if (currentLine) {
// Add specific reset for underline only (preserves background)
const lineEndReset = tracker.getLineEndReset();
if (lineEndReset) {
currentLine += lineEndReset;
}
wrapped.push(currentLine);
currentLine = "";
currentVisibleLength = 0;
}
// Break long token - breakLongWord handles its own resets
const broken = breakLongWord(token, width, tracker);
wrapped.push(...broken.slice(0, -1));
currentLine = broken[broken.length - 1];
currentVisibleLength = visibleWidth(currentLine);
continue;
}
// Check if adding this token would exceed width
const totalNeeded = currentVisibleLength + tokenVisibleLength;
if (totalNeeded > width && currentVisibleLength > 0) {
// Trim trailing whitespace, then add underline reset (not full reset, to preserve background)
let lineToWrap = currentLine.trimEnd();
const lineEndReset = tracker.getLineEndReset();
if (lineEndReset) {
lineToWrap += lineEndReset;
}
wrapped.push(lineToWrap);
if (isWhitespace) {
// Don't start new line with whitespace
currentLine = tracker.getActiveCodes();
currentVisibleLength = 0;
} else {
currentLine = tracker.getActiveCodes() + token;
currentVisibleLength = tokenVisibleLength;
}
} else {
// Add to current line
currentLine += token;
currentVisibleLength += tokenVisibleLength;
}
updateTrackerFromText(token, tracker);
}
if (currentLine) {
// No reset at end of final line - let caller handle it
wrapped.push(currentLine);
}
// Trailing whitespace can cause lines to exceed the requested width
return wrapped.length > 0 ? wrapped.map(line => line.trimEnd()) : [""];
return Bun.wrapAnsi(text, width, { wordWrap: true, hard: true, trim: false }).split("\n");
}
const PUNCTUATION_REGEX = /[(){}[\]<>.,;:'"!?+\-=*/\\|&%^$#@~`]/;
@@ -574,75 +343,6 @@ export function isPunctuationChar(char: string): boolean {
return PUNCTUATION_REGEX.test(char);
}
function breakLongWord(word: string, width: number, tracker: AnsiCodeTracker): string[] {
const lines: string[] = [];
let currentLine = tracker.getActiveCodes();
let currentWidth = 0;
// First, separate ANSI codes from visible content
// We need to handle ANSI codes specially since they're not graphemes
let i = 0;
const segments: Array<{ type: "ansi" | "grapheme"; value: string }> = [];
while (i < word.length) {
const ansiResult = extractAnsiCode(word, i);
if (ansiResult) {
segments.push({ type: "ansi", value: ansiResult.code });
i += ansiResult.length;
} else {
// Find the next ANSI code or end of string
let end = i;
while (end < word.length) {
const nextAnsi = extractAnsiCode(word, end);
if (nextAnsi) break;
end++;
}
// Segment this non-ANSI portion into graphemes
const textPortion = word.slice(i, end);
for (const seg of segmenter.segment(textPortion)) {
segments.push({ type: "grapheme", value: seg.segment });
}
i = end;
}
}
// Now process segments
for (const seg of segments) {
if (seg.type === "ansi") {
currentLine += seg.value;
tracker.process(seg.value);
continue;
}
const grapheme = seg.value;
// Skip empty graphemes to avoid issues with string-width calculation
if (!grapheme) continue;
const graphemeWidth = visibleWidth(grapheme);
if (currentWidth + graphemeWidth > width) {
// Add specific reset for underline only (preserves background)
const lineEndReset = tracker.getLineEndReset();
if (lineEndReset) {
currentLine += lineEndReset;
}
lines.push(currentLine);
currentLine = tracker.getActiveCodes();
currentWidth = 0;
}
currentLine += grapheme;
currentWidth += graphemeWidth;
}
if (currentLine) {
// No reset at end of final segment - caller handles continuation
lines.push(currentLine);
}
return lines.length > 0 ? lines : [""];
}
/**
* Apply background color to a line, padding to full width.
*
@@ -782,7 +482,7 @@ export function sliceWithWidth(
while (textEnd < line.length && !extractAnsiCode(line, textEnd)) textEnd++;
for (const { segment } of segmenter.segment(line.slice(i, textEnd))) {
const w = graphemeWidth(segment);
const w = visibleWidth(segment);
const inRange = currentCol >= startCol && currentCol < endCol;
const fits = !strict || currentCol + w <= endCol;
if (inRange && fits) {
@@ -850,7 +550,7 @@ export function extractSegments(
while (textEnd < line.length && !extractAnsiCode(line, textEnd)) textEnd++;
for (const { segment } of segmenter.segment(line.slice(i, textEnd))) {
const w = graphemeWidth(segment);
const w = visibleWidth(segment);
if (currentCol < beforeEnd) {
if (pendingAnsiBefore) {
+5 -5
View File
@@ -487,7 +487,7 @@ describe("Editor component", () => {
// All content lines (between borders) should fit within width
for (let i = 1; i < lines.length - 1; i++) {
const lineWidth = visibleWidth(lines[i]!);
expect(lineWidth).toBe(width);
expect(lineWidth).toBeLessThanOrEqual(width);
}
});
@@ -504,7 +504,7 @@ describe("Editor component", () => {
// First line: 5 emojis (10 cols), second line: 1 emoji (2 cols) + padding
for (let i = 1; i < lines.length - 1; i++) {
const lineWidth = visibleWidth(lines[i]!);
expect(lineWidth).toBe(width);
expect(lineWidth).toBeLessThanOrEqual(width);
}
});
@@ -519,7 +519,7 @@ describe("Editor component", () => {
// All content lines (including last which has bottom border) should be correct width
for (let i = 1; i < lines.length; i++) {
const lineWidth = visibleWidth(lines[i]!);
expect(lineWidth).toBe(width);
expect(lineWidth).toBeLessThanOrEqual(width);
}
// Verify content split correctly - extract content between borders
@@ -548,7 +548,7 @@ describe("Editor component", () => {
expect(contentLines.length).toBe(1);
const lineWidth = visibleWidth(contentLines[0]!);
expect(lineWidth).toBe(width);
expect(lineWidth).toBeLessThanOrEqual(width);
});
it("renders cursor correctly on wide characters", () => {
@@ -564,7 +564,7 @@ describe("Editor component", () => {
expect(contentLine.includes("\x1b[5m")).toBeTruthy();
// Line should still be correct width
expect(visibleWidth(contentLine)).toBe(width);
expect(visibleWidth(contentLine)).toBeLessThanOrEqual(width);
});
it("shows cursor at end before wrap and wraps on next char", () => {
+21 -12
View File
@@ -11,22 +11,30 @@ describe("wrapTextWithAnsi", () => {
const wrapped = wrapTextWithAnsi(text, 40);
// First line should NOT contain underline code - it's just "read this thread"
expect(wrapped[0]).toBe("read this thread");
// Second line should start with underline, have URL content
const prefix = "read this thread ";
expect(wrapped[0].startsWith(prefix)).toBe(true);
const underlineIndex = wrapped[0].indexOf(underlineOn);
if (underlineIndex !== -1) {
expect(underlineIndex).toBeGreaterThanOrEqual(prefix.length);
expect(wrapped[0].endsWith(underlineOff)).toBe(true);
}
// Second line should start with underline
expect(wrapped[1].startsWith(underlineOn)).toBe(true);
expect(wrapped[1].includes("https://")).toBe(true);
const plain = wrapped.join("").replace(/\x1b\[[0-9;]*m/g, "");
expect(plain.includes(url)).toBe(true);
});
it("should not have whitespace before underline reset code", () => {
it("should preserve whitespace before underline reset code", () => {
const underlineOn = "\x1b[4m";
const underlineOff = "\x1b[24m";
const textWithUnderlinedTrailingSpace = `${underlineOn}underlined text here ${underlineOff}more`;
const wrapped = wrapTextWithAnsi(textWithUnderlinedTrailingSpace, 18);
expect(wrapped[0].includes(` ${underlineOff}`)).toBe(false);
expect(wrapped[1].includes(` ${underlineOff}`)).toBe(true);
});
it("should not bleed underline to padding - each line should end with reset for underline only", () => {
@@ -69,7 +77,7 @@ describe("wrapTextWithAnsi", () => {
}
});
it("should reset underline but preserve background when wrapping underlined text inside background", () => {
it("should reset underline without preserving background after wrap", () => {
const underlineOn = "\x1b[4m";
const underlineOff = "\x1b[24m";
const reset = "\x1b[0m";
@@ -78,11 +86,12 @@ describe("wrapTextWithAnsi", () => {
const wrapped = wrapTextWithAnsi(text, 20);
// All lines should have background color 41 (either as \x1b[41m or combined like \x1b[4;41m)
for (const line of wrapped) {
const hasBgColor = line.includes("[41m") || line.includes(";41m") || line.includes("[41;");
expect(hasBgColor).toBeTruthy();
}
const lineHasBg = (line: string) =>
line.includes("[41m") || line.includes(";41m") || line.includes("[41;");
expect(lineHasBg(wrapped[0])).toBeTruthy();
expect(lineHasBg(wrapped[1])).toBeFalsy();
expect(lineHasBg(wrapped[2])).toBeFalsy();
// Lines with underlined content should use underline-off at end, not full reset
for (let i = 0; i < wrapped.length - 1; i++) {
+1 -2
View File
@@ -73,8 +73,7 @@ function classifyError(resultText: string): string {
async function extractEditAttempts(sessionPath: string): Promise<EditAttempt[]> {
const content = await Bun.file(sessionPath).text();
const lines = content.trim().split("\n");
const messages: Message[] = lines.map((line) => JSON.parse(line));
const messages = Bun.JSONL.parse(content) as Message[];
const editAttempts: EditAttempt[] = [];