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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

<!-- BEGIN:turborepo-agent-rules -->

Expand Down
7 changes: 7 additions & 0 deletions apps/cli/scripts/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(", "),
Expand Down
26 changes: 21 additions & 5 deletions packages/client/src/features/message/hooks/useMarkdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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*<message[^>]*>\n?/;
const MESSAGE_CLOSE = /\n?<\/message>\s*$/;

Expand All @@ -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(
Expand Down
11 changes: 10 additions & 1 deletion packages/tui/src/core/components/Box.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 7 additions & 4 deletions packages/tui/src/features/message/components/Markdown.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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,
}: {
Expand All @@ -39,7 +42,7 @@ function MarkdownBlock({
passNode: true,
},
);
}
});

export default function Markdown({
source,
Expand Down
7 changes: 6 additions & 1 deletion packages/tui/src/features/part/components/Task.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
52 changes: 52 additions & 0 deletions patches/@alcalzone__ansi-tokenize@0.3.1.patch
Original file line number Diff line number Diff line change
@@ -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;
173 changes: 173 additions & 0 deletions patches/ink@8.0.0.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
diff --git a/build/output.js b/build/output.js
index 10a94568e2e9fc005dd383babf36456cb31ef572..c5c32c79527a1263bc98507e093a97dcf280dde8 100644
--- a/build/output.js
+++ b/build/output.js
@@ -27,16 +27,96 @@ 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;
+};
+// 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 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 characters) {
+ if (character.value === '\n') {
+ characterLines.push([]);
+ }
+ else {
+ characterLines.at(-1).push(character);
+ }
+ }
+ cached = characterLines.map(line => styledCharsToString(line));
+ // 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) {
- // 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;
}
}
+// 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 +157,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)) {
Loading
Loading