@cueloop/client 0.1.0-alpha.112 → 0.1.0-alpha.113

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.112",
3
+ "version": "0.1.0-alpha.113",
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,10 +27,10 @@
27
27
  "access": "public"
28
28
  },
29
29
  "dependencies": {
30
- "@cueloop/daemon": "0.1.0-alpha.112",
31
- "@cueloop/extension-api": "0.1.0-alpha.112",
32
- "@cueloop/integration-obsidian": "0.1.0-alpha.112",
33
- "@cueloop/schema": "0.1.0-alpha.112",
30
+ "@cueloop/daemon": "0.1.0-alpha.113",
31
+ "@cueloop/extension-api": "0.1.0-alpha.113",
32
+ "@cueloop/integration-obsidian": "0.1.0-alpha.113",
33
+ "@cueloop/schema": "0.1.0-alpha.113",
34
34
  "@opentui/core": "^0.6.8",
35
35
  "@opentui/keymap": "0.6.8",
36
36
  "@opentui/react": "^0.6.8",
@@ -58,6 +58,7 @@ export function Composer({
58
58
  placeholder,
59
59
  agentEnabled = false,
60
60
  focusRequest,
61
+ containerRef,
61
62
  }: {
62
63
  seed: string;
63
64
  glyph: string | null;
@@ -68,6 +69,7 @@ export function Composer({
68
69
  placeholder?: string;
69
70
  agentEnabled?: boolean;
70
71
  focusRequest?: number;
72
+ containerRef?: React.RefObject<BoxRenderable | null>;
71
73
  }): React.ReactNode {
72
74
  const renderer = useRenderer();
73
75
  const editorRef = useRef<TextareaRenderable | null>(null);
@@ -115,7 +117,7 @@ export function Composer({
115
117
  }, [focusRequest]);
116
118
 
117
119
  return (
118
- <box style={{ flexDirection: "row" }}>
120
+ <box ref={containerRef} style={{ flexDirection: "row" }}>
119
121
  {glyph !== null ? (
120
122
  <text selectable={false} fg={tokens.text} style={{ flexShrink: 0 }}>{`${glyph} `}</text>
121
123
  ) : null}
@@ -200,16 +202,18 @@ export function ComposerPalette({
200
202
  slashItems,
201
203
  slashIndex,
202
204
  tokens,
205
+ containerRef,
203
206
  }: {
204
207
  slashActive: boolean;
205
208
  slashItems: SlashItem[];
206
209
  slashIndex: number;
207
210
  tokens: Theme;
211
+ containerRef?: React.RefObject<BoxRenderable | null>;
208
212
  }): React.ReactNode {
209
213
  if (!slashActive) return null;
210
214
 
211
215
  return (
212
- <box style={{ flexDirection: "column", marginLeft: 3 }}>
216
+ <box ref={containerRef} style={{ flexDirection: "column", marginLeft: 3 }}>
213
217
  <SlashList
214
218
  items={slashItems}
215
219
  selected={Math.min(slashIndex, Math.max(0, slashItems.length - 1))}
@@ -13,7 +13,12 @@
13
13
  */
14
14
 
15
15
  import React, { useContext, useEffect, useEffectEvent, useRef, useState } from "react";
16
- import type { KeyEvent, MouseEvent as TerminalMouseEvent, TextRenderable } from "@opentui/core";
16
+ import type {
17
+ BoxRenderable,
18
+ KeyEvent,
19
+ MouseEvent as TerminalMouseEvent,
20
+ TextRenderable,
21
+ } from "@opentui/core";
17
22
  import { flushSync } from "@opentui/react";
18
23
  import { useSharedKeyboard } from "../keyboard/use-shared-keyboard";
19
24
  import { agentInputTarget, type Annotation, type Thread } from "@cueloop/schema";
@@ -133,6 +138,8 @@ export interface AnnotationSurface {
133
138
  cursor: number;
134
139
  head: TextPosition;
135
140
  compose: ComposeState | null;
141
+ composerRef: React.RefObject<BoxRenderable | null>;
142
+ composerPaletteRef: React.RefObject<BoxRenderable | null>;
136
143
  navMode: boolean;
137
144
  focusedDiscussion: string | null;
138
145
  revealBlockIndex: number;
@@ -312,6 +319,8 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
312
319
  const [caretOffset, setCaretOffset] = useState(0);
313
320
  const [slashIndex, setSlashIndex] = useState(0);
314
321
  const composerReady = useRef(false);
322
+ const composerRef = useRef<BoxRenderable | null>(null);
323
+ const composerPaletteRef = useRef<BoxRenderable | null>(null);
315
324
  const readComposerText = useRef<(() => string) | null>(null);
316
325
  const composeRef = useRef<ComposeState | null>(null);
317
326
  const promptDraft = useRef("");
@@ -1008,6 +1017,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
1008
1017
  const composerNode = compose ? (
1009
1018
  <Composer
1010
1019
  key={compose.seed}
1020
+ containerRef={composerRef}
1011
1021
  seed={compose.seed}
1012
1022
  glyph={compose.kind === "prompt" ? null : "●"}
1013
1023
  tokens={tokens}
@@ -1028,6 +1038,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
1028
1038
  const paletteNode = (
1029
1039
  <ComposerPalette
1030
1040
  key="composer-palette"
1041
+ containerRef={composerPaletteRef}
1031
1042
  slashActive={slashActive}
1032
1043
  slashItems={slashItems}
1033
1044
  slashIndex={slashIndex}
@@ -1343,6 +1354,8 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
1343
1354
  cursor,
1344
1355
  head,
1345
1356
  compose,
1357
+ composerRef,
1358
+ composerPaletteRef,
1346
1359
  navMode,
1347
1360
  focusedDiscussion,
1348
1361
  revealBlockIndex,
@@ -785,7 +785,11 @@ describe("the bare-launch welcome shell", () => {
785
785
  const welcome = locateText(setup, "Welcome");
786
786
 
787
787
  await setup.mockMouse.click(welcome.column + 8, welcome.row);
788
- await setup.mockMouse.click(diffToggleColumn(setup), HEADER_ROW);
788
+ const header = (await captureSettledFrame(setup)).split("\n")[HEADER_ROW]!;
789
+ const changesColumn = header.lastIndexOf("changes");
790
+
791
+ expect(changesColumn).toBeGreaterThanOrEqual(0);
792
+ await setup.mockMouse.click(changesColumn, HEADER_ROW);
789
793
  await waitForText(setup, "edited in the working tree");
790
794
  const frame = setup.captureCharFrame();
791
795
 
@@ -9,7 +9,7 @@ import { DiffContentView } from "./DiffContentView";
9
9
  import { diffRows, marksByRows } from "../view-diff";
10
10
  import { DARK } from "../../appearance/theme";
11
11
  import { annotationPaletteFor } from "../../annotations/annotation-palette";
12
- import { press, settle, typeText } from "../../testing/test-support";
12
+ import { dragText, press, settle, typeText } from "../../testing/test-support";
13
13
  import { fixtureDiffSession } from "../../stories/story-fixtures";
14
14
 
15
15
  const noop = (): void => {};
@@ -380,6 +380,17 @@ test("opening a comment on the diff's last line reveals its composer", async ()
380
380
  await typeText(setup, "bottom comment");
381
381
 
382
382
  expect(setup.captureCharFrame()).toContain("● bottom comment");
383
+ const scrollbox = findById(setup.renderer.root, "diff-scroll");
384
+
385
+ if (!(scrollbox instanceof ScrollBoxRenderable)) throw new Error("diff scrollbox missing");
386
+
387
+ const before = scrollbox.scrollTop;
388
+
389
+ await press(setup, "enter");
390
+ await typeText(setup, "second draft line");
391
+ expect(setup.captureCharFrame()).toContain("● bottom comment");
392
+ expect(setup.captureCharFrame()).toContain("second draft line");
393
+ expect(scrollbox.scrollTop).toBe(before + 1);
383
394
  setup.renderer.destroy();
384
395
  }, 25000);
385
396
 
@@ -441,3 +452,174 @@ test("Up keeps the caret on the first screen row while wrapped lines scroll", as
441
452
  expect(scrolled).toBe(true);
442
453
  setup.renderer.destroy();
443
454
  });
455
+
456
+ test("opening and typing a comment preserves a visible selection's screen position", async () => {
457
+ const rows = diffRows(tallPatch(100));
458
+ const setup = await testRender(
459
+ <DiffContentView
460
+ rows={rows}
461
+ session={fixtureDiffSession()}
462
+ marks={marksByRows([], rows)}
463
+ quickActions={[]}
464
+ observer={false}
465
+ onAnnotate={noop}
466
+ onReply={noop}
467
+ onUpdateAnnotation={noop}
468
+ onExit={noop}
469
+ />,
470
+ { width: 80, height: 20 },
471
+ );
472
+
473
+ try {
474
+ await settle(setup);
475
+ for (let step = 0; step < 60; step++) {
476
+ await press(setup, "down");
477
+ }
478
+ const scrollbox = findById(setup.renderer.root, "diff-scroll");
479
+
480
+ if (!(scrollbox instanceof ScrollBoxRenderable)) throw new Error("diff scrollbox missing");
481
+
482
+ const firstVisible = setup.captureCharFrame().match(/line (\d{2})/);
483
+
484
+ if (!firstVisible) throw new Error("diff rows missing");
485
+
486
+ const first = Number(firstVisible[1]);
487
+ const startText = `line ${String(first + 2).padStart(2, "0")}`;
488
+ const endText = `line ${String(first + 5).padStart(2, "0")}`;
489
+
490
+ expect(scrollbox.scrollTop).toBeGreaterThan(0);
491
+ await dragText(setup, startText, endText, endText.length);
492
+ const before = scrollbox.scrollTop;
493
+ const selectionRow = setup
494
+ .captureCharFrame()
495
+ .split("\n")
496
+ .findIndex((line) => line.includes(startText));
497
+
498
+ await typeText(setup, "h");
499
+ expect(setup.captureCharFrame()).toContain("● h");
500
+ expect(scrollbox.scrollTop).toBe(before);
501
+ await typeText(setup, "ello world");
502
+ expect(setup.captureCharFrame()).toContain("● hello world");
503
+ expect(scrollbox.scrollTop).toBe(before);
504
+ expect(
505
+ setup
506
+ .captureCharFrame()
507
+ .split("\n")
508
+ .findIndex((line) => line.includes(startText)),
509
+ ).toBe(selectionRow);
510
+ } finally {
511
+ setup.renderer.destroy();
512
+ }
513
+ }, 25000);
514
+
515
+ test("a visible new comment stays in place above a long existing discussion", async () => {
516
+ const rows = diffRows(tallPatch(60));
517
+ const annotation: Annotation = {
518
+ id: "long-discussion",
519
+ kind: "comment",
520
+ body: "Existing discussion text that wraps across many lines. ".repeat(80),
521
+ anchor: { quote: "line 20", prefix: "", suffix: "" },
522
+ createdAt: "2026-01-01T00:00:00Z",
523
+ };
524
+ const setup = await testRender(
525
+ <DiffContentView
526
+ rows={rows}
527
+ session={fixtureDiffSession({ annotations: [annotation] })}
528
+ marks={marksByRows([annotation], rows)}
529
+ quickActions={[]}
530
+ observer={false}
531
+ onAnnotate={noop}
532
+ onReply={noop}
533
+ onUpdateAnnotation={noop}
534
+ onExit={noop}
535
+ />,
536
+ { width: 80, height: 20 },
537
+ );
538
+
539
+ try {
540
+ await settle(setup);
541
+ const scrollbox = findById(setup.renderer.root, "diff-scroll");
542
+
543
+ if (!(scrollbox instanceof ScrollBoxRenderable)) throw new Error("diff scrollbox missing");
544
+
545
+ scrollbox.scrollBy(14);
546
+ await settle(setup);
547
+ await dragText(setup, "line 16", "line 20", "line 20".length);
548
+ const before = scrollbox.scrollTop;
549
+
550
+ await typeText(setup, "new comment");
551
+ expect(setup.captureCharFrame()).toContain("● new comment");
552
+ expect(scrollbox.scrollTop).toBe(before);
553
+ scrollbox.scrollBy(30);
554
+ await settle(setup);
555
+ expect(scrollbox.scrollTop).toBe(before + 30);
556
+ } finally {
557
+ setup.renderer.destroy();
558
+ }
559
+ });
560
+
561
+ test("typing reveals a held selection whose virtual row was scrolled out", async () => {
562
+ const rows = diffRows(tallPatch(100));
563
+ const setup = await testRender(
564
+ <DiffContentView
565
+ rows={rows}
566
+ session={fixtureDiffSession()}
567
+ marks={marksByRows([], rows)}
568
+ quickActions={[]}
569
+ observer={false}
570
+ onAnnotate={noop}
571
+ onReply={noop}
572
+ onUpdateAnnotation={noop}
573
+ onExit={noop}
574
+ />,
575
+ { width: 80, height: 20 },
576
+ );
577
+
578
+ try {
579
+ await settle(setup);
580
+ await dragText(setup, "line 04", "line 06", "line 06".length);
581
+ const scrollbox = findById(setup.renderer.root, "diff-scroll");
582
+
583
+ if (!(scrollbox instanceof ScrollBoxRenderable)) throw new Error("diff scrollbox missing");
584
+
585
+ scrollbox.scrollBy(70);
586
+ await settle(setup);
587
+ expect(setup.captureCharFrame()).not.toContain("line 06");
588
+ await typeText(setup, "held selection draft");
589
+ expect(setup.captureCharFrame()).toContain("● held selection draft");
590
+ } finally {
591
+ setup.renderer.destroy();
592
+ }
593
+ });
594
+
595
+ test("opening slash choices at the bottom reveals the input and its action list", async () => {
596
+ const rows = diffRows(tallPatch(24));
597
+ const setup = await testRender(
598
+ <DiffContentView
599
+ rows={rows}
600
+ session={fixtureDiffSession()}
601
+ marks={marksByRows([], rows)}
602
+ quickActions={[{ prompt: "Explain" }, { prompt: "Review" }, { prompt: "Test" }]}
603
+ observer={false}
604
+ onAnnotate={noop}
605
+ onReply={noop}
606
+ onUpdateAnnotation={noop}
607
+ onExit={noop}
608
+ />,
609
+ { width: 60, height: 10 },
610
+ );
611
+
612
+ try {
613
+ await settle(setup);
614
+ for (let step = 0; step < rows.length + 2; step++) {
615
+ await press(setup, "down");
616
+ }
617
+ await typeText(setup, "/");
618
+ expect(setup.captureCharFrame()).toContain("● /");
619
+ expect(setup.captureCharFrame()).toContain("explain");
620
+ expect(setup.captureCharFrame()).toContain("review");
621
+ expect(setup.captureCharFrame()).toContain("test");
622
+ } finally {
623
+ setup.renderer.destroy();
624
+ }
625
+ });
@@ -12,6 +12,7 @@ import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from "re
12
12
  import {
13
13
  MacOSScrollAccel,
14
14
  createTextAttributes,
15
+ type BoxRenderable,
15
16
  type KeyEvent,
16
17
  type ScrollBoxRenderable,
17
18
  } from "@opentui/core";
@@ -693,18 +694,80 @@ export function DiffContentView({
693
694
  return distance > 1;
694
695
  };
695
696
 
696
- // An opening card or focused discussion reveals its owning item. Ordinary caret
697
- // movement reveals the exact visual line, since an item's first wrapped line may
698
- // sit above the viewport while the caret is already on screen.
699
697
  const revealItem = layout.itemOfRow[surface.revealBlockIndex];
700
698
 
701
699
  useEffect(() => {
702
- if (revealItem === undefined || (!surface.compose && !surface.focusedDiscussion)) return;
700
+ if (revealItem === undefined) return;
703
701
 
704
- virtual.scrollToIndex(revealItem, surface.compose ? "end" : "auto");
702
+ if (surface.compose) {
703
+ if (!surface.composerRef.current) virtual.scrollToIndex(revealItem, "start");
704
+ } else if (surface.focusedDiscussion) {
705
+ virtual.scrollToIndex(revealItem, "auto");
706
+ }
705
707
  // eslint-disable-next-line react-hooks/exhaustive-deps
706
708
  }, [surface.revealBlockIndex, surface.compose, surface.focusedDiscussion]);
707
709
 
710
+ const revealedComposer = useRef<{ node: BoxRenderable; height: number } | null>(null);
711
+ const composerBounds = useFrameMeasure<{
712
+ node: BoxRenderable;
713
+ top: number;
714
+ bottom: number;
715
+ } | null>(
716
+ () => {
717
+ const composer = surface.composerRef.current;
718
+ const palette = surface.composerPaletteRef.current;
719
+
720
+ if (!composer || composer.height <= 0) return null;
721
+
722
+ const composerBottom = composer.y + composer.height - 1;
723
+
724
+ return {
725
+ node: composer,
726
+ top: composer.y,
727
+ bottom:
728
+ palette && palette.height > 0
729
+ ? Math.max(composerBottom, palette.y + palette.height - 1)
730
+ : composerBottom,
731
+ };
732
+ },
733
+ (left, right) =>
734
+ left?.node === right?.node && left?.top === right?.top && left?.bottom === right?.bottom,
735
+ null,
736
+ Boolean(surface.compose),
737
+ );
738
+
739
+ useLayoutEffect(() => {
740
+ const scrollbox = scrollRef.current;
741
+
742
+ if (
743
+ !surface.compose ||
744
+ !composerBounds ||
745
+ !scrollbox ||
746
+ composerBounds.node !== surface.composerRef.current
747
+ )
748
+ return;
749
+
750
+ const height = composerBounds.bottom - composerBounds.top + 1;
751
+
752
+ // Only mounting or resizing the input can override a manual scroll.
753
+ if (
754
+ revealedComposer.current?.node === composerBounds.node &&
755
+ revealedComposer.current.height === height
756
+ )
757
+ return;
758
+
759
+ revealedComposer.current = { node: composerBounds.node, height };
760
+ const top = scrollbox.viewport.y;
761
+ const bottom = top + scrollbox.viewport.height - 1;
762
+
763
+ if (composerBounds.bottom > bottom) scrollbox.scrollBy(composerBounds.bottom - bottom);
764
+ else if (
765
+ composerBounds.top < top &&
766
+ composerBounds.bottom - composerBounds.top < scrollbox.viewport.height
767
+ )
768
+ scrollbox.scrollBy(composerBounds.top - top);
769
+ }, [surface.compose, surface.composerRef, composerBounds]);
770
+
708
771
  useLayoutEffect(() => {
709
772
  if (revealItem === undefined || surface.compose || surface.focusedDiscussion) return;
710
773
 
@@ -0,0 +1,99 @@
1
+ import { expect, mock, test } from "bun:test";
2
+ import React, { useEffect, useState } from "react";
3
+ import { testRender } from "@opentui/react/test-utils";
4
+ import { GridTabContent } from "./GridTabContent";
5
+ import { diffRows, type DiffRow } from "../../diff/view-diff";
6
+ import { fixtureDiffSession } from "../../stories/story-fixtures";
7
+ import { DARK } from "../../appearance/theme";
8
+ import { press, waitForText } from "../../testing/test-support";
9
+
10
+ function createTestFilePatch(secondName: string): string {
11
+ return [
12
+ "diff --git a/first.ts b/first.ts",
13
+ "--- /dev/null",
14
+ "+++ b/first.ts",
15
+ "@@ -0,0 +1,3 @@",
16
+ "+export const firstOld = 1;",
17
+ "+export const firstNext = 2;",
18
+ "+export const firstLast = 3;",
19
+ "diff --git a/second.ts b/second.ts",
20
+ "--- /dev/null",
21
+ "+++ b/second.ts",
22
+ "@@ -0,0 +1,1 @@",
23
+ `+export const ${secondName} = 4;`,
24
+ "",
25
+ ].join("\n");
26
+ }
27
+
28
+ test("a file tab follows file switches and fresh rows after cursor updates", async () => {
29
+ const initialRows = diffRows(createTestFilePatch("secondOld"));
30
+ const session = fixtureDiffSession();
31
+ const cursorChanges = mock((_rowIndex: number) => {});
32
+ const noop = () => {};
33
+ let changePath: (path: string) => void = noop;
34
+ let changeRows: (rows: DiffRow[]) => void = noop;
35
+ let changeView: (view: "diff" | "contents") => void = noop;
36
+
37
+ function FileReview() {
38
+ const [path, setPath] = useState("first.ts");
39
+ const [rows, setRows] = useState(initialRows);
40
+ const [fileView, setFileView] = useState<"diff" | "contents">("diff");
41
+ const [, setCursor] = useState(0);
42
+
43
+ useEffect(() => {
44
+ changePath = setPath;
45
+ changeRows = setRows;
46
+ changeView = setFileView;
47
+ }, []);
48
+
49
+ return (
50
+ <GridTabContent
51
+ tab={{ id: "file", kind: "file", path, label: path, fileView }}
52
+ rows={rows}
53
+ surface={{
54
+ session,
55
+ quickActions: [],
56
+ observer: false,
57
+ onCursorChange: (rowIndex) => {
58
+ cursorChanges(rowIndex);
59
+ setCursor(rowIndex);
60
+ },
61
+ onAnnotate: noop,
62
+ onReply: noop,
63
+ onUpdateAnnotation: noop,
64
+ onExit: noop,
65
+ }}
66
+ rejectedRows={new Set()}
67
+ dimmed={false}
68
+ readFile={async () => "Full file contents\n"}
69
+ onAddFileComment={noop}
70
+ theme={DARK}
71
+ />
72
+ );
73
+ }
74
+ const setup = await testRender(<FileReview />, { width: 100, height: 20 });
75
+
76
+ try {
77
+ await waitForText(setup, "firstOld");
78
+ await press(setup, "down");
79
+ await press(setup, "down");
80
+ expect(cursorChanges).toHaveBeenLastCalledWith(
81
+ initialRows.findIndex((row) => row.text.includes("firstLast")),
82
+ );
83
+ expect(setup.captureCharFrame()).toContain("firstLast");
84
+ changePath("second.ts");
85
+ await waitForText(setup, "secondOld");
86
+ expect(setup.captureCharFrame()).not.toContain("firstOld");
87
+ changeRows(diffRows(createTestFilePatch("secondFresh")));
88
+ await waitForText(setup, "secondFresh");
89
+ expect(setup.captureCharFrame()).not.toContain("secondOld");
90
+ changeView("contents");
91
+ await waitForText(setup, "Full file contents");
92
+ expect(setup.captureCharFrame()).not.toContain("secondFresh");
93
+ changeView("diff");
94
+ await waitForText(setup, "secondFresh");
95
+ expect(setup.captureCharFrame()).not.toContain("Full file contents");
96
+ } finally {
97
+ setup.renderer.destroy();
98
+ }
99
+ });