From 91cfec57cd60bec93c571800c28b953bfd7b82ea Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 15:07:51 +0000 Subject: [PATCH 1/5] perf(tui): paint a box's background only where it is set Every Box handed its inherited background to Ink, so every box in the app painted its whole area on every frame, nested boxes repainting the same cells once per level, and the transcript's scrolled-away history costing as much as what is on screen. Profiled over a three-turn session this was 86% of all CPU, growing with every turn. The box a color comes from has already painted it; overlays still paint it over what they cover, and text still inherits it through Ink's context. It also stops content wider than its block from painting over that block's border. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018mPBzWaJnr8VavGtGDMxvQ --- packages/tui/src/core/components/Box.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/core/components/Box.tsx b/packages/tui/src/core/components/Box.tsx index f9e3bdaa..93c8afff 100644 --- a/packages/tui/src/core/components/Box.tsx +++ b/packages/tui/src/core/components/Box.tsx @@ -75,7 +75,16 @@ export default function Box({ ], ); - const backgroundColor = useColor(mergedContext.backgroundColor); + // Painted only where it is set, or over whatever an overlay covers. The box + // an inherited color comes from has already painted it under this one, and + // Ink paints every cell of every box it is given a background for — on + // every frame, scrolled out of view or not — so repainting it here costs a + // pass over each cell for every box it is nested in. Text still inherits + // it from Ink's own context. + const backgroundColor = useColor( + _backgroundColor ?? + (props.position === "absolute" ? context?.backgroundColor : undefined), + ); const borderColor = useColor(mergedContext.borderColor ?? "border"); const borderBackgroundColor = useColor( mergedContext.borderBackgroundColor ?? mergedContext.backgroundColor, From 2b079eada0cd47a597094d8a7a55bc5e9693a2bf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 15:07:51 +0000 Subject: [PATCH 2/5] perf(tui): patch ink and ansi-tokenize to skip work a frame throws away - ink: drop writes that fall outside the output or the clip before tokenizing them, and keep the tokenizing caches across frames (bounded) rather than building new ones for every frame. - @alcalzone/ansi-tokenize: skip diffing the styles of neighboring cells that are styled the same (their diff is empty), and share one styles array per run of characters instead of copying it per character. Both produce byte-identical output: checked against the originals over thousands of randomized frames and styled strings. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018mPBzWaJnr8VavGtGDMxvQ --- AGENTS.md | 4 +- patches/@alcalzone__ansi-tokenize@0.3.1.patch | 52 ++++++++++ patches/ink@8.0.0.patch | 97 +++++++++++++++++++ pnpm-lock.yaml | 24 ++--- pnpm-workspace.yaml | 2 + 5 files changed, 166 insertions(+), 13 deletions(-) create mode 100644 patches/@alcalzone__ansi-tokenize@0.3.1.patch create mode 100644 patches/ink@8.0.0.patch diff --git a/AGENTS.md b/AGENTS.md index 98a4779b..c686c4d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,8 +99,8 @@ All root scripts load the repo-root `.env`. | Builds | `pnpm build:desktop`, `build:ios`, `build:android`, `build:cli` (unsigned) | | Lint / types / tests | `pnpm lint`, `pnpm typecheck`, `pnpm test` | -TypeScript is strict with `.ts` import extensions; Biome uses tabs and double quotes. `@ai-sdk/google` and `sixel` are -pnpm-patched — don't edit them in `node_modules`. +TypeScript is strict with `.ts` import extensions; Biome uses tabs and double quotes. `ink`, `@alcalzone/ansi-tokenize` and +`native-run` are pnpm-patched (`patches/`) — don't edit them in `node_modules`. diff --git a/patches/@alcalzone__ansi-tokenize@0.3.1.patch b/patches/@alcalzone__ansi-tokenize@0.3.1.patch new file mode 100644 index 00000000..2fabbc27 --- /dev/null +++ b/patches/@alcalzone__ansi-tokenize@0.3.1.patch @@ -0,0 +1,52 @@ +diff --git a/build/styledChars.js b/build/styledChars.js +index 13016ee6bd6155fc51b3d942f4dc5689c0cb9ded..d6360ca95bbac9112476c9b319286a97c8fa1323 100644 +--- a/build/styledChars.js ++++ b/build/styledChars.js +@@ -3,20 +3,37 @@ import { diffAnsiCodes } from "./diff.js"; + import { reduceAnsiCodesIncremental } from "./reduce.js"; + export function styledCharsFromTokens(tokens) { + let codes = []; ++ // One copy per run of characters in the same style, shared by all of them, ++ // rather than one per character. `reduceAnsiCodesIncremental` returns a new ++ // array, so a shared copy is never changed under the characters holding it. ++ let styles = []; + const ret = []; + for (const token of tokens) { + if (token.type === "ansi") { + codes = reduceAnsiCodesIncremental(codes, [token]); ++ styles = [...codes]; + } + else if (token.type === "char") { + ret.push({ + ...token, +- styles: [...codes], ++ styles, + }); + } + } + return ret; + } ++/** Whether two reduced styles are the same, in which case their diff is empty. */ ++function isSameStyle(from, to) { ++ if (from === to) ++ return true; ++ if (from.length !== to.length) ++ return false; ++ for (let i = 0; i < from.length; i++) { ++ if (from[i].code !== to[i].code || from[i].endCode !== to[i].endCode) ++ return false; ++ } ++ return true; ++} + export function styledCharsToString(chars) { + let ret = ""; + for (let i = 0; i < chars.length; i++) { +@@ -24,7 +41,7 @@ export function styledCharsToString(chars) { + if (i === 0) { + ret += ansiCodesToString(char.styles); + } +- else { ++ else if (!isSameStyle(chars[i - 1].styles, char.styles)) { + ret += ansiCodesToString(diffAnsiCodes(chars[i - 1].styles, char.styles)); + } + ret += char.value; diff --git a/patches/ink@8.0.0.patch b/patches/ink@8.0.0.patch new file mode 100644 index 00000000..8c385a76 --- /dev/null +++ b/patches/ink@8.0.0.patch @@ -0,0 +1,97 @@ +diff --git a/build/output.js b/build/output.js +index 10a94568e2e9fc005dd383babf36456cb31ef572..17cf3761e3f919289190898307a24392b688208c 100644 +--- a/build/output.js ++++ b/build/output.js +@@ -1,5 +1,6 @@ + import stringWidth from 'string-width'; + import { styledCharsFromTokens, styledCharsToString, tokenize, } from '@alcalzone/ansi-tokenize'; ++import QuickLRU from 'quick-lru'; + // Replaces a partially visible wide character with a space that keeps its styles. + const blankCell = (cell) => ({ + ...cell, +@@ -27,10 +28,37 @@ const intersectClips = (outer, inner) => { + y2: intersectBound(outer.y2, inner.y2, Math.min), + }; + }; ++// Rows of a text are counted by its line breaks, which is what splitting its styled characters on them would give. ++const countLines = (text) => { ++ let count = 1; ++ for (let index = text.indexOf('\n'); index !== -1; index = text.indexOf('\n', index + 1)) { ++ count++; ++ } ++ return count; ++}; + class OutputCaches { +- widths = new Map(); +- blockWidths = new Map(); +- styledChars = new Map(); ++ widths = new QuickLRU({ maxSize: 8192 }); ++ blockWidths = new QuickLRU({ maxSize: 4096 }); ++ styledChars = new QuickLRU({ maxSize: 4096 }); ++ lines = new QuickLRU({ maxSize: 4096 }); ++ // A text's rows, each with its styles reopened, which is all a write needs of it before it is clipped. ++ getLines(text) { ++ let cached = this.lines.get(text); ++ if (cached === undefined) { ++ const characterLines = [[]]; ++ for (const character of this.getStyledChars(text)) { ++ if (character.value === '\n') { ++ characterLines.push([]); ++ } ++ else { ++ characterLines.at(-1).push(character); ++ } ++ } ++ cached = characterLines.map(line => styledCharsToString(line)); ++ this.lines.set(text, cached); ++ } ++ return cached; ++ } + getStyledChars(line) { + let cached = this.styledChars.get(line); + if (cached === undefined) { +@@ -61,9 +89,11 @@ class OutputCaches { + return cached; + } + } ++// Shared by every frame: a new Output is made for each one, and almost all of what it writes was written the frame before. ++const sharedCaches = new OutputCaches(); + export default class Output { + operations = []; +- caches = new OutputCaches(); ++ caches = sharedCaches; + width; + height; + constructor(options) { +@@ -74,17 +104,23 @@ export default class Output { + applyWriteOperation(output, operation, clip) { + const { text, transformers } = operation; + let { x, y } = operation; +- // Preserve styles across explicit newlines before clipping individual rows. +- const characterLines = [[]]; +- for (const character of this.caches.getStyledChars(text)) { +- if (character.value === '\n') { +- characterLines.push([]); ++ // Text that lands outside the output, or outside the clip, is let go of before it is tokenized. Most of a long scrolled view is both, and tokenizing it was most of the cost of a frame. ++ const lineCount = countLines(text); ++ if (y + lineCount <= 0 || y >= output.length) { ++ return; ++ } ++ if (clip) { ++ if (isClipEmpty(clip)) { ++ return; + } +- else { +- characterLines.at(-1).push(character); ++ if (typeof clip.y1 === 'number' && ++ typeof clip.y2 === 'number' && ++ (y + lineCount < clip.y1 || y > clip.y2)) { ++ return; + } + } +- let lines = characterLines.map(line => styledCharsToString(line)); ++ // Preserve styles across explicit newlines before clipping individual rows. ++ let lines = this.caches.getLines(text); + if (clip) { + // Two nested clips can intersect to nothing. Nothing is visible, so bail out before slicing. + if (isClipEmpty(clip)) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7722328a..1cd067f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -192,6 +192,8 @@ overrides: effect: 4.0.0-rc.115 patchedDependencies: + '@alcalzone/ansi-tokenize@0.3.1': 210f7f77296c0e18b948ffa5c52e2e9ec1e10c898ab241b398c2201c83480ba8 + ink@8.0.0: d6e36a0b08283b1e75f06432f472173f3f126452a3c59927b31eaa5123176dac native-run: 9fa167e2367df0c66ebb956cb430ed74193b4780927891e89bb35d27b17dfb56 importers: @@ -315,7 +317,7 @@ importers: version: link:../../packages/core ink: specifier: ^8.0.0 - version: 8.0.0(@types/react@19.3.0)(react-devtools-core@8.0.0)(react@19.3.1) + version: 8.0.0(patch_hash=d6e36a0b08283b1e75f06432f472173f3f126452a3c59927b31eaa5123176dac)(@types/react@19.3.0)(react-devtools-core@8.0.0)(react@19.3.1) react: specifier: ^19.3.1 version: 19.3.1 @@ -768,13 +770,13 @@ importers: version: 2.3.6(supports-color@10.2.2) ink: specifier: ^8.0.0 - version: 8.0.0(@types/react@19.3.0)(react-devtools-core@8.0.0)(react@19.3.1) + version: 8.0.0(patch_hash=d6e36a0b08283b1e75f06432f472173f3f126452a3c59927b31eaa5123176dac)(@types/react@19.3.0)(react-devtools-core@8.0.0)(react@19.3.1) ink-picture: specifier: ^2.1.0 - version: 2.1.0(ink@8.0.0(@types/react@19.3.0)(react-devtools-core@8.0.0)(react@19.3.1))(react@19.3.1) + version: 2.1.0(ink@8.0.0(patch_hash=d6e36a0b08283b1e75f06432f472173f3f126452a3c59927b31eaa5123176dac)(@types/react@19.3.0)(react-devtools-core@8.0.0)(react@19.3.1))(react@19.3.1) ink-spinner: specifier: ^5.0.0 - version: 5.0.0(ink@8.0.0(@types/react@19.3.0)(react-devtools-core@8.0.0)(react@19.3.1))(react@19.3.1) + version: 5.0.0(ink@8.0.0(patch_hash=d6e36a0b08283b1e75f06432f472173f3f126452a3c59927b31eaa5123176dac)(@types/react@19.3.0)(react-devtools-core@8.0.0)(react@19.3.1))(react@19.3.1) react: specifier: ^19.3.1 version: 19.3.1 @@ -8312,7 +8314,7 @@ snapshots: dependencies: json-schema: 0.4.0 - '@alcalzone/ansi-tokenize@0.3.1': + '@alcalzone/ansi-tokenize@0.3.1(patch_hash=210f7f77296c0e18b948ffa5c52e2e9ec1e10c898ab241b398c2201c83480ba8)': dependencies: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 @@ -12957,10 +12959,10 @@ snapshots: ini@6.0.0: {} - ink-picture@2.1.0(ink@8.0.0(@types/react@19.3.0)(react-devtools-core@8.0.0)(react@19.3.1))(react@19.3.1): + ink-picture@2.1.0(ink@8.0.0(patch_hash=d6e36a0b08283b1e75f06432f472173f3f126452a3c59927b31eaa5123176dac)(@types/react@19.3.0)(react-devtools-core@8.0.0)(react@19.3.1))(react@19.3.1): dependencies: chalk: 5.6.2 - ink: 8.0.0(@types/react@19.3.0)(react-devtools-core@8.0.0)(react@19.3.1) + ink: 8.0.0(patch_hash=d6e36a0b08283b1e75f06432f472173f3f126452a3c59927b31eaa5123176dac)(@types/react@19.3.0)(react-devtools-core@8.0.0)(react@19.3.1) is-unicode-supported: 2.1.0 iterm2-version: 6.0.0 jimp: 1.6.1(supports-color@10.2.2) @@ -12969,19 +12971,19 @@ snapshots: sixel: 0.16.0 supports-color: 10.2.2 - ink-spinner@5.0.0(ink@8.0.0(@types/react@19.3.0)(react-devtools-core@8.0.0)(react@19.3.1))(react@19.3.1): + ink-spinner@5.0.0(ink@8.0.0(patch_hash=d6e36a0b08283b1e75f06432f472173f3f126452a3c59927b31eaa5123176dac)(@types/react@19.3.0)(react-devtools-core@8.0.0)(react@19.3.1))(react@19.3.1): dependencies: cli-spinners: 2.9.2 - ink: 8.0.0(@types/react@19.3.0)(react-devtools-core@8.0.0)(react@19.3.1) + ink: 8.0.0(patch_hash=d6e36a0b08283b1e75f06432f472173f3f126452a3c59927b31eaa5123176dac)(@types/react@19.3.0)(react-devtools-core@8.0.0)(react@19.3.1) react: 19.3.1 ink-testing-library@4.0.0(@types/react@19.3.0): optionalDependencies: '@types/react': 19.3.0 - ink@8.0.0(@types/react@19.3.0)(react-devtools-core@8.0.0)(react@19.3.1): + ink@8.0.0(patch_hash=d6e36a0b08283b1e75f06432f472173f3f126452a3c59927b31eaa5123176dac)(@types/react@19.3.0)(react-devtools-core@8.0.0)(react@19.3.1): dependencies: - '@alcalzone/ansi-tokenize': 0.3.1 + '@alcalzone/ansi-tokenize': 0.3.1(patch_hash=210f7f77296c0e18b948ffa5c52e2e9ec1e10c898ab241b398c2201c83480ba8) ansi-escapes: 7.3.0 ansi-styles: 7.0.0 auto-bind: 5.0.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 84e4518e..9dbae3e9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -44,4 +44,6 @@ catalogs: "@ryangarber/prisma-orm-extension-zod": "0.2.9" "@ryangarber/better-auth-adapter-prisma": "0.1.9" patchedDependencies: + '@alcalzone/ansi-tokenize@0.3.1': patches/@alcalzone__ansi-tokenize@0.3.1.patch + ink@8.0.0: patches/ink@8.0.0.patch native-run: patches/native-run.patch From 8d37d7eeefdd1e5b1c854471c25de52c32a44448 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 15:07:51 +0000 Subject: [PATCH 3/5] perf(tui): stop settled tasks ticking and blocks re-parsing - Task.Status only animates while pending. Every settled tool call and thought ticked at 5 Hz, committing ~19 frames a second while idle. - MarkdownBlock is memoized explicitly: the compiler did not take it for a component, so every block of a message was parsed again on every render, each token while streaming. - useMarkdown keeps its parts while they are the same parts, so the blocks' props hold still between renders of the same source. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018mPBzWaJnr8VavGtGDMxvQ --- .../src/features/message/hooks/useMarkdown.ts | 26 +++++++++++++++---- .../features/message/components/Markdown.tsx | 11 +++++--- .../tui/src/features/part/components/Task.tsx | 7 ++++- 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/packages/client/src/features/message/hooks/useMarkdown.ts b/packages/client/src/features/message/hooks/useMarkdown.ts index 7cd37803..0472e140 100644 --- a/packages/client/src/features/message/hooks/useMarkdown.ts +++ b/packages/client/src/features/message/hooks/useMarkdown.ts @@ -6,7 +6,7 @@ import type { Parent, RootContent, } from "mdast"; -import { type JSX, useMemo } from "react"; +import { type JSX, useMemo, useRef } from "react"; import RehypeKatex from "rehype-katex"; import RehypeRaw from "rehype-raw"; import RehypeSanitize, { defaultSchema } from "rehype-sanitize"; @@ -392,6 +392,11 @@ export const processor = unified() * to treat the whole document as one block, which is exactly the incremental * rendering the split is meant to enable. */ +const NO_PARTS: zEditorPart[] = []; + +const isSameParts = (a: readonly zEditorPart[], b: readonly zEditorPart[]) => + a.length === b.length && a.every((part, index) => part === b[index]); + const MESSAGE_OPEN = /^\s*]*>\n?/; const MESSAGE_CLOSE = /\n?<\/message>\s*$/; @@ -402,14 +407,25 @@ export const useMarkdown = ({ source: MarkdownSource; withKatex?: boolean; }) => { - const { text, parts } = useMemo(() => { - if (typeof source === "string") return { text: source, parts: [] }; + const joined = useMemo(() => { + if (typeof source === "string") return { text: source, parts: NO_PARTS }; - const { source: joined, parts } = EditorPartUtils.join( + const { source: text, parts } = EditorPartUtils.join( source.flat().filter(EditorPartUtils.isRun), ); - return { text: joined, parts }; + return { text, parts }; }, [source]); + const { text } = joined; + + // Every block is drawn with the parts, so they are held for as long as they + // are the same parts: a source is rebuilt by whatever renders it, and parts + // joined anew from it would miss every block's memo and parse the whole + // message again with each render — each token, while it streams. + const previousParts = useRef(NO_PARTS); + const parts = isSameParts(previousParts.current, joined.parts) + ? previousParts.current + : joined.parts; + previousParts.current = parts; const content = useMemo(() => { const normalized = MarkdownUtils.normalize( diff --git a/packages/tui/src/features/message/components/Markdown.tsx b/packages/tui/src/features/message/components/Markdown.tsx index 19e624bb..00fec1f4 100644 --- a/packages/tui/src/features/message/components/Markdown.tsx +++ b/packages/tui/src/features/message/components/Markdown.tsx @@ -1,5 +1,5 @@ import { toJsxRuntime } from "hast-util-to-jsx-runtime"; -import { Fragment } from "react"; +import { Fragment, memo } from "react"; import { jsx, jsxs } from "react/jsx-runtime"; import { StreamContext } from "#client/features/message/components/StreamContext.tsx"; import { @@ -18,9 +18,12 @@ import { MarkdownComponents } from "#tui/features/message/components/MarkdownCom * * The memo is the point of the split: while a message streams, only the last * block's source changes, so every block above it keeps its element tree, and - * with it the Ink nodes and the layout Yoga already measured for them. + * with it the Ink nodes and the layout Yoga already measured for them. It is + * spelled out rather than left to the compiler, which takes a function that + * returns no JSX of its own for something other than a component, and would + * otherwise leave every block to be parsed again on every render. */ -function MarkdownBlock({ +const MarkdownBlock = memo(function MarkdownBlock({ source, parts, }: { @@ -39,7 +42,7 @@ function MarkdownBlock({ passNode: true, }, ); -} +}); export default function Markdown({ source, diff --git a/packages/tui/src/features/part/components/Task.tsx b/packages/tui/src/features/part/components/Task.tsx index c9c9818b..5c69b780 100644 --- a/packages/tui/src/features/part/components/Task.tsx +++ b/packages/tui/src/features/part/components/Task.tsx @@ -115,7 +115,12 @@ namespace Task { isActive: true, }); - const shimmer = useAnimation({ interval: 200 }); + // Only a pending task shimmers, so only one ticks: every tick is a frame + // for the whole screen, and a settled one would draw it the same each time. + const shimmer = useAnimation({ + interval: 200, + isActive: status === "pending", + }); const offset = useMemo(() => Math.random() * 1000, []); const current = status === "pending" ? shimmer : null; From b27edc7423fc9386a86b0c8a2a7567e4fa8aa7a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 15:07:51 +0000 Subject: [PATCH 4/5] perf(cli): bundle React's production builds in releases Bun.build leaves NODE_ENV to the environment, so release builds shipped React's development builds, which time and record every component render. Dev builds keep the development builds. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018mPBzWaJnr8VavGtGDMxvQ --- apps/cli/scripts/compile.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/cli/scripts/compile.ts b/apps/cli/scripts/compile.ts index 8ed8694d..9748d10e 100644 --- a/apps/cli/scripts/compile.ts +++ b/apps/cli/scripts/compile.ts @@ -47,6 +47,13 @@ export async function compile({ return [[key, value]]; }), ); + // Bun leaves `NODE_ENV` to the environment the bundle is built in, which + // bundles React's development builds unless it is set. A release takes the + // production builds, as the app's does: the development ones time and + // record every component on every render, costing a frame many times over. + define["process.env.NODE_ENV"] = JSON.stringify( + dev ? "development" : "production", + ); print({ message: `environment${dev ? " · dev" : ""}`, details: Object.keys(define).join(", "), From 86de99004aae7d365393779381dbb432238ae4d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 01:35:04 +0000 Subject: [PATCH 5/5] fix(tui): bound the memory Ink's shared render caches can hold The caches the Ink patch keeps across frames were bounded by entry count, not by what the entries hold, and three things let them grow without bound: - Parsed characters were kept for whole multiline blocks, so each change to one row of a block left a parsed copy of all of it behind. - Thousands of long entries (each prefix of a streaming paragraph) fit under a count of a few thousand. - Widths of lines split from a block were cached by those lines, which are slices that keep the whole block alive. Parsed characters are now kept only for single lines (a block is still tokenized whole on a miss, so styles spanning lines are reopened), every cache is bounded by the size of its text in two generations so a hit stays one lookup, and split lines are measured without being cached. Retained memory after 3,000 streaming updates: 3.7 GiB -> 4.9 MiB, with the same per-frame time; output unchanged against stock Ink in a differential fuzz. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018mPBzWaJnr8VavGtGDMxvQ --- patches/ink@8.0.0.patch | 110 +++++++++++++++++++++++++++++++++------- pnpm-lock.yaml | 20 ++++---- 2 files changed, 103 insertions(+), 27 deletions(-) diff --git a/patches/ink@8.0.0.patch b/patches/ink@8.0.0.patch index 8c385a76..f9b4886b 100644 --- a/patches/ink@8.0.0.patch +++ b/patches/ink@8.0.0.patch @@ -1,15 +1,8 @@ diff --git a/build/output.js b/build/output.js -index 10a94568e2e9fc005dd383babf36456cb31ef572..17cf3761e3f919289190898307a24392b688208c 100644 +index 10a94568e2e9fc005dd383babf36456cb31ef572..c5c32c79527a1263bc98507e093a97dcf280dde8 100644 --- a/build/output.js +++ b/build/output.js -@@ -1,5 +1,6 @@ - import stringWidth from 'string-width'; - import { styledCharsFromTokens, styledCharsToString, tokenize, } from '@alcalzone/ansi-tokenize'; -+import QuickLRU from 'quick-lru'; - // Replaces a partially visible wide character with a space that keeps its styles. - const blankCell = (cell) => ({ - ...cell, -@@ -27,10 +28,37 @@ const intersectClips = (outer, inner) => { +@@ -27,16 +27,96 @@ const intersectClips = (outer, inner) => { y2: intersectBound(outer.y2, inner.y2, Math.min), }; }; @@ -21,20 +14,71 @@ index 10a94568e2e9fc005dd383babf36456cb31ef572..17cf3761e3f919289190898307a24392 + } + return count; +}; ++// A cache bounded by the size of what it holds rather than by how many entries it has: entries are kept across frames, and a count alone lets a few thousand long ones (each prefix of a streaming paragraph, say) hold on to a great deal. Sizes are in UTF-16 code units of the text an entry was made from. ++// Kept in two generations, as quick-lru keeps its own, so that a hit, which most lookups in a frame are, is a single lookup: an entry used since the older generation was started is carried into the newer one, and the older one is dropped whole once the newer one has filled half of the room. ++class SizedCache { ++ current = new Map(); ++ previous = new Map(); ++ currentSize = 0; ++ constructor(maxSize) { ++ this.maxSize = maxSize; ++ } ++ get(key) { ++ const entry = this.current.get(key); ++ if (entry !== undefined) { ++ return entry.value; ++ } ++ const old = this.previous.get(key); ++ if (old !== undefined) { ++ this.previous.delete(key); ++ this.add(key, old); ++ return old.value; ++ } ++ return undefined; ++ } ++ set(key, value, size) { ++ // Anything this large is left uncached rather than allowed to push out everything else. ++ if (size > this.maxSize / 8) { ++ return; ++ } ++ this.previous.delete(key); ++ const existing = this.current.get(key); ++ if (existing !== undefined) { ++ this.currentSize -= existing.size; ++ } ++ this.add(key, { value, size }); ++ } ++ add(key, entry) { ++ this.current.set(key, entry); ++ this.currentSize += entry.size; ++ if (this.currentSize >= this.maxSize / 2) { ++ this.previous = this.current; ++ this.current = new Map(); ++ this.currentSize = 0; ++ } ++ } ++} ++// Standalone invisible Unicode characters must not occupy terminal cells. ++const toStyledChars = (text) => styledCharsFromTokens(tokenize(text)).filter(character => !/^\p{Default_Ignorable_Code_Point}+$/u.test(character.value)); class OutputCaches { - widths = new Map(); - blockWidths = new Map(); - styledChars = new Map(); -+ widths = new QuickLRU({ maxSize: 8192 }); -+ blockWidths = new QuickLRU({ maxSize: 4096 }); -+ styledChars = new QuickLRU({ maxSize: 4096 }); -+ lines = new QuickLRU({ maxSize: 4096 }); ++ widths = new SizedCache(1 << 18); ++ blockWidths = new SizedCache(1 << 20); ++ // Parsed characters are far larger than the text they come from, so they are only kept for single lines, which a frame mostly repeats. A block of several lines is a new key whenever any line of it changes, and keeping its characters would keep a copy of every line of it for every change. ++ styledChars = new SizedCache(1 << 16); ++ lines = new SizedCache(1 << 20); + // A text's rows, each with its styles reopened, which is all a write needs of it before it is clipped. + getLines(text) { + let cached = this.lines.get(text); + if (cached === undefined) { ++ // Tokenized whole, so a style that spans a line break is reopened on the next line. ++ const characters = text.includes('\n') ++ ? toStyledChars(text) ++ : this.getStyledChars(text); + const characterLines = [[]]; -+ for (const character of this.getStyledChars(text)) { ++ for (const character of characters) { + if (character.value === '\n') { + characterLines.push([]); + } @@ -43,14 +87,46 @@ index 10a94568e2e9fc005dd383babf36456cb31ef572..17cf3761e3f919289190898307a24392 + } + } + cached = characterLines.map(line => styledCharsToString(line)); -+ this.lines.set(text, cached); ++ // The key, and the lines that are about as long again. ++ this.lines.set(text, cached, text.length * 2); + } + return cached; + } getStyledChars(line) { let cached = this.styledChars.get(line); if (cached === undefined) { -@@ -61,9 +89,11 @@ class OutputCaches { +- // Standalone invisible Unicode characters must not occupy terminal cells. +- cached = styledCharsFromTokens(tokenize(line)).filter(character => !/^\p{Default_Ignorable_Code_Point}+$/u.test(character.value)); +- this.styledChars.set(line, cached); ++ cached = toStyledChars(line); ++ if (!line.includes('\n')) { ++ this.styledChars.set(line, cached, line.length); ++ } + } + return cached; + } +@@ -44,26 +124,29 @@ class OutputCaches { + let cached = this.widths.get(text); + if (cached === undefined) { + cached = stringWidth(text); +- this.widths.set(text, cached); ++ this.widths.set(text, cached, text.length); + } + return cached; + } + getWidestLine(text) { + let cached = this.blockWidths.get(text); + if (cached === undefined) { ++ // Measured here rather than through the cache of widths: a line split from a block is a slice of it, and kept as a key it would keep the whole block alive with it. + let lineWidth = 0; + for (const line of text.split('\n')) { +- lineWidth = Math.max(lineWidth, this.getStringWidth(line)); ++ lineWidth = Math.max(lineWidth, stringWidth(line)); + } + cached = lineWidth; +- this.blockWidths.set(text, cached); ++ this.blockWidths.set(text, cached, text.length); + } return cached; } } @@ -63,7 +139,7 @@ index 10a94568e2e9fc005dd383babf36456cb31ef572..17cf3761e3f919289190898307a24392 width; height; constructor(options) { -@@ -74,17 +104,23 @@ export default class Output { +@@ -74,17 +157,23 @@ export default class Output { applyWriteOperation(output, operation, clip) { const { text, transformers } = operation; let { x, y } = operation; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1cd067f9..5a9f95ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -193,7 +193,7 @@ overrides: patchedDependencies: '@alcalzone/ansi-tokenize@0.3.1': 210f7f77296c0e18b948ffa5c52e2e9ec1e10c898ab241b398c2201c83480ba8 - ink@8.0.0: d6e36a0b08283b1e75f06432f472173f3f126452a3c59927b31eaa5123176dac + ink@8.0.0: 4a5bd83b0c4c7b3eea2c475af6b40dc609126bf7a8df6967ee76c6cc62e0fccb native-run: 9fa167e2367df0c66ebb956cb430ed74193b4780927891e89bb35d27b17dfb56 importers: @@ -317,7 +317,7 @@ importers: version: link:../../packages/core ink: specifier: ^8.0.0 - version: 8.0.0(patch_hash=d6e36a0b08283b1e75f06432f472173f3f126452a3c59927b31eaa5123176dac)(@types/react@19.3.0)(react-devtools-core@8.0.0)(react@19.3.1) + version: 8.0.0(patch_hash=4a5bd83b0c4c7b3eea2c475af6b40dc609126bf7a8df6967ee76c6cc62e0fccb)(@types/react@19.3.0)(react-devtools-core@8.0.0)(react@19.3.1) react: specifier: ^19.3.1 version: 19.3.1 @@ -770,13 +770,13 @@ importers: version: 2.3.6(supports-color@10.2.2) ink: specifier: ^8.0.0 - version: 8.0.0(patch_hash=d6e36a0b08283b1e75f06432f472173f3f126452a3c59927b31eaa5123176dac)(@types/react@19.3.0)(react-devtools-core@8.0.0)(react@19.3.1) + version: 8.0.0(patch_hash=4a5bd83b0c4c7b3eea2c475af6b40dc609126bf7a8df6967ee76c6cc62e0fccb)(@types/react@19.3.0)(react-devtools-core@8.0.0)(react@19.3.1) ink-picture: specifier: ^2.1.0 - version: 2.1.0(ink@8.0.0(patch_hash=d6e36a0b08283b1e75f06432f472173f3f126452a3c59927b31eaa5123176dac)(@types/react@19.3.0)(react-devtools-core@8.0.0)(react@19.3.1))(react@19.3.1) + version: 2.1.0(ink@8.0.0(patch_hash=4a5bd83b0c4c7b3eea2c475af6b40dc609126bf7a8df6967ee76c6cc62e0fccb)(@types/react@19.3.0)(react-devtools-core@8.0.0)(react@19.3.1))(react@19.3.1) ink-spinner: specifier: ^5.0.0 - version: 5.0.0(ink@8.0.0(patch_hash=d6e36a0b08283b1e75f06432f472173f3f126452a3c59927b31eaa5123176dac)(@types/react@19.3.0)(react-devtools-core@8.0.0)(react@19.3.1))(react@19.3.1) + version: 5.0.0(ink@8.0.0(patch_hash=4a5bd83b0c4c7b3eea2c475af6b40dc609126bf7a8df6967ee76c6cc62e0fccb)(@types/react@19.3.0)(react-devtools-core@8.0.0)(react@19.3.1))(react@19.3.1) react: specifier: ^19.3.1 version: 19.3.1 @@ -12959,10 +12959,10 @@ snapshots: ini@6.0.0: {} - ink-picture@2.1.0(ink@8.0.0(patch_hash=d6e36a0b08283b1e75f06432f472173f3f126452a3c59927b31eaa5123176dac)(@types/react@19.3.0)(react-devtools-core@8.0.0)(react@19.3.1))(react@19.3.1): + ink-picture@2.1.0(ink@8.0.0(patch_hash=4a5bd83b0c4c7b3eea2c475af6b40dc609126bf7a8df6967ee76c6cc62e0fccb)(@types/react@19.3.0)(react-devtools-core@8.0.0)(react@19.3.1))(react@19.3.1): dependencies: chalk: 5.6.2 - ink: 8.0.0(patch_hash=d6e36a0b08283b1e75f06432f472173f3f126452a3c59927b31eaa5123176dac)(@types/react@19.3.0)(react-devtools-core@8.0.0)(react@19.3.1) + ink: 8.0.0(patch_hash=4a5bd83b0c4c7b3eea2c475af6b40dc609126bf7a8df6967ee76c6cc62e0fccb)(@types/react@19.3.0)(react-devtools-core@8.0.0)(react@19.3.1) is-unicode-supported: 2.1.0 iterm2-version: 6.0.0 jimp: 1.6.1(supports-color@10.2.2) @@ -12971,17 +12971,17 @@ snapshots: sixel: 0.16.0 supports-color: 10.2.2 - ink-spinner@5.0.0(ink@8.0.0(patch_hash=d6e36a0b08283b1e75f06432f472173f3f126452a3c59927b31eaa5123176dac)(@types/react@19.3.0)(react-devtools-core@8.0.0)(react@19.3.1))(react@19.3.1): + ink-spinner@5.0.0(ink@8.0.0(patch_hash=4a5bd83b0c4c7b3eea2c475af6b40dc609126bf7a8df6967ee76c6cc62e0fccb)(@types/react@19.3.0)(react-devtools-core@8.0.0)(react@19.3.1))(react@19.3.1): dependencies: cli-spinners: 2.9.2 - ink: 8.0.0(patch_hash=d6e36a0b08283b1e75f06432f472173f3f126452a3c59927b31eaa5123176dac)(@types/react@19.3.0)(react-devtools-core@8.0.0)(react@19.3.1) + ink: 8.0.0(patch_hash=4a5bd83b0c4c7b3eea2c475af6b40dc609126bf7a8df6967ee76c6cc62e0fccb)(@types/react@19.3.0)(react-devtools-core@8.0.0)(react@19.3.1) react: 19.3.1 ink-testing-library@4.0.0(@types/react@19.3.0): optionalDependencies: '@types/react': 19.3.0 - ink@8.0.0(patch_hash=d6e36a0b08283b1e75f06432f472173f3f126452a3c59927b31eaa5123176dac)(@types/react@19.3.0)(react-devtools-core@8.0.0)(react@19.3.1): + ink@8.0.0(patch_hash=4a5bd83b0c4c7b3eea2c475af6b40dc609126bf7a8df6967ee76c6cc62e0fccb)(@types/react@19.3.0)(react-devtools-core@8.0.0)(react@19.3.1): dependencies: '@alcalzone/ansi-tokenize': 0.3.1(patch_hash=210f7f77296c0e18b948ffa5c52e2e9ec1e10c898ab241b398c2201c83480ba8) ansi-escapes: 7.3.0