@cueloop/client 0.1.0-alpha.109 → 0.1.0-alpha.110

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/client",
3
- "version": "0.1.0-alpha.109",
3
+ "version": "0.1.0-alpha.110",
4
4
  "description": "The cueloop review UI: projection renderer, keyboard grammar, review rail",
5
5
  "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
6
  "bugs": {
@@ -27,14 +27,14 @@
27
27
  "access": "public"
28
28
  },
29
29
  "dependencies": {
30
- "@cueloop/daemon": "0.1.0-alpha.109",
31
- "@cueloop/extension-api": "0.1.0-alpha.109",
32
- "@cueloop/integration-obsidian": "0.1.0-alpha.109",
33
- "@cueloop/schema": "0.1.0-alpha.109",
34
- "@opentui/core": "^0.5.17",
35
- "@opentui/keymap": "0.5.17",
36
- "@opentui/react": "^0.5.17",
37
- "@opentui/ssh": "0.5.17",
30
+ "@cueloop/daemon": "0.1.0-alpha.110",
31
+ "@cueloop/extension-api": "0.1.0-alpha.110",
32
+ "@cueloop/integration-obsidian": "0.1.0-alpha.110",
33
+ "@cueloop/schema": "0.1.0-alpha.110",
34
+ "@opentui/core": "^0.6.8",
35
+ "@opentui/keymap": "0.6.8",
36
+ "@opentui/react": "^0.6.8",
37
+ "@opentui/ssh": "0.6.8",
38
38
  "@pierre/diffs": "^1.6.1",
39
39
  "@tanstack/virtual-core": "^3.19.7",
40
40
  "diff": "^9",
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import React, { useContext, useLayoutEffect, useRef, useState } from "react";
10
- import { usePaste } from "@opentui/react";
10
+ import { usePaste, useRenderer } from "@opentui/react";
11
11
  import type {
12
12
  BoxRenderable,
13
13
  KeyBinding,
@@ -69,6 +69,7 @@ export function Composer({
69
69
  agentEnabled?: boolean;
70
70
  focusRequest?: number;
71
71
  }): React.ReactNode {
72
+ const renderer = useRenderer();
72
73
  const editorRef = useRef<TextareaRenderable | null>(null);
73
74
  const pastedImageCount = useRef(0);
74
75
  const [rows, setRows] = useState(1);
@@ -86,7 +87,7 @@ export function Composer({
86
87
  });
87
88
 
88
89
  const paintReferences = (editor: TextareaRenderable): void => {
89
- const { styleId } = referenceStyleFor(tokens);
90
+ const { styleId } = referenceStyleFor(tokens, renderer.nativeScene);
90
91
 
91
92
  editor.editBuffer.clearAllHighlights();
92
93
  for (const range of skillReferenceRanges(editor.plainText, referenceNames)) {
@@ -103,7 +104,7 @@ export function Composer({
103
104
  if (!editor) return;
104
105
 
105
106
  editor.cursorOffset = seed.length;
106
- editor.editBuffer.setSyntaxStyle(referenceStyleFor(tokens).style);
107
+ editor.editBuffer.setSyntaxStyle(referenceStyleFor(tokens, renderer.nativeScene).style);
107
108
  paintReferences(editor);
108
109
  setRows(composeRowCount(editor.plainText, editor.width));
109
110
  onReady(() => editorRef.current?.plainText ?? "");
@@ -66,6 +66,7 @@ describe("markdown prototype review", () => {
66
66
  for (let attempt = 0; attempt < 60 && !keywordHighlighted; attempt++) {
67
67
  await setup.renderOnce();
68
68
  await new Promise((resolve) => setTimeout(resolve, 20));
69
+ await setup.renderOnce();
69
70
  const accentText = setup
70
71
  .captureSpans()
71
72
  .lines.flatMap((line) =>
@@ -263,6 +263,7 @@ for (const role of ["collaborator", "observer"] as const) {
263
263
 
264
264
  expect(row).toBeGreaterThanOrEqual(0);
265
265
  await setup.mockMouse.moveTo(12, row);
266
+ await setup.renderOnce();
266
267
  expect(setup.captureCharFrame()).not.toContain(NERD.kebab);
267
268
  expect(setup.captureCharFrame()).not.toContain(" edit ");
268
269
  } finally {
package/src/app/App.tsx CHANGED
@@ -944,12 +944,21 @@ export function App({
944
944
  return;
945
945
  }
946
946
 
947
- renderer?.suspend();
948
- try {
949
- controller.edit();
950
- } finally {
951
- renderer?.resume();
952
- }
947
+ void (async () => {
948
+ let suspended = false;
949
+
950
+ try {
951
+ await renderer?.suspend();
952
+ suspended = true;
953
+ } catch {
954
+ suspended = false;
955
+ }
956
+ try {
957
+ controller.edit();
958
+ } finally {
959
+ if (suspended) await renderer?.resume();
960
+ }
961
+ })();
953
962
  };
954
963
 
955
964
  // ── keyboard grammar: build state, reduce, dispatch ──
@@ -415,6 +415,7 @@ describe("the four-pane workbench", () => {
415
415
  return header.includes("util.ts") && header.includes("README.md");
416
416
  });
417
417
  await setup.mockMouse.click(util.column, util.row);
418
+ await setup.renderOnce();
418
419
  const header = setup.captureCharFrame().split("\n")[HEADER_ROW]!;
419
420
 
420
421
  expect(header.match(/util\.ts/g)).toHaveLength(1);
@@ -694,6 +695,7 @@ describe("the bare-launch welcome shell", () => {
694
695
  const skill = locateText(setup, "custom-review");
695
696
 
696
697
  await setup.mockMouse.click(skill.column, skill.row);
698
+ await setup.renderOnce();
697
699
  expect(setup.captureCharFrame()).toContain("custom-review");
698
700
  setup.renderer.destroy();
699
701
  });
@@ -788,12 +790,12 @@ describe("the bare-launch welcome shell", () => {
788
790
 
789
791
  expect(frame).toContain("README.md");
790
792
  expect(frame).toContain(NERD.copy);
791
- const title = locateText(setup, "README.md");
793
+ const title = locateText(setup, "│ README.md");
792
794
 
793
- await setup.mockMouse.click(title.column, title.row);
795
+ await setup.mockMouse.click(title.column + 2, title.row);
794
796
  await waitForTextGone(setup, "edited in the working tree");
795
797
  expect(setup.captureCharFrame()).toContain("README.md");
796
- await setup.mockMouse.click(title.column, title.row);
798
+ await setup.mockMouse.click(title.column + 2, title.row);
797
799
  await waitForText(setup, "edited in the working tree");
798
800
  setup.renderer.destroy();
799
801
  });
@@ -48,7 +48,6 @@ describe("AnnotatableFileView", () => {
48
48
  { path: "src/add.js", source: "const add = 1;", keyword: "const" },
49
49
  ]) {
50
50
  test(`highlights ${path} by its detected language`, async () => {
51
- allowEventLoopUpdates();
52
51
  const setup = await testRender(
53
52
  <AnnotatableFileView
54
53
  path={path}
@@ -64,11 +63,14 @@ describe("AnnotatableFileView", () => {
64
63
  />,
65
64
  { width: 64, height: 12 },
66
65
  );
66
+
67
+ allowEventLoopUpdates();
67
68
  let highlighted = false;
68
69
 
69
70
  for (let attempt = 0; attempt < 60 && !highlighted; attempt++) {
70
71
  await setup.renderOnce();
71
72
  await new Promise((resolve) => setTimeout(resolve, 20));
73
+ await setup.renderOnce();
72
74
  highlighted = setup.captureSpans().lines.some((line) =>
73
75
  line.spans
74
76
  .filter((span) => colorHex(span.fg) === DARK.accent)
@@ -147,6 +149,7 @@ describe("AnnotatableFileView", () => {
147
149
  { width: 64, height: 12 },
148
150
  );
149
151
 
152
+ allowEventLoopUpdates();
150
153
  await waitForText(setup, "return a + b");
151
154
 
152
155
  // Assert - the current file shows, but the head-side note is not attached to it
@@ -47,11 +47,13 @@ test("renders tree-sitter colors, with the changed word in the diff color on top
47
47
 
48
48
  // Act
49
49
  // syntax highlighting resolves off the render path; pump until a keyword paints
50
+ allowEventLoopUpdates();
50
51
  let keywordAccent = false;
51
52
 
52
53
  for (let attempt = 0; attempt < 60 && !keywordAccent; attempt++) {
53
54
  await setup.renderOnce();
54
55
  await new Promise((resolve) => setTimeout(resolve, 20));
56
+ await setup.renderOnce();
55
57
  // the caret cell splits the keyword's first character into its own span, so read the
56
58
  // accent-colored text of a whole line rather than one span
57
59
  for (const line of setup.captureSpans().lines) {
@@ -13,6 +13,7 @@
13
13
 
14
14
  import React, { forwardRef, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
15
15
  import type { KeyBinding, TextareaRenderable } from "@opentui/core";
16
+ import { useRenderer } from "@opentui/react";
16
17
  import { type Theme } from "../../appearance/theme";
17
18
  import { markdownHighlightRanges } from "../markdown-highlight";
18
19
  import { markdownEditorStyle } from "../markdown-editor-syntax-style";
@@ -63,6 +64,7 @@ function newlinePrefixCounts(text: string): number[] {
63
64
 
64
65
  export const MarkdownThreadEditor = forwardRef<MarkdownEditorHandle, MarkdownThreadEditorProps>(
65
66
  function MarkdownThreadEditor({ initialText, theme, onExitEditor }, handleRef): React.ReactNode {
67
+ const renderer = useRenderer();
66
68
  const editorRef = useRef<TextareaRenderable | null>(null);
67
69
  const [position, setPosition] = useState<MarkdownEditorPosition>({
68
70
  line: 1,
@@ -76,7 +78,7 @@ export const MarkdownThreadEditor = forwardRef<MarkdownEditorHandle, MarkdownThr
76
78
  useImperativeHandle(handleRef, () => ({ requestExit: exitWithCurrentText }));
77
79
 
78
80
  const paintMarkdown = (editor: TextareaRenderable): void => {
79
- const { styleIdFor } = markdownEditorStyle(theme);
81
+ const { styleIdFor } = markdownEditorStyle(theme, renderer.nativeScene);
80
82
  const text = editor.plainText;
81
83
  // addHighlightByCharRange counts offsets with newlines excluded, so map source offsets down by the
82
84
  // newlines before them - otherwise a highlight drifts one cell per preceding line
@@ -98,7 +100,7 @@ export const MarkdownThreadEditor = forwardRef<MarkdownEditorHandle, MarkdownThr
98
100
 
99
101
  if (!editor) return;
100
102
 
101
- editor.editBuffer.setSyntaxStyle(markdownEditorStyle(theme).style);
103
+ editor.editBuffer.setSyntaxStyle(markdownEditorStyle(theme, renderer.nativeScene).style);
102
104
  paintMarkdown(editor);
103
105
  setPosition((prior) => ({ ...prior, lineCount: editor.editBuffer.getLineCount() }));
104
106
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -296,7 +296,7 @@ export function ThreadView({
296
296
  const usable = viewWidth > 0 ? Math.max(1, viewWidth - 6) : 40;
297
297
  const lines = Math.max(1, Math.ceil(displayText(block).length / usable));
298
298
 
299
- return lines + (index === 0 ? 0 : 1);
299
+ return lines + 1;
300
300
  };
301
301
  const virtual = useTerminalVirtualizer({
302
302
  scrollbox: scrollRef,
@@ -400,7 +400,12 @@ export function ThreadView({
400
400
 
401
401
  if (custom !== undefined)
402
402
  return (
403
- <box key={`custom-${blockIndex}`} ref={virtual.measureRef(blockIndex)}>
403
+ <box
404
+ key={`custom-${blockIndex}`}
405
+ ref={virtual.measureRef(blockIndex)}
406
+ style={{ flexDirection: "column" }}
407
+ >
408
+ {blockIndex === 0 ? <box key="lead-gap" style={{ height: 1 }} /> : null}
404
409
  {custom}
405
410
  </box>
406
411
  );
@@ -415,8 +420,7 @@ export function ThreadView({
415
420
  (block.kind === "li" || block.kind === "oli");
416
421
  // the leading gap rides inside the measured box (not marginTop, which sits outside it) so the
417
422
  // virtualizer's spacer math counts it
418
- const leadingGap =
419
- blockIndex === 0 || tight ? null : <box key="lead-gap" style={{ height: 1 }} />;
423
+ const leadingGap = tight ? null : <box key="lead-gap" style={{ height: 1 }} />;
420
424
  const measuredBox = (children: React.ReactNode): React.ReactNode => (
421
425
  <box
422
426
  key={isPromptBlock?.(blockIndex) ? "prompt-block" : `discussion-block-${blockIndex}`}
@@ -550,7 +554,7 @@ export function ThreadView({
550
554
  {/* the rail draws this box's scrollbar past the dots, so the built-in one stays hidden */}
551
555
  <scrollbox
552
556
  ref={scrollRef}
553
- style={{ flexGrow: 1, paddingTop: 1 }}
557
+ style={{ flexGrow: 1 }}
554
558
  focused={false}
555
559
  verticalScrollbarOptions={{ visible: false }}
556
560
  >
@@ -6,7 +6,12 @@
6
6
  * hidden, since the editor shows raw source.
7
7
  */
8
8
 
9
- import { SyntaxStyle, type StyleDefinitionInput } from "@opentui/core";
9
+ import {
10
+ SyntaxStyle,
11
+ type NativeResourceOwner,
12
+ type ResourceContext,
13
+ type StyleDefinitionInput,
14
+ } from "@opentui/core";
10
15
  import { type Theme } from "../appearance/theme";
11
16
  import { MARKDOWN_HIGHLIGHT_GROUPS, type MarkdownHighlightGroup } from "./markdown-highlight";
12
17
 
@@ -25,16 +30,26 @@ function markdownGroupStyles(theme: Theme): Record<MarkdownHighlightGroup, Style
25
30
  };
26
31
  }
27
32
 
28
- const markdownEditorStyleCache = new WeakMap<Theme, MarkdownEditorStyle>();
33
+ const markdownEditorStyleCache = new WeakMap<
34
+ ResourceContext,
35
+ WeakMap<Theme, MarkdownEditorStyle>
36
+ >();
29
37
 
30
38
  /** Build (or reuse) the markdown editor's SyntaxStyle for a theme, with a group-to-styleId lookup. */
31
- export function markdownEditorStyle(theme: Theme): MarkdownEditorStyle {
32
- const cached = markdownEditorStyleCache.get(theme);
39
+ export function markdownEditorStyle(theme: Theme, owner: NativeResourceOwner): MarkdownEditorStyle {
40
+ let byTheme = markdownEditorStyleCache.get(owner.resourceContext);
41
+
42
+ if (!byTheme) {
43
+ byTheme = new WeakMap<Theme, MarkdownEditorStyle>();
44
+ markdownEditorStyleCache.set(owner.resourceContext, byTheme);
45
+ }
46
+
47
+ const cached = byTheme.get(theme);
33
48
 
34
49
  if (cached) return cached;
35
50
 
36
51
  const groupStyles = markdownGroupStyles(theme);
37
- const style = SyntaxStyle.fromStyles(groupStyles);
52
+ const style = SyntaxStyle.fromStyles(groupStyles, owner);
38
53
  const styleIds = new Map<MarkdownHighlightGroup, number>();
39
54
 
40
55
  for (const group of MARKDOWN_HIGHLIGHT_GROUPS) {
@@ -45,7 +60,7 @@ export function markdownEditorStyle(theme: Theme): MarkdownEditorStyle {
45
60
  styleIdFor: (group) => styleIds.get(group) ?? 0,
46
61
  };
47
62
 
48
- markdownEditorStyleCache.set(theme, entry);
63
+ byTheme.set(theme, entry);
49
64
 
50
65
  return entry;
51
66
  }
@@ -248,7 +248,7 @@ function PrototypeContentViewImpl({
248
248
 
249
249
  if (launchedRef.current || !region) return;
250
250
 
251
- if (caps && caps.kitty_graphics === false) {
251
+ if (caps && Boolean(caps.terminal.name) && caps.kitty_graphics === false) {
252
252
  setStatus("unsupported");
253
253
 
254
254
  return;
@@ -45,6 +45,7 @@ export async function renderReadyApp(
45
45
  const probe = appReadyProbe();
46
46
  const setup = await testRender(cloneElement(element, { onReady: probe.onReady }), options);
47
47
 
48
+ allowEventLoopUpdates();
48
49
  await probe.ready;
49
50
 
50
51
  return setup;
@@ -117,9 +118,21 @@ export async function settle(setup: TestRendererSetup): Promise<void> {
117
118
  let quietTurns = 0;
118
119
 
119
120
  for (let turn = 0; turn < SETTLE_MAX_TURNS; turn++) {
121
+ if (setup.renderer.isDestroyed) return;
122
+
120
123
  await new Promise((resolve) => setTimeout(resolve, 0));
124
+
125
+ if (setup.renderer.isDestroyed) return;
126
+
121
127
  await setup.renderOnce();
122
128
  await setup.waitForVisualIdle();
129
+
130
+ if (setup.renderer.isDestroyed) return;
131
+
132
+ if (setup.renderer.getSchedulerState().isRendering) {
133
+ await setup.renderOnce();
134
+ }
135
+
123
136
  const frame = paintedFrame(setup);
124
137
 
125
138
  quietTurns = frame === previous ? quietTurns + 1 : 0;
@@ -281,7 +294,20 @@ async function waitForFramePredicate(
281
294
  let lastLog = start;
282
295
 
283
296
  for (;;) {
297
+ if (setup.renderer.isDestroyed) {
298
+ throw new Error(
299
+ `waitFor ${label} failed: renderer was destroyed before frame matched.\nlast frame:\n${lastFrame}`,
300
+ );
301
+ }
302
+
284
303
  await setup.renderOnce();
304
+
305
+ if (setup.renderer.isDestroyed) {
306
+ throw new Error(
307
+ `waitFor ${label} failed: renderer was destroyed before frame matched.\nlast frame:\n${lastFrame}`,
308
+ );
309
+ }
310
+
285
311
  const frame = setup.captureCharFrame();
286
312
 
287
313
  polls++;
@@ -329,9 +355,24 @@ export async function waitForState(
329
355
  let lastLog = start;
330
356
 
331
357
  for (;;) {
332
- if (predicate()) return;
358
+ if (setup.renderer.isDestroyed) {
359
+ if (predicate()) return;
360
+
361
+ throw new Error(
362
+ `waitForState ${label} failed: renderer was destroyed before condition matched.\nlast frame:\n${lastFrame}`,
363
+ );
364
+ }
333
365
 
334
366
  await setup.renderOnce();
367
+
368
+ if (setup.renderer.isDestroyed) {
369
+ if (predicate()) return;
370
+
371
+ throw new Error(
372
+ `waitForState ${label} failed: renderer was destroyed before condition matched.\nlast frame:\n${lastFrame}`,
373
+ );
374
+ }
375
+
335
376
  const frame = setup.captureCharFrame();
336
377
 
337
378
  polls++;
@@ -7,6 +7,7 @@
7
7
 
8
8
  import React from "react";
9
9
  import { createTextAttributes } from "@opentui/core";
10
+ import { useRenderer } from "@opentui/react";
10
11
  import type { Theme } from "../../appearance/theme";
11
12
  import { useComponentTheme } from "../../appearance/components/theme-context";
12
13
  import { filetypeFor, syntaxStyleFor } from "./syntax-highlight";
@@ -38,6 +39,7 @@ export function CodeBlock({
38
39
  theme,
39
40
  }: CodeBlockProps): React.ReactNode {
40
41
  const tokens = useComponentTheme(theme);
42
+ const renderer = useRenderer();
41
43
  const tagColor = changeTag === "new" ? tokens.green : tokens.accent;
42
44
 
43
45
  return (
@@ -69,7 +71,7 @@ export function CodeBlock({
69
71
  <code
70
72
  content={content}
71
73
  filetype={filetypeFor(language)}
72
- syntaxStyle={syntaxStyleFor(tokens)}
74
+ syntaxStyle={syntaxStyleFor(tokens, renderer.nativeScene)}
73
75
  selectable={false}
74
76
  style={{ wrapMode: "none", fg: tokens.textMuted, bg: tokens.elevated }}
75
77
  />
@@ -2,7 +2,7 @@
2
2
  // surfaced at the screen root instead: controls report the hovered label and cursor cell, and this layer
3
3
  // paints it one row below, nudged left when it would run past the right edge.
4
4
 
5
- import React, { createContext, useContext, useMemo, useState } from "react";
5
+ import React, { createContext, useContext, useMemo, useRef, useState } from "react";
6
6
  import { useTerminalDimensions } from "@opentui/react";
7
7
  import { DARK, type Theme } from "../../appearance/theme";
8
8
 
@@ -15,11 +15,13 @@ interface TooltipTarget {
15
15
  interface TooltipApi {
16
16
  showTooltip: (label: string, x: number, y: number) => void;
17
17
  hideTooltip: () => void;
18
+ dismissTooltip: (x: number, y: number) => void;
18
19
  }
19
20
 
20
21
  const TooltipContext = createContext<TooltipApi>({
21
22
  showTooltip: () => {},
22
23
  hideTooltip: () => {},
24
+ dismissTooltip: () => {},
23
25
  });
24
26
 
25
27
  /** Report or clear the hovered control's tooltip; a no-op outside a TooltipProvider. */
@@ -38,10 +40,29 @@ export function TooltipProvider({
38
40
  const tokens = theme ?? DARK;
39
41
  const { width, height } = useTerminalDimensions();
40
42
  const [target, setTarget] = useState<TooltipTarget | null>(null);
43
+ const suppressedCell = useRef<{ x: number; y: number } | null>(null);
41
44
  const api = useMemo<TooltipApi>(
42
45
  () => ({
43
- showTooltip: (label, x, y) => setTarget({ label, x, y }),
44
- hideTooltip: () => setTarget(null),
46
+ showTooltip: (label, x, y) => {
47
+ if (
48
+ suppressedCell.current &&
49
+ suppressedCell.current.x === x &&
50
+ suppressedCell.current.y === y
51
+ ) {
52
+ return;
53
+ }
54
+
55
+ suppressedCell.current = null;
56
+ setTarget({ label, x, y });
57
+ },
58
+ hideTooltip: () => {
59
+ suppressedCell.current = null;
60
+ setTarget(null);
61
+ },
62
+ dismissTooltip: (x, y) => {
63
+ suppressedCell.current = { x, y };
64
+ setTarget(null);
65
+ },
45
66
  }),
46
67
  [],
47
68
  );
@@ -32,11 +32,11 @@ export function IconButton({
32
32
  }: IconButtonProps): React.ReactNode {
33
33
  const tokens = theme ?? DARK;
34
34
  const resolved = color ?? (disabled ? tokens.textDim : active ? tokens.accent : tokens.textMuted);
35
- const { showTooltip, hideTooltip } = useTooltip();
35
+ const { showTooltip, hideTooltip, dismissTooltip } = useTooltip();
36
36
  // a button press is its own action - never let it bubble to a clickable row behind it
37
37
  const handleMouseUp = (event: TerminalMouseEvent): void => {
38
38
  event.stopPropagation();
39
- hideTooltip();
39
+ dismissTooltip(event.x, event.y);
40
40
  onPress?.();
41
41
  };
42
42
 
@@ -11,6 +11,8 @@ import {
11
11
  infoStringToFiletype,
12
12
  extensionToFiletype,
13
13
  basenameToFiletype,
14
+ type NativeResourceOwner,
15
+ type ResourceContext,
14
16
  } from "@opentui/core";
15
17
  import type { Theme } from "../../appearance/theme";
16
18
 
@@ -86,16 +88,23 @@ function syntaxGroupStyles(theme: Theme): SyntaxGroupStyles {
86
88
  };
87
89
  }
88
90
 
89
- const syntaxStyleCache = new WeakMap<Theme, SyntaxStyle>();
91
+ const syntaxStyleCache = new WeakMap<ResourceContext, WeakMap<Theme, SyntaxStyle>>();
90
92
 
91
- export function syntaxStyleFor(theme: Theme): SyntaxStyle {
92
- const cached = syntaxStyleCache.get(theme);
93
+ export function syntaxStyleFor(theme: Theme, owner: NativeResourceOwner): SyntaxStyle {
94
+ let byTheme = syntaxStyleCache.get(owner.resourceContext);
95
+
96
+ if (!byTheme) {
97
+ byTheme = new WeakMap<Theme, SyntaxStyle>();
98
+ syntaxStyleCache.set(owner.resourceContext, byTheme);
99
+ }
100
+
101
+ const cached = byTheme.get(theme);
93
102
 
94
103
  if (cached) return cached;
95
104
 
96
- const style = SyntaxStyle.fromStyles(syntaxGroupStyles(theme));
105
+ const style = SyntaxStyle.fromStyles(syntaxGroupStyles(theme), owner);
97
106
 
98
- syntaxStyleCache.set(theme, style);
107
+ byTheme.set(theme, style);
99
108
 
100
109
  return style;
101
110
  }
@@ -106,20 +115,27 @@ export interface ReferenceStyle {
106
115
  styleId: number;
107
116
  }
108
117
 
109
- const referenceStyleCache = new WeakMap<Theme, ReferenceStyle>();
118
+ const referenceStyleCache = new WeakMap<ResourceContext, WeakMap<Theme, ReferenceStyle>>();
119
+
120
+ export function referenceStyleFor(theme: Theme, owner: NativeResourceOwner): ReferenceStyle {
121
+ let byTheme = referenceStyleCache.get(owner.resourceContext);
122
+
123
+ if (!byTheme) {
124
+ byTheme = new WeakMap<Theme, ReferenceStyle>();
125
+ referenceStyleCache.set(owner.resourceContext, byTheme);
126
+ }
110
127
 
111
- export function referenceStyleFor(theme: Theme): ReferenceStyle {
112
- const cached = referenceStyleCache.get(theme);
128
+ const cached = byTheme.get(theme);
113
129
 
114
130
  if (cached) return cached;
115
131
 
116
132
  const entry: ReferenceStyle = {
117
- style: SyntaxStyle.fromStyles({ reference: { fg: theme.accent } }),
133
+ style: SyntaxStyle.fromStyles({ reference: { fg: theme.accent } }, owner),
118
134
  styleId: 0,
119
135
  };
120
136
 
121
137
  entry.styleId = entry.style.getStyleId("reference") ?? 0;
122
- referenceStyleCache.set(theme, entry);
138
+ byTheme.set(theme, entry);
123
139
 
124
140
  return entry;
125
141
  }
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { useEffect, useState } from "react";
9
- import { useRenderer } from "@opentui/react";
9
+ import { flushSync, useRenderer } from "@opentui/react";
10
10
 
11
11
  type Renderer = NonNullable<ReturnType<typeof useRenderer>>;
12
12
  type Measure = () => void;
@@ -18,13 +18,24 @@ interface FrameSubscriptions {
18
18
 
19
19
  const subscribers = new WeakMap<Renderer, FrameSubscriptions>();
20
20
 
21
+ declare global {
22
+ var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
23
+ }
24
+
25
+ function flushFrameMeasures(measures: ReadonlySet<Measure>): void {
26
+ globalThis.IS_REACT_ACT_ENVIRONMENT = false;
27
+ flushSync(() => {
28
+ for (const subscriber of measures) subscriber();
29
+ });
30
+ }
31
+
21
32
  export function subscribeToFrames(renderer: Renderer, measure: Measure): () => void {
22
33
  let subscription = subscribers.get(renderer);
23
34
 
24
35
  if (!subscription) {
25
36
  const measures = new Set<Measure>();
26
37
  const notify = (): void => {
27
- for (const subscriber of measures) subscriber();
38
+ flushFrameMeasures(measures);
28
39
  };
29
40
 
30
41
  subscription = { measures, notify };
@@ -150,6 +150,10 @@ export function useTerminalVirtualizer(options: TerminalVirtualizerOptions): Ter
150
150
 
151
151
  // the offset reporter the core registered, so a scroll the core asks for reports at once
152
152
  const reportOffset = useRef<(() => void) | null>(null);
153
+ const pendingScroll = useRef<{
154
+ index: number;
155
+ align: "auto" | "start" | "center" | "end";
156
+ } | null>(null);
153
157
 
154
158
  // a frame-event poll stands in for scroll and resize events: the renderer fires it after every
155
159
  // render, and the callbacks only fire the core when the value actually changed
@@ -241,6 +245,13 @@ export function useTerminalVirtualizer(options: TerminalVirtualizerOptions): Ter
241
245
  for (const element of virtualizer.elementsCache.values()) {
242
246
  virtualizer.measureElement(element);
243
247
  }
248
+
249
+ if (pendingScroll.current) {
250
+ const target = pendingScroll.current;
251
+
252
+ pendingScroll.current = null;
253
+ virtualizer.scrollToIndex(target.index, { align: target.align });
254
+ }
244
255
  }),
245
256
  // eslint-disable-next-line react-hooks/exhaustive-deps
246
257
  [virtualizer, renderer],
@@ -262,8 +273,14 @@ export function useTerminalVirtualizer(options: TerminalVirtualizerOptions): Ter
262
273
  if (virtualizer.elementsCache.get(index) === view) virtualizer.elementsCache.delete(index);
263
274
  };
264
275
  },
265
- scrollToIndex: (index, align = "auto") => virtualizer.scrollToIndex(index, { align }),
276
+ scrollToIndex: (index, align = "auto") => {
277
+ pendingScroll.current = { index, align };
278
+ virtualizer.scrollToIndex(index, { align });
279
+ },
266
280
  startOfIndex: (index) => virtualizer.getOffsetForIndex(index, "start")?.[0],
267
- scrollToOffset: (offset) => virtualizer.scrollToOffset(offset, { align: "start" }),
281
+ scrollToOffset: (offset) => {
282
+ pendingScroll.current = null;
283
+ virtualizer.scrollToOffset(offset, { align: "start" });
284
+ },
268
285
  };
269
286
  }
@@ -111,6 +111,7 @@ test("Split is disabled at eight tiles and when a tile is too small", async () =
111
111
 
112
112
  expect(button).toBeDefined();
113
113
  await setup.mockMouse.click(button!.x, button!.y);
114
+ await setup.renderOnce();
114
115
  expect(setup.captureCharFrame()).not.toContain("right →");
115
116
  expect(splitCalls).toBe(0);
116
117
  setup.renderer.destroy();
@@ -7,6 +7,7 @@
7
7
  import { ScrollArea } from "../../ui/components/ScrollArea";
8
8
  import React, { useEffect, useRef, useState } from "react";
9
9
  import type { CodeRenderable } from "@opentui/core";
10
+ import { useRenderer } from "@opentui/react";
10
11
  import type { Theme } from "../../appearance/theme";
11
12
  import { useComponentTheme } from "../../appearance/components/theme-context";
12
13
  import { filetypeForPath, syntaxStyleFor } from "../../ui/components/syntax-highlight";
@@ -29,6 +30,7 @@ export function FileContentsView({
29
30
  theme,
30
31
  }: FileContentsViewProps): React.ReactNode {
31
32
  const tokens = useComponentTheme(theme);
33
+ const renderer = useRenderer();
32
34
  const [loaded, setLoaded] = useState<FileLoad | null>(null);
33
35
  // The gutter mirrors the code renderable's line info, so it needs the mounted
34
36
  // instance; a state-backed ref rebinds the target once the code renderable exists.
@@ -92,7 +94,7 @@ export function FileContentsView({
92
94
  ref={setCodeTarget}
93
95
  content={content}
94
96
  filetype={filetypeForPath(path)}
95
- syntaxStyle={syntaxStyleFor(tokens)}
97
+ syntaxStyle={syntaxStyleFor(tokens, renderer.nativeScene)}
96
98
  selectable={false}
97
99
  style={{ wrapMode: "none", fg: tokens.text }}
98
100
  />