diff --git a/.changeset/stable-native-lib-cache.md b/.changeset/stable-native-lib-cache.md new file mode 100644 index 00000000..3e8f3654 --- /dev/null +++ b/.changeset/stable-native-lib-cache.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Stop leaking one extracted native library per launch from compiled binaries. Bun single-file executables extract their embedded OpenTUI library to the OS temp directory under a fresh random name on every run and never remove it (oven-sh/bun#30962, #556), which could fill a tmpfs under repeated invocation (git pager loops, agent polling). The compiled binary now writes the embedded library once to a stable content-addressed path under the user cache directory (`~/.cache/hunk/native` on Linux, `~/Library/Caches/hunk/native` on macOS, `%LOCALAPPDATA%\hunk\native` on Windows) and reuses it across runs. If the cache is unavailable, hunk falls back to the previous behavior rather than failing to start. diff --git a/.changeset/wrap-agent-notes-by-cells.md b/.changeset/wrap-agent-notes-by-cells.md new file mode 100644 index 00000000..943b41b6 --- /dev/null +++ b/.changeset/wrap-agent-notes-by-cells.md @@ -0,0 +1,8 @@ +--- +"hunkdiff": patch +--- + +Wrap plain-text agent notes by terminal cells instead of UTF-16 code +units, so CJK and emoji text wraps correctly instead of being truncated +with silent content loss. Long unbroken words split on grapheme +boundaries, so wide characters and surrogate pairs are never cut apart. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c37e209b..b3cd37e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -207,6 +207,20 @@ jobs: - name: Build binary run: bun run build:bin + # Tripwire for the native-lib leak (oven-sh/bun#30962): if the build + # plugin ever stops matching OpenTUI's platform packages, the binary + # silently goes back to leaking one extracted library per launch. + - name: Verify binary leaves no native-lib artifacts in tmp + run: | + TMP_TEST="$(mktemp -d)" + TMPDIR="$TMP_TEST" ./dist/hunk --help > /dev/null + TMPDIR="$TMP_TEST" ./dist/hunk --help > /dev/null + if [ -n "$(find "$TMP_TEST" -mindepth 1 -print -quit)" ]; then + echo "Binary leaked artifacts into TMPDIR:" + ls -la "$TMP_TEST" + exit 1 + fi + - name: Verify compiled binary watch mode run: bun test ./test/pty/watch.test.ts env: diff --git a/scripts/build-bin.ts b/scripts/build-bin.ts index 2d26ca7f..c6dada1a 100644 --- a/scripts/build-bin.ts +++ b/scripts/build-bin.ts @@ -2,6 +2,7 @@ import { mkdirSync, rmSync } from "node:fs"; import path from "node:path"; +import { createOpentuiStableNativeLibPlugin } from "./opentuiStableNativeLibPlugin"; const repoRoot = path.resolve(import.meta.dir, ".."); const distDir = path.join(repoRoot, "dist"); @@ -12,31 +13,27 @@ const legacyOutfile = path.join(distDir, process.platform === "win32" ? "otdiff. mkdirSync(distDir, { recursive: true }); rmSync(legacyOutfile, { force: true }); -const proc = Bun.spawnSync( - [ - "bun", - "build", - "--compile", - "--no-compile-autoload-bunfig", - path.join(repoRoot, "src", "main.tsx"), - "--outfile", +// Keep the build's own temp usage repo-local, as the previous CLI spawn did +// via env. +process.env.BUN_TMPDIR = path.join(repoRoot, ".bun-tmp"); +process.env.BUN_INSTALL = path.join(repoRoot, ".bun-install"); + +const result = await Bun.build({ + entrypoints: [path.join(repoRoot, "src", "main.tsx")], + target: "bun", + compile: { outfile, - ], - { - cwd: repoRoot, - stdin: "inherit", - stdout: "inherit", - stderr: "inherit", - env: { - ...process.env, - BUN_TMPDIR: path.join(repoRoot, ".bun-tmp"), - BUN_INSTALL: path.join(repoRoot, ".bun-install"), - }, + // Matches the previous `--no-compile-autoload-bunfig`. + autoloadBunfig: false, }, -); + plugins: [createOpentuiStableNativeLibPlugin(repoRoot)], +}); -if (proc.exitCode !== 0) { - throw new Error(`bun build --compile failed with exit ${proc.exitCode}`); +if (!result.success) { + for (const log of result.logs) { + console.error(log); + } + throw new Error("bun build --compile failed; see logs above"); } console.log(`Built ${outfile}`); diff --git a/scripts/opentuiStableNativeLibPlugin.ts b/scripts/opentuiStableNativeLibPlugin.ts new file mode 100644 index 00000000..f027e1da --- /dev/null +++ b/scripts/opentuiStableNativeLibPlugin.ts @@ -0,0 +1,59 @@ +// Build plugin that routes OpenTUI's embedded native library through hunk's +// stable cache path instead of Bun's leaking tmp extraction. +// +// Each @opentui/core- package's default export is just a path +// string that @opentui/core dlopens; under the "bun" export condition it +// comes from `import "./libopentui.so" with { type: "file" }`, which in a +// compiled binary resolves to Bun's per-launch temp extraction +// (oven-sh/bun#30962, hunk #556). This plugin intercepts those platform +// package imports and substitutes a shim whose default export is the stable +// content-addressed cache path produced by src/core/nativeLibMaterialize.ts. +// Reading the embedded bytes never extracts; only dlopen does, and dlopen now +// targets the cache path. Deletable together with that module once Bun's +// extraction dedupe (oven-sh/bun#29587) ships. + +import { readdirSync } from "node:fs"; +import path from "node:path"; + +const PLATFORM_PACKAGE_FILTER = /^@opentui\/core-/; +const NATIVE_LIB_FILE_PATTERN = /\.(?:so|dylib|dll)$/; +const SHIM_NAMESPACE = "hunk-opentui-native-shim"; + +/** Create the Bun build plugin that substitutes stable-path shims for OpenTUI platform packages. */ +export function createOpentuiStableNativeLibPlugin(repoRoot: string): Bun.BunPlugin { + return { + name: "hunk-opentui-stable-native-lib", + setup(build) { + build.onResolve({ filter: PLATFORM_PACKAGE_FILTER }, (args) => { + // Resolve from the importing module: the platform packages are + // optional deps of @opentui/core and are not visible from the repo + // root under Bun's isolated node_modules layout. + const entryPath = Bun.resolveSync(args.path, path.dirname(args.importer)); + return { path: entryPath, namespace: SHIM_NAMESPACE }; + }); + build.onLoad({ filter: /.*/, namespace: SHIM_NAMESPACE }, (args) => { + const pkgDir = path.dirname(args.path); + // Exactly one native library per platform package is the contract this + // shim relies on; ambiguity must fail the build loudly rather than + // silently dlopen the wrong file. + const libFiles = readdirSync(pkgDir).filter((name) => NATIVE_LIB_FILE_PATTERN.test(name)); + if (libFiles.length !== 1) { + throw new Error( + `Expected exactly one native library (.so/.dylib/.dll) in ${pkgDir}, found ${libFiles.length}: ${libFiles.join(", ")}`, + ); + } + const libFile = libFiles[0]; + const helperPath = path.join(repoRoot, "src", "core", "nativeLibMaterialize.ts"); + return { + resolveDir: pkgDir, + loader: "ts", + contents: [ + `import embedded from ${JSON.stringify(`./${libFile}`)} with { type: "file" };`, + `import { materializeStableNativeLibPath } from ${JSON.stringify(helperPath)};`, + `export default await materializeStableNativeLibPath(embedded, ${JSON.stringify(libFile)});`, + ].join("\n"), + }; + }); + }, + }; +} diff --git a/src/core/nativeLibMaterialize.test.ts b/src/core/nativeLibMaterialize.test.ts new file mode 100644 index 00000000..9b08e7dd --- /dev/null +++ b/src/core/nativeLibMaterialize.test.ts @@ -0,0 +1,239 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + utimesSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { materializeStableNativeLibPath, resolveNativeCacheRoot } from "./nativeLibMaterialize"; + +const LIB_BYTES = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); +const OTHER_VERSION_BYTES = new Uint8Array([9, 9, 9]); +const HOUR_MS = 60 * 60 * 1000; + +let dirsToClean: string[] = []; + +afterEach(() => { + for (const dir of dirsToClean) { + rmSync(dir, { recursive: true, force: true }); + } + dirsToClean = []; +}); + +/** Create a throwaway cache root that is always cleaned up. */ +function fakeCacheRoot(): string { + const dir = mkdtempSync(join(tmpdir(), "hunk-native-lib-test-")); + dirsToClean.push(dir); + return dir; +} + +/** Expected content-addressed file name for the given bytes. */ +function expectedName(bytes: Uint8Array, libFileName = "libopentui.so"): string { + const hash = createHash("sha256").update(bytes).digest("hex").slice(0, 16); + const dot = libFileName.lastIndexOf("."); + return `${libFileName.slice(0, dot)}-${hash}${libFileName.slice(dot)}`; +} + +/** Deps pointing at a fake cache root with a fixed embedded payload. */ +function testDeps(cacheRoot: string, bytes: Uint8Array = LIB_BYTES, now?: number) { + return { + cacheRootImpl: () => cacheRoot, + readEmbedded: async () => bytes, + ...(now === undefined ? {} : { now: () => now }), + }; +} + +describe("resolveNativeCacheRoot", () => { + test("prefers XDG_CACHE_HOME on Linux", () => { + expect( + resolveNativeCacheRoot( + { XDG_CACHE_HOME: "/xdg" }, + () => "linux", + () => "/home/u", + ), + ).toBe("/xdg"); + }); + + test("falls back to ~/.cache on Linux", () => { + expect( + resolveNativeCacheRoot( + {}, + () => "linux", + () => "/home/u", + ), + ).toBe(join("/home/u", ".cache")); + }); + + test("uses ~/Library/Caches on macOS", () => { + expect( + resolveNativeCacheRoot( + {}, + () => "darwin", + () => "/Users/u", + ), + ).toBe(join("/Users/u", "Library", "Caches")); + }); + + test("prefers LOCALAPPDATA on Windows", () => { + expect( + resolveNativeCacheRoot( + { LOCALAPPDATA: "C:\\Users\\u\\AppData\\Local" }, + () => "win32", + () => "C:\\Users\\u", + ), + ).toBe("C:\\Users\\u\\AppData\\Local"); + }); + + test("falls back to ~/AppData/Local on Windows", () => { + expect( + resolveNativeCacheRoot( + {}, + () => "win32", + () => "C:\\Users\\u", + ), + ).toBe(join("C:\\Users\\u", "AppData", "Local")); + }); +}); + +describe("materializeStableNativeLibPath", () => { + test("writes the library to the content-addressed path and returns it", async () => { + const cacheRoot = fakeCacheRoot(); + + const result = await materializeStableNativeLibPath( + "/$bunfs/root/libopentui.so", + "libopentui.so", + testDeps(cacheRoot), + ); + + const expected = join(cacheRoot, "hunk", "native", expectedName(LIB_BYTES)); + expect(result).toBe(expected); + expect(readFileSync(result)).toEqual(Buffer.from(LIB_BYTES)); + // The exec bit only exists on POSIX filesystems. + if (process.platform !== "win32") { + expect(statSync(result).mode & 0o111).not.toBe(0); + } + }); + + test("reuses the existing file without rewriting it", async () => { + const cacheRoot = fakeCacheRoot(); + const deps = testDeps(cacheRoot); + + const first = await materializeStableNativeLibPath("/embedded", "libopentui.so", deps); + const firstMtime = statSync(first).mtimeMs; + const second = await materializeStableNativeLibPath("/embedded", "libopentui.so", deps); + + expect(second).toBe(first); + expect(statSync(second).mtimeMs).toBe(firstMtime); + expect(readdirSync(join(cacheRoot, "hunk", "native"))).toHaveLength(1); + }); + + test("keeps distinct library versions side by side", async () => { + const cacheRoot = fakeCacheRoot(); + const now = 10 * HOUR_MS; + + const first = await materializeStableNativeLibPath( + "/embedded", + "libopentui.so", + testDeps(cacheRoot, LIB_BYTES, now), + ); + // Fresh files are never pruned, so a same-run version change leaves both. + const second = await materializeStableNativeLibPath( + "/embedded", + "libopentui.so", + testDeps(cacheRoot, OTHER_VERSION_BYTES, now), + ); + + expect(second).not.toBe(first); + expect(existsSync(first)).toBe(true); + expect(existsSync(second)).toBe(true); + }); + + test("prunes stale superseded versions once they are old", async () => { + const cacheRoot = fakeCacheRoot(); + const now = 10 * HOUR_MS; + + const old = await materializeStableNativeLibPath( + "/embedded", + "libopentui.so", + testDeps(cacheRoot, LIB_BYTES, now), + ); + // Age the old version beyond the prune guard, then materialize a new one. + const oldTime = (now - 2 * HOUR_MS) / 1000; + utimesSync(old, oldTime, oldTime); + + const current = await materializeStableNativeLibPath( + "/embedded", + "libopentui.so", + testDeps(cacheRoot, OTHER_VERSION_BYTES, now), + ); + + expect(existsSync(old)).toBe(false); + expect(existsSync(current)).toBe(true); + }); + + test("falls back to the embedded path when the cache root is not writable", async () => { + const cacheRoot = fakeCacheRoot(); + // A regular file where the cache dir must be created makes mkdir fail. + const blockingFile = join(cacheRoot, "hunk"); + writeFileSync(blockingFile, "occupied"); + + const result = await materializeStableNativeLibPath( + "/$bunfs/root/libopentui.so", + "libopentui.so", + testDeps(cacheRoot), + ); + + expect(result).toBe("/$bunfs/root/libopentui.so"); + }); + + test("falls back to the embedded path when the embedded bytes cannot be read", async () => { + const cacheRoot = fakeCacheRoot(); + + const result = await materializeStableNativeLibPath("/missing", "libopentui.so", { + cacheRootImpl: () => cacheRoot, + readEmbedded: async () => { + throw new Error("no such embedded file"); + }, + }); + + expect(result).toBe("/missing"); + }); + + test("preserves the library file extension in the cache name", async () => { + const cacheRoot = fakeCacheRoot(); + + const result = await materializeStableNativeLibPath( + "/embedded", + "opentui.dll", + testDeps(cacheRoot), + ); + + expect(result.endsWith(".dll")).toBe(true); + expect(result).toContain("opentui-"); + }); + + test("leaves unrelated files in the cache dir alone", async () => { + const cacheRoot = fakeCacheRoot(); + const nativeDir = join(cacheRoot, "hunk", "native"); + mkdirSync(nativeDir, { recursive: true }); + const unrelated = join(nativeDir, "README.txt"); + writeFileSync(unrelated, "not a library"); + const otherLib = join(nativeDir, "unrelated-deadbeef.so"); + writeFileSync(otherLib, "x"); + const longAgo = (Date.now() - 2 * HOUR_MS) / 1000; + utimesSync(otherLib, longAgo, longAgo); + + await materializeStableNativeLibPath("/embedded", "libopentui.so", testDeps(cacheRoot)); + + expect(existsSync(unrelated)).toBe(true); + expect(existsSync(otherLib)).toBe(true); + }); +}); diff --git a/src/core/nativeLibMaterialize.ts b/src/core/nativeLibMaterialize.ts new file mode 100644 index 00000000..187938a6 --- /dev/null +++ b/src/core/nativeLibMaterialize.ts @@ -0,0 +1,142 @@ +// Stable on-disk home for the OpenTUI native library in compiled binaries. +// +// Bun single-file executables extract an embedded native library to the OS +// temp directory under a fresh random name on every dlopen and never reuse or +// remove it (oven-sh/bun#30962), so repeated invocations leak one library copy +// per launch (#556). The compiled build wires this module in place of each +// OpenTUI platform package's default path export (see +// scripts/opentuiStableNativeLibPlugin.ts): the embedded library bytes are +// written once to a content-addressed path under the user cache directory and +// that stable path is what OpenTUI dlopens. One file per library version, +// reused across runs and processes, in a directory hunk owns. +// +// Any failure falls back to the original embedded path, i.e. the previous +// leaking-but-working behavior, so a read-only cache or exotic mount can never +// break startup. Deletable once Bun's own extraction dedupe +// (oven-sh/bun#29587) ships in a hunk release. + +import { createHash } from "node:crypto"; +import { + existsSync, + mkdirSync, + readdirSync, + renameSync, + statSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { homedir, platform } from "node:os"; +import { join } from "node:path"; + +const HASH_HEX_LENGTH = 16; +// Guard against pruning a sibling version that a just-started (older) hunk +// process wrote but has not dlopened yet; that window is milliseconds. +const PRUNE_MIN_AGE_MS = 60 * 60 * 1000; + +export interface NativeLibMaterializeDeps { + cacheRootImpl?: () => string; + now?: () => number; + readEmbedded?: (path: string) => Promise; +} + +/** + * Conventional per-OS cache root for hunk's native library cache: XDG on + * Linux, ~/Library/Caches on macOS, %LOCALAPPDATA% on Windows. + */ +export function resolveNativeCacheRoot( + env: NodeJS.ProcessEnv = process.env, + platformImpl: () => NodeJS.Platform = () => platform(), + homedirImpl: () => string = homedir, +): string { + const os = platformImpl(); + if (os === "win32") { + return env.LOCALAPPDATA ?? join(homedirImpl(), "AppData", "Local"); + } + if (os === "darwin") { + return join(homedirImpl(), "Library", "Caches"); + } + return env.XDG_CACHE_HOME ?? join(homedirImpl(), ".cache"); +} + +/** Split `libopentui.so` into ["libopentui", ".so"] for content-addressed naming. */ +function splitLibFileName(libFileName: string): { base: string; ext: string } { + const dot = libFileName.lastIndexOf("."); + if (dot <= 0) { + return { base: libFileName, ext: "" }; + } + return { base: libFileName.slice(0, dot), ext: libFileName.slice(dot) }; +} + +/** Best-effort removal of superseded library versions in the cache dir. */ +function pruneStaleVersions( + dir: string, + base: string, + ext: string, + keepName: string, + nowMs: number, +): void { + for (const entry of readdirSync(dir)) { + if (entry === keepName || !entry.startsWith(`${base}-`) || !entry.endsWith(ext)) { + continue; + } + try { + const fullPath = join(dir, entry); + if (nowMs - statSync(fullPath).mtimeMs < PRUNE_MIN_AGE_MS) { + continue; + } + // On Windows unlinking a dll a running process still has mapped fails; + // that and every other error just leaves the file in place. + unlinkSync(fullPath); + } catch { + // Best-effort: leave anything that cannot be removed. + } + } +} + +/** + * Copy the embedded native library to its stable content-addressed cache path + * and return that path, or return `embeddedPath` unchanged on any failure. + * + * The write is tmp-file-plus-rename so concurrent first runs of several hunk + * processes can never observe a truncated library. Once one process has + * materialized a version, every later process reuses it without a write. + */ +export async function materializeStableNativeLibPath( + embeddedPath: string, + libFileName: string, + deps: NativeLibMaterializeDeps = {}, +): Promise { + const readEmbedded = + deps.readEmbedded ?? + ((path: string) => + Bun.file(path) + .arrayBuffer() + .then((buf) => new Uint8Array(buf))); + const cacheRootImpl = deps.cacheRootImpl ?? (() => resolveNativeCacheRoot()); + const now = deps.now ?? Date.now; + + try { + const bytes = await readEmbedded(embeddedPath); + const hash = createHash("sha256").update(bytes).digest("hex").slice(0, HASH_HEX_LENGTH); + const { base, ext } = splitLibFileName(libFileName); + const dir = join(cacheRootImpl(), "hunk", "native"); + const destName = `${base}-${hash}${ext}`; + const dest = join(dir, destName); + + if (!existsSync(dest) || statSync(dest).size !== bytes.byteLength) { + mkdirSync(dir, { recursive: true }); + const tmpPath = join( + dir, + `.${base}-${process.pid}-${Math.random().toString(16).slice(2)}.tmp`, + ); + writeFileSync(tmpPath, bytes, { mode: 0o755 }); + renameSync(tmpPath, dest); + } + + pruneStaleVersions(dir, base, ext, destName, now()); + return dest; + } catch { + // Leaking via Bun's extraction is strictly better than failing to start. + return embeddedPath; + } +} diff --git a/src/ui/lib/agentPopover.ts b/src/ui/lib/agentPopover.ts index 123e4515..06c2a7fb 100644 --- a/src/ui/lib/agentPopover.ts +++ b/src/ui/lib/agentPopover.ts @@ -1,11 +1,11 @@ import { sanitizeTerminalLine } from "../../lib/terminalText"; -import { fitText } from "./text"; +import { fitText, measureTextWidth, sliceTextByWidth } from "./text"; function clamp(value: number, min: number, max: number) { return Math.min(Math.max(value, min), max); } -/** Wrap plain text to a fixed terminal width, breaking long tokens when needed. */ +/** Wrap plain text to a fixed terminal-cell width, breaking long tokens when needed. */ export function wrapText(text: string, width: number) { if (width <= 0) { return [""]; @@ -19,31 +19,49 @@ export function wrapText(text: string, width: number) { const words = normalized.split(" "); const lines: string[] = []; let current = ""; + let currentWidth = 0; const pushCurrent = () => { if (current.length > 0) { lines.push(current); current = ""; + currentWidth = 0; } }; for (const word of words) { - if (word.length > width) { + const wordWidth = measureTextWidth(word); + + if (wordWidth > width) { pushCurrent(); - for (let offset = 0; offset < word.length; offset += width) { - lines.push(word.slice(offset, offset + width)); + let offset = 0; + while (offset < wordWidth) { + const chunk = sliceTextByWidth(word, offset, width); + if (chunk.width <= 0) { + // Width is narrower than one cluster; keep the remainder on one + // line (fitText clamps at render time) instead of dropping it. + const rest = sliceTextByWidth(word, offset, Number.MAX_SAFE_INTEGER); + if (rest.text.length > 0) { + lines.push(rest.text); + } + break; + } + lines.push(chunk.text); + offset += chunk.width; } continue; } - const next = current.length === 0 ? word : `${current} ${word}`; - if (next.length <= width) { - current = next; + const nextWidth = current.length === 0 ? wordWidth : currentWidth + 1 + wordWidth; + if (nextWidth <= width) { + current = current.length === 0 ? word : `${current} ${word}`; + currentWidth = nextWidth; continue; } pushCurrent(); current = word; + currentWidth = wordWidth; } pushCurrent(); diff --git a/src/ui/lib/ui-lib.test.ts b/src/ui/lib/ui-lib.test.ts index 5ea6261c..4a67a4c9 100644 --- a/src/ui/lib/ui-lib.test.ts +++ b/src/ui/lib/ui-lib.test.ts @@ -321,6 +321,38 @@ describe("ui helpers", () => { expect(wrapText("alpha beta gamma", 8)).toEqual(["alpha", "beta", "gamma"]); expect(wrapText("supercalifragilistic", 6)).toEqual(["superc", "alifra", "gilist", "ic"]); + // Wide text wraps by terminal cells, not UTF-16 code units, so CJK lines + // never overflow the box and get clipped by fitText/padText downstream. + expect(wrapText("こんにちは世界", 8)).toEqual(["こんにち", "は世界"]); + expect(wrapText("これは全角文字の長い注釈です", 10)).toEqual([ + "これは全角", + "文字の長い", + "注釈です", + ]); + expect(wrapText("fix 説明が長い日本語のまま続く", 10)).toEqual([ + "fix", + "説明が長い", + "日本語のま", + "ま続く", + ]); + + // Emoji clusters (surrogate pairs) are never split into lone surrogates. + expect(wrapText("🎉🎉🎉", 4)).toEqual(["🎉🎉", "🎉"]); + + // Odd width: a 2-cell character cannot straddle the boundary, so each + // line carries one character even though a cell stays unused. + expect(wrapText("日本語", 3)).toEqual(["日", "本", "語"]); + + // Multiple ASCII words still pack into one line when they fit. + expect(wrapText("ab cd", 5)).toEqual(["ab cd"]); + + // Width narrower than one cluster keeps the text for fitText to clamp + // at render time instead of silently dropping it. + expect(wrapText("日日", 1)).toEqual(["日日"]); + + // ZWJ emoji clusters stay whole when hard-splitting. + expect(wrapText("🧑‍💻🧑‍💻", 2)).toEqual(["🧑‍💻", "🧑‍💻"]); + const content = buildAgentPopoverContent({ summary: "Guard missing socket path", rationale: "Prevents noisy reconnect errors during first launch.",