@cueloop/client 0.1.0-alpha.111 → 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 +5 -5
- package/src/annotations/components/AnnotationCards.tsx +6 -2
- package/src/annotations/use-annotation-surface.tsx +14 -1
- package/src/app/App.workbench.test.tsx +5 -1
- package/src/diff/components/DiffContentView.scroll.test.tsx +183 -1
- package/src/diff/components/DiffContentView.tsx +68 -5
- package/src/terminal/pty.ts +8 -1
- package/src/thread/thread-controller.ts +3 -0
- package/src/workbench/components/GridTabContent.test.tsx +99 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/client",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
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.
|
|
31
|
-
"@cueloop/extension-api": "0.1.0-alpha.
|
|
32
|
-
"@cueloop/integration-obsidian": "0.1.0-alpha.
|
|
33
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
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 {
|
|
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.
|
|
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
|
|
700
|
+
if (revealItem === undefined) return;
|
|
703
701
|
|
|
704
|
-
|
|
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
|
|
package/src/terminal/pty.ts
CHANGED
|
@@ -27,6 +27,7 @@ export interface PtyForkOptions {
|
|
|
27
27
|
rows?: number;
|
|
28
28
|
cwd?: string;
|
|
29
29
|
env?: Record<string, string>;
|
|
30
|
+
readPollMaxMs?: number;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
export interface ExitEvent {
|
|
@@ -127,6 +128,7 @@ class Pty implements IPty {
|
|
|
127
128
|
private processId: number;
|
|
128
129
|
private columns: number;
|
|
129
130
|
private rowCount: number;
|
|
131
|
+
private readonly readPollMaxMs: number;
|
|
130
132
|
private closing = false;
|
|
131
133
|
private reading = false;
|
|
132
134
|
private readonly decoder = new TextDecoder("utf-8");
|
|
@@ -139,6 +141,11 @@ class Pty implements IPty {
|
|
|
139
141
|
args: string[],
|
|
140
142
|
options: PtyForkOptions,
|
|
141
143
|
) {
|
|
144
|
+
this.readPollMaxMs = options.readPollMaxMs ?? READ_IDLE_MAX_MS;
|
|
145
|
+
|
|
146
|
+
if (!Number.isFinite(this.readPollMaxMs) || this.readPollMaxMs < 1)
|
|
147
|
+
throw new Error("PTY read poll limit must be at least one millisecond");
|
|
148
|
+
|
|
142
149
|
this.columns = options.cols ?? DEFAULT_COLS;
|
|
143
150
|
this.rowCount = options.rows ?? DEFAULT_ROWS;
|
|
144
151
|
const cwd = options.cwd ?? process.cwd();
|
|
@@ -236,7 +243,7 @@ class Pty implements IPty {
|
|
|
236
243
|
this.closing = true;
|
|
237
244
|
this.exitEvent.fire({ exitCode });
|
|
238
245
|
} else {
|
|
239
|
-
const delay = Math.min(
|
|
246
|
+
const delay = Math.min(this.readPollMaxMs, READ_IDLE_MS * 2 ** Math.min(idlePolls, 5));
|
|
240
247
|
|
|
241
248
|
idlePolls += 1;
|
|
242
249
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
@@ -479,6 +479,9 @@ class Controller implements ReviewController {
|
|
|
479
479
|
|
|
480
480
|
this.client = client;
|
|
481
481
|
client.onEvent((event) => {
|
|
482
|
+
// Agent transcripts have their own observer; they do not change Thread records or the inbox.
|
|
483
|
+
if (event.event === "agent.updated") return;
|
|
484
|
+
|
|
482
485
|
// another controller/observer changed state: re-fetch the active session's content
|
|
483
486
|
const session = this.snapshot.session;
|
|
484
487
|
|
|
@@ -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
|
+
});
|