@cueloop/client 0.1.0-alpha.6 → 0.1.0-alpha.61
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/native/build-pty.sh +51 -0
- package/native/build.sh +82 -0
- package/native/darwin-arm64/libcuelooppty.dylib +0 -0
- package/native/darwin-arm64/libcueloopvt.dylib +0 -0
- package/native/src/pty.zig +193 -0
- package/native/src/shim.zig +114 -0
- package/package.json +37 -33
- package/src/App.appearance.test.tsx +51 -0
- package/src/App.autoclose.test.tsx +73 -47
- package/src/App.diff.test.tsx +169 -22
- package/src/App.export.test.tsx +49 -37
- package/src/App.plan-review.test.tsx +714 -0
- package/src/App.prototype.test.tsx +194 -0
- package/src/App.readonly.test.tsx +69 -35
- package/src/App.rendering.test.tsx +59 -31
- package/src/App.review-panel.test.tsx +143 -0
- package/src/App.submit-confirm.test.tsx +229 -0
- package/src/App.test.tsx +139 -68
- package/src/App.toast.test.tsx +77 -0
- package/src/App.tsx +0 -0
- package/src/App.walk.test.tsx +299 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
- package/src/app-key-state.ts +61 -0
- package/src/app-screens.tsx +270 -0
- package/src/app-view-model.ts +307 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +42 -0
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +33 -0
- package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
- package/src/components/AnnotationCard.stories.tsx +94 -0
- package/src/components/AnnotationCard.tsx +250 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +45 -0
- package/src/components/CodeBlock.stories.tsx +42 -0
- package/src/components/CodeBlock.tsx +84 -0
- package/src/components/CompletionOverlay.stories.tsx +23 -0
- package/src/components/CompletionOverlay.tsx +60 -0
- package/src/components/ConfirmCard.stories.tsx +35 -0
- package/src/components/ConfirmCard.tsx +129 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +66 -0
- package/src/components/DiffSheet.scroll.test.tsx +135 -0
- package/src/components/DiffSheet.stories.tsx +78 -0
- package/src/components/DiffSheet.syntax.test.tsx +65 -0
- package/src/components/DiffSheet.tsx +374 -0
- package/src/components/InboxList.stories.tsx +31 -0
- package/src/components/InboxList.tsx +64 -0
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +59 -0
- package/src/components/MarkerPopover.stories.tsx +58 -0
- package/src/components/MarkerPopover.tsx +109 -0
- package/src/components/MenuBar.stories.tsx +37 -0
- package/src/components/MenuBar.tsx +75 -0
- package/src/components/PlanSheet.stories.tsx +113 -0
- package/src/components/PlanSheet.tsx +422 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.tsx +80 -0
- package/src/components/PrototypeSheet.stories.tsx +20 -0
- package/src/components/PrototypeSheet.tsx +450 -0
- package/src/components/ReviewPanel.stories.tsx +86 -0
- package/src/components/ReviewPanel.tsx +103 -0
- package/src/components/ReviewRail.stories.tsx +218 -0
- package/src/components/ReviewRail.tsx +318 -0
- package/src/components/SettingsDialog.stories.tsx +111 -0
- package/src/components/SettingsDialog.tsx +147 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +112 -0
- package/src/components/Toast.stories.tsx +12 -0
- package/src/components/Toast.tsx +45 -0
- package/src/components/WalkWizard.stories.tsx +100 -0
- package/src/components/WalkWizard.tsx +152 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +1857 -0
- package/src/components/agent-launcher.stories.tsx +26 -0
- package/src/components/agent-launcher.test.tsx +50 -0
- package/src/components/agent-launcher.tsx +149 -0
- package/src/components/diff-sheet-layout.test.ts +128 -0
- package/src/components/diff-sheet-layout.ts +184 -0
- package/src/components/plan-sheet-run-style.ts +90 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +52 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +60 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.tsx +66 -0
- package/src/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/components/primitives/StatusBar.tsx +23 -0
- package/src/components/primitives/Tabs.stories.tsx +29 -0
- package/src/components/primitives/Tabs.tsx +102 -0
- package/src/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/components/primitives/Toolbar.tsx +14 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +14 -0
- package/src/components/quick-actions-editor.stories.tsx +54 -0
- package/src/components/quick-actions-editor.tsx +138 -0
- package/src/components/stories-app.tsx +124 -0
- package/src/components/stories.test.tsx +66 -0
- package/src/components/story-fixtures.ts +98 -0
- package/src/components/story.ts +75 -0
- package/src/components/syntax-highlight.ts +102 -0
- package/src/components/terminal-pane.test.tsx +40 -0
- package/src/components/terminal-pane.ts +193 -0
- package/src/components/theme-context.stories.tsx +46 -0
- package/src/components/theme-context.tsx +37 -0
- package/src/components/truncate-text.ts +7 -0
- package/src/config.test.ts +448 -31
- package/src/config.ts +339 -57
- package/src/diff-hunk-curate.test.ts +315 -0
- package/src/diff-hunk-curate.ts +253 -0
- package/src/diff-intraline.test.ts +165 -0
- package/src/diff-intraline.ts +146 -0
- package/src/diff-syntax.test.ts +96 -0
- package/src/diff-syntax.ts +176 -0
- package/src/editor.test.ts +149 -0
- package/src/editor.ts +135 -9
- package/src/ghostty-terminal.test.ts +84 -0
- package/src/ghostty-terminal.ts +176 -0
- package/src/herdr.test.ts +9 -34
- package/src/herdr.ts +17 -21
- package/src/index.ts +10 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +32 -0
- package/src/intent-dispatch.test.ts +499 -0
- package/src/intent-dispatch.ts +585 -0
- package/src/key-bindings.test.ts +156 -0
- package/src/key-bindings.ts +452 -0
- package/src/keymap.test.ts +635 -0
- package/src/keymap.ts +404 -0
- package/src/kitty-image.test.ts +66 -0
- package/src/kitty-image.ts +138 -0
- package/src/prototype-browser.test.ts +56 -0
- package/src/prototype-browser.ts +289 -0
- package/src/pty.test.ts +81 -0
- package/src/pty.ts +237 -0
- package/src/review-panel.test.ts +113 -0
- package/src/review-panel.ts +83 -0
- package/src/run.ts +16 -5
- package/src/serve.test.ts +25 -9
- package/src/serve.ts +19 -8
- package/src/session-controller.curate.test.ts +260 -0
- package/src/session-controller.share.test.ts +320 -0
- package/src/session-controller.ts +993 -0
- package/src/share.ts +120 -0
- package/src/test-support.test.ts +60 -0
- package/src/test-support.ts +197 -0
- package/src/theme-presets.test.ts +51 -0
- package/src/theme-presets.ts +187 -0
- package/src/theme.ts +64 -17
- package/src/use-settings-dialog.tsx +275 -0
- package/src/version.ts +5 -0
- package/src/view-diff.test.ts +22 -8
- package/src/view-diff.ts +39 -23
- package/src/view-plan.regression.test.ts +60 -0
- package/src/view-plan.test.ts +406 -0
- package/src/view-plan.ts +554 -0
- package/src/walk.test.ts +121 -0
- package/src/walk.ts +74 -0
- package/src/syntax.test.ts +0 -41
- package/src/syntax.ts +0 -88
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post-submit hand-back overlay: the agent is unblocked, the reviewer either
|
|
3
|
+
* closes now, opts into auto-close, or dismisses back to the resolved view.
|
|
4
|
+
* The latest status line (e.g. the vault-export path) stays visible here.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import React from "react";
|
|
8
|
+
import type { VerdictKind } from "@cueloop/schema";
|
|
9
|
+
import type { Theme } from "../theme";
|
|
10
|
+
import { useComponentTheme } from "./theme-context";
|
|
11
|
+
|
|
12
|
+
export interface CompletionOverlayProps {
|
|
13
|
+
verdict: VerdictKind;
|
|
14
|
+
completion: { phase: "prompt" } | { phase: "counting"; remaining: number };
|
|
15
|
+
/** Latest status line (e.g. the vault-export path) stays visible here. */
|
|
16
|
+
status: string;
|
|
17
|
+
/** Where focus goes on close (the agent's pane), when known. */
|
|
18
|
+
returnsTo?: string;
|
|
19
|
+
theme?: Theme;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function CompletionOverlay({
|
|
23
|
+
verdict,
|
|
24
|
+
completion,
|
|
25
|
+
status,
|
|
26
|
+
returnsTo,
|
|
27
|
+
theme,
|
|
28
|
+
}: CompletionOverlayProps): React.ReactNode {
|
|
29
|
+
const tokens = useComponentTheme(theme);
|
|
30
|
+
const approved = verdict === "approve";
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<box
|
|
34
|
+
style={{
|
|
35
|
+
width: "100%",
|
|
36
|
+
height: "100%",
|
|
37
|
+
backgroundColor: tokens.background,
|
|
38
|
+
flexDirection: "column",
|
|
39
|
+
justifyContent: "center",
|
|
40
|
+
alignItems: "center",
|
|
41
|
+
}}
|
|
42
|
+
>
|
|
43
|
+
<text fg={approved ? tokens.green : tokens.accent}>
|
|
44
|
+
{approved ? "review approved" : "feedback sent"}
|
|
45
|
+
</text>
|
|
46
|
+
<text> </text>
|
|
47
|
+
<text fg={tokens.text}>
|
|
48
|
+
The agent has your {approved ? "approval" : "feedback"} and is unblocked.
|
|
49
|
+
</text>
|
|
50
|
+
{returnsTo ? <text fg={tokens.textDim}>returning to {returnsTo} on close</text> : null}
|
|
51
|
+
{status ? <text fg={tokens.textDim}>{status}</text> : null}
|
|
52
|
+
<text> </text>
|
|
53
|
+
<text fg={tokens.textDim}>
|
|
54
|
+
close [return]
|
|
55
|
+
{completion.phase === "counting" ? ` · closing in ${completion.remaining}s` : ""}
|
|
56
|
+
{" · return to plan [esc] · always [a]"}
|
|
57
|
+
</text>
|
|
58
|
+
</box>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "./story";
|
|
4
|
+
import { ConfirmCard } from "./ConfirmCard";
|
|
5
|
+
|
|
6
|
+
export const meta: StoryMeta = { title: "ConfirmCard" };
|
|
7
|
+
|
|
8
|
+
const callbacks = {
|
|
9
|
+
onInput: () => {},
|
|
10
|
+
onSelectVerdict: () => {},
|
|
11
|
+
onSubmit: () => {},
|
|
12
|
+
onCancel: () => {},
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const ApproveDefault: Story = {
|
|
16
|
+
render: () => <ConfirmCard verdict="approve" summary="" {...callbacks} />,
|
|
17
|
+
expectedColors: [DARK.green, DARK.accent],
|
|
18
|
+
size: { width: 40, height: 14 },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const RequestChanges: Story = {
|
|
22
|
+
render: () => (
|
|
23
|
+
<ConfirmCard verdict="request_changes" summary="Tighten the steps." {...callbacks} />
|
|
24
|
+
),
|
|
25
|
+
expectedColors: [DARK.red],
|
|
26
|
+
size: { width: 40, height: 14 },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const WithViewedSummary: Story = {
|
|
30
|
+
render: () => (
|
|
31
|
+
<ConfirmCard verdict="approve" summary="" viewedSummary="2/3 files viewed" {...callbacks} />
|
|
32
|
+
),
|
|
33
|
+
expectedColors: [DARK.green, DARK.textDim],
|
|
34
|
+
size: { width: 40, height: 14 },
|
|
35
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Submit button expanded into a bordered confirm card at the rail bottom:
|
|
3
|
+
* the verdict selector (arrow keys or click), the optional summary, and plain
|
|
4
|
+
* word-buttons - key hints live in the status line only. The bordered height
|
|
5
|
+
* derives from the content rows through Card, so layout and render never drift.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React from "react";
|
|
9
|
+
import type { VerdictKind } from "@cueloop/schema";
|
|
10
|
+
import type { Theme } from "../theme";
|
|
11
|
+
import { useComponentTheme } from "./theme-context";
|
|
12
|
+
import { Card } from "./primitives/Card";
|
|
13
|
+
import { Button } from "./primitives/Button";
|
|
14
|
+
import { Toolbar } from "./primitives/Toolbar";
|
|
15
|
+
|
|
16
|
+
export const VERDICTS: VerdictKind[] = ["comment", "approve", "request_changes"];
|
|
17
|
+
|
|
18
|
+
/** Selector words in the confirm card - one word per verdict. */
|
|
19
|
+
export const VERDICT_LABEL: Record<VerdictKind, string> = {
|
|
20
|
+
comment: "Comment",
|
|
21
|
+
approve: "Approve",
|
|
22
|
+
request_changes: "Changes",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export interface ConfirmCardProps {
|
|
26
|
+
verdict: VerdictKind;
|
|
27
|
+
summary: string;
|
|
28
|
+
/**
|
|
29
|
+
* The guided walk's honest coverage line for diff sessions, e.g.
|
|
30
|
+
* "2/3 files viewed". Undefined = no walk data, the row does not render.
|
|
31
|
+
*/
|
|
32
|
+
viewedSummary?: string;
|
|
33
|
+
onInput: (summary: string) => void;
|
|
34
|
+
onSelectVerdict: (verdict: VerdictKind) => void;
|
|
35
|
+
onSubmit: () => void;
|
|
36
|
+
onCancel: () => void;
|
|
37
|
+
theme?: Theme;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 1-row verdict selector, spacer, summary input, spacer, buttons. */
|
|
41
|
+
const CONFIRM_CONTENT_ROWS = 5;
|
|
42
|
+
|
|
43
|
+
export function verdictColor(verdict: VerdictKind, tokens: Theme): string {
|
|
44
|
+
return verdict === "approve"
|
|
45
|
+
? tokens.green
|
|
46
|
+
: verdict === "request_changes"
|
|
47
|
+
? tokens.red
|
|
48
|
+
: tokens.blue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The verdict selector: one row of pressable words, matching the reading
|
|
53
|
+
* direction of a choice between three peers. Selection stays controlled by
|
|
54
|
+
* the grammar (←/→ cycle the verdict); a click selects directly. The
|
|
55
|
+
* selected verdict wears brackets and its color.
|
|
56
|
+
*/
|
|
57
|
+
function VerdictSelector({
|
|
58
|
+
verdict,
|
|
59
|
+
onSelectVerdict,
|
|
60
|
+
theme,
|
|
61
|
+
}: {
|
|
62
|
+
verdict: VerdictKind;
|
|
63
|
+
onSelectVerdict: (verdict: VerdictKind) => void;
|
|
64
|
+
theme?: Theme;
|
|
65
|
+
}): React.ReactNode {
|
|
66
|
+
const tokens = useComponentTheme(theme);
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<box style={{ flexDirection: "row", height: 1 }}>
|
|
70
|
+
{VERDICTS.map((candidate) => (
|
|
71
|
+
<box
|
|
72
|
+
key={candidate}
|
|
73
|
+
style={{ paddingRight: 1 }}
|
|
74
|
+
onMouseUp={() => onSelectVerdict(candidate)}
|
|
75
|
+
>
|
|
76
|
+
<text fg={candidate === verdict ? verdictColor(candidate, tokens) : tokens.textDim}>
|
|
77
|
+
{candidate === verdict
|
|
78
|
+
? `[${VERDICT_LABEL[candidate]}]`
|
|
79
|
+
: ` ${VERDICT_LABEL[candidate]} `}
|
|
80
|
+
</text>
|
|
81
|
+
</box>
|
|
82
|
+
))}
|
|
83
|
+
</box>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function ConfirmCard({
|
|
88
|
+
verdict,
|
|
89
|
+
summary,
|
|
90
|
+
viewedSummary,
|
|
91
|
+
onInput,
|
|
92
|
+
onSelectVerdict,
|
|
93
|
+
onSubmit,
|
|
94
|
+
onCancel,
|
|
95
|
+
theme,
|
|
96
|
+
}: ConfirmCardProps): React.ReactNode {
|
|
97
|
+
const tokens = useComponentTheme(theme);
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<Card
|
|
101
|
+
title=" submit review "
|
|
102
|
+
contentRows={CONFIRM_CONTENT_ROWS + (viewedSummary !== undefined ? 2 : 0)}
|
|
103
|
+
borderColor={tokens.text}
|
|
104
|
+
backgroundColor="transparent"
|
|
105
|
+
marginRight={1}
|
|
106
|
+
theme={theme}
|
|
107
|
+
>
|
|
108
|
+
{viewedSummary !== undefined ? <text fg={tokens.textDim}>{viewedSummary}</text> : null}
|
|
109
|
+
{viewedSummary !== undefined ? <box style={{ height: 1 }} /> : null}
|
|
110
|
+
<VerdictSelector verdict={verdict} onSelectVerdict={onSelectVerdict} theme={theme} />
|
|
111
|
+
<box style={{ height: 1 }} />
|
|
112
|
+
<input
|
|
113
|
+
focused
|
|
114
|
+
value={summary}
|
|
115
|
+
onInput={onInput}
|
|
116
|
+
placeholder="summary for the agent (optional)"
|
|
117
|
+
/>
|
|
118
|
+
<box style={{ height: 1 }} />
|
|
119
|
+
<Toolbar>
|
|
120
|
+
<Button variant="solid" marginRight={2} onPress={onSubmit} theme={theme}>
|
|
121
|
+
{" Submit "}
|
|
122
|
+
</Button>
|
|
123
|
+
<Button onPress={onCancel} theme={theme}>
|
|
124
|
+
{" Cancel "}
|
|
125
|
+
</Button>
|
|
126
|
+
</Toolbar>
|
|
127
|
+
</Card>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "./story";
|
|
4
|
+
import { ConfirmDialog } from "./ConfirmDialog";
|
|
5
|
+
|
|
6
|
+
export const meta: StoryMeta = { title: "ConfirmDialog" };
|
|
7
|
+
|
|
8
|
+
const callbacks = { onConfirm: () => {}, onCancel: () => {} };
|
|
9
|
+
|
|
10
|
+
export const DeletePlan: Story = {
|
|
11
|
+
render: () => (
|
|
12
|
+
<ConfirmDialog
|
|
13
|
+
isOpen
|
|
14
|
+
title=" Delete plan "
|
|
15
|
+
message={'Delete "Auth rollout"? This removes the plan and its review.'}
|
|
16
|
+
{...callbacks}
|
|
17
|
+
/>
|
|
18
|
+
),
|
|
19
|
+
expectedColors: [DARK.accent],
|
|
20
|
+
size: { width: 64, height: 16 },
|
|
21
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A centered yes/no confirmation on the shared Dialog overlay. Presentational:
|
|
3
|
+
* the key grammar (⏎ confirm, esc cancel) is driven by the reducer; the buttons
|
|
4
|
+
* carry the same intents for the mouse. Used for destructive actions like the
|
|
5
|
+
* inbox delete.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React from "react";
|
|
9
|
+
import { useTerminalDimensions } from "@opentui/react";
|
|
10
|
+
import type { Theme } from "../theme";
|
|
11
|
+
import { useComponentTheme } from "./theme-context";
|
|
12
|
+
import { Dialog } from "./primitives/Dialog";
|
|
13
|
+
import { Button } from "./primitives/Button";
|
|
14
|
+
import { Toolbar } from "./primitives/Toolbar";
|
|
15
|
+
|
|
16
|
+
export interface ConfirmDialogProps {
|
|
17
|
+
isOpen: boolean;
|
|
18
|
+
title?: string;
|
|
19
|
+
message: string;
|
|
20
|
+
confirmLabel?: string;
|
|
21
|
+
cancelLabel?: string;
|
|
22
|
+
onConfirm: () => void;
|
|
23
|
+
onCancel: () => void;
|
|
24
|
+
theme?: Theme;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function ConfirmDialog({
|
|
28
|
+
isOpen,
|
|
29
|
+
title,
|
|
30
|
+
message,
|
|
31
|
+
confirmLabel = "Delete",
|
|
32
|
+
cancelLabel = "Cancel",
|
|
33
|
+
onConfirm,
|
|
34
|
+
onCancel,
|
|
35
|
+
theme,
|
|
36
|
+
}: ConfirmDialogProps): React.ReactNode {
|
|
37
|
+
const tokens = useComponentTheme(theme);
|
|
38
|
+
const { width: terminalWidth } = useTerminalDimensions();
|
|
39
|
+
|
|
40
|
+
if (!isOpen) return null;
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<Dialog isOpen title={title} width={Math.min(54, terminalWidth - 6)} height={7} theme={theme}>
|
|
44
|
+
<box
|
|
45
|
+
style={{
|
|
46
|
+
flexDirection: "column",
|
|
47
|
+
flexGrow: 1,
|
|
48
|
+
paddingLeft: 1,
|
|
49
|
+
paddingRight: 1,
|
|
50
|
+
paddingTop: 1,
|
|
51
|
+
}}
|
|
52
|
+
>
|
|
53
|
+
<text fg={tokens.text}>{message}</text>
|
|
54
|
+
<box style={{ flexGrow: 1 }} />
|
|
55
|
+
<Toolbar>
|
|
56
|
+
<Button variant="solid" onPress={onConfirm} theme={theme}>
|
|
57
|
+
{` ${confirmLabel} `}
|
|
58
|
+
</Button>
|
|
59
|
+
<Button onPress={onCancel} theme={theme}>
|
|
60
|
+
{` ${cancelLabel} esc `}
|
|
61
|
+
</Button>
|
|
62
|
+
</Toolbar>
|
|
63
|
+
</box>
|
|
64
|
+
</Dialog>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/** Keyboard scroll is smooth: cursor-follow never drifts past wrapped cards. */
|
|
2
|
+
|
|
3
|
+
import { expect, test } from "bun:test";
|
|
4
|
+
import React, { useState } from "react";
|
|
5
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
6
|
+
import { useKeyboard } from "@opentui/react";
|
|
7
|
+
import type { Renderable, ScrollBoxRenderable } from "@opentui/core";
|
|
8
|
+
import type { Annotation } from "@cueloop/schema";
|
|
9
|
+
import { DiffSheet } from "./DiffSheet";
|
|
10
|
+
import { diffRows, type DiffRow } from "../view-diff";
|
|
11
|
+
import { DARK } from "../theme";
|
|
12
|
+
import { press } from "../test-support";
|
|
13
|
+
|
|
14
|
+
/** Find a renderable by id anywhere in the tree (getRenderable is not recursive). */
|
|
15
|
+
function findById(node: Renderable, id: string): Renderable | undefined {
|
|
16
|
+
if (node.id === id) return node;
|
|
17
|
+
for (const child of node.getChildren()) {
|
|
18
|
+
const found = findById(child, id);
|
|
19
|
+
|
|
20
|
+
if (found) return found;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function hex(color: { toInts(): [number, number, number, number] }): string {
|
|
27
|
+
const [red, green, blue] = color.toInts();
|
|
28
|
+
|
|
29
|
+
return "#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join("");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A single-hunk patch of `count` added lines, taller than any test viewport. */
|
|
33
|
+
function tallPatch(count: number): string {
|
|
34
|
+
const adds = Array.from(
|
|
35
|
+
{ length: count },
|
|
36
|
+
(_, index) => `+line ${String(index).padStart(2, "0")}`,
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
return [
|
|
40
|
+
"diff --git a/f.ts b/f.ts",
|
|
41
|
+
"--- a/f.ts",
|
|
42
|
+
"+++ b/f.ts",
|
|
43
|
+
`@@ -0,0 +1,${count} @@`,
|
|
44
|
+
...adds,
|
|
45
|
+
"",
|
|
46
|
+
].join("\n");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** DiffSheet with j-driven cursor, mirroring the App's down-navigation. */
|
|
50
|
+
function ScrollHarness({
|
|
51
|
+
rows,
|
|
52
|
+
annotations,
|
|
53
|
+
}: {
|
|
54
|
+
rows: DiffRow[];
|
|
55
|
+
annotations: Annotation[];
|
|
56
|
+
}): React.ReactNode {
|
|
57
|
+
const [cursor, setCursor] = useState(0);
|
|
58
|
+
|
|
59
|
+
useKeyboard((key) => {
|
|
60
|
+
if (key.name === "j") setCursor((current) => Math.min(rows.length - 1, current + 1));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return <DiffSheet rows={rows} cursor={cursor} annotations={annotations} />;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
test("scrolling down past wrapped annotation cards keeps the cursor pinned and the scroll monotonic", async () => {
|
|
67
|
+
// Arrange - a tall diff whose annotation bodies would wrap at this width; the
|
|
68
|
+
// wrapped card is exactly the layout the cursor-follow model used to mis-measure
|
|
69
|
+
const rows = diffRows(tallPatch(40));
|
|
70
|
+
const longBody =
|
|
71
|
+
"this is a deliberately long annotation body that would wrap across the narrow test viewport";
|
|
72
|
+
const annotations: Annotation[] = [8, 16, 24].map((rowIndex) => ({
|
|
73
|
+
id: `a${rowIndex}`,
|
|
74
|
+
kind: "comment",
|
|
75
|
+
body: longBody,
|
|
76
|
+
anchor: { quote: rows[rowIndex]!.text, prefix: "", suffix: "" },
|
|
77
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
78
|
+
}));
|
|
79
|
+
const setup = await testRender(<ScrollHarness rows={rows} annotations={annotations} />, {
|
|
80
|
+
width: 60,
|
|
81
|
+
height: 12,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
await setup.waitForVisualIdle();
|
|
85
|
+
const scrollbox = findById(setup.renderer.root, "diff-scroll") as ScrollBoxRenderable;
|
|
86
|
+
|
|
87
|
+
// the cursor row is the one painted with the cursor background
|
|
88
|
+
const cursorScreenRow = (): number => {
|
|
89
|
+
const lines = setup.captureSpans().lines;
|
|
90
|
+
|
|
91
|
+
for (let index = 0; index < lines.length; index++) {
|
|
92
|
+
if (lines[index]!.spans.some((span) => hex(span.bg) === DARK.cursorBackground)) return index;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return -1;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// Act - walk the cursor down through the whole diff, sampling the scroll each step
|
|
99
|
+
const scrollTops: number[] = [];
|
|
100
|
+
const screenRows: number[] = [];
|
|
101
|
+
|
|
102
|
+
for (let step = 0; step < rows.length; step++) {
|
|
103
|
+
scrollTops.push(scrollbox.scrollTop);
|
|
104
|
+
screenRows.push(cursorScreenRow());
|
|
105
|
+
await press(setup, "j");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Assert - scrollTop only ever grows going down (never rebounds/jitters)
|
|
109
|
+
for (let step = 1; step < scrollTops.length; step++) {
|
|
110
|
+
expect(scrollTops[step]!).toBeGreaterThanOrEqual(scrollTops[step - 1]!);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Assert - the cursor never scrolls off screen (the pre-fix bug walked it off
|
|
114
|
+
// the viewport as it passed each wrapped card)
|
|
115
|
+
expect(screenRows.every((row) => row >= 0)).toBe(true);
|
|
116
|
+
|
|
117
|
+
// Assert - through the steady scroll region (not the initial fill, not the
|
|
118
|
+
// final clamp at the content end) the cursor holds ONE stable row near the
|
|
119
|
+
// bottom; pre-fix it drifted down a row per wrapped card
|
|
120
|
+
const maxScroll = scrollbox.scrollHeight - scrollbox.height;
|
|
121
|
+
const steadyRows = screenRows.filter(
|
|
122
|
+
(_, step) => scrollTops[step]! > 0 && scrollTops[step]! < maxScroll,
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
expect(steadyRows.length).toBeGreaterThan(0);
|
|
126
|
+
expect(new Set(steadyRows).size).toBe(1);
|
|
127
|
+
const pinnedRow = steadyRows[0]!;
|
|
128
|
+
const viewportBottom = scrollbox.height;
|
|
129
|
+
|
|
130
|
+
// the cursor sits a couple of rows inside the bottom edge, per the follow rule
|
|
131
|
+
expect(viewportBottom - pinnedRow).toBeGreaterThanOrEqual(2);
|
|
132
|
+
expect(viewportBottom - pinnedRow).toBeLessThanOrEqual(3);
|
|
133
|
+
|
|
134
|
+
setup.renderer.destroy();
|
|
135
|
+
}, 25000);
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "./story";
|
|
4
|
+
import { DiffSheet } from "./DiffSheet";
|
|
5
|
+
import { FIXTURE_PATCH, fixtureDiffRows } from "./story-fixtures";
|
|
6
|
+
|
|
7
|
+
export const meta: StoryMeta = { title: "DiffSheet" };
|
|
8
|
+
|
|
9
|
+
export const SignedRows: Story = {
|
|
10
|
+
render: () => <DiffSheet rows={fixtureDiffRows()} cursor={0} annotations={[]} />,
|
|
11
|
+
expectedColors: [DARK.insertedForeground, DARK.deletedForeground],
|
|
12
|
+
size: { width: 90, height: 16 },
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// A modified line keeps the diff color on the changed words and dims the
|
|
16
|
+
// unchanged prefix to the dim token, so the intra-line change stands out.
|
|
17
|
+
export const IntralineWordDiff: Story = {
|
|
18
|
+
render: () => <DiffSheet rows={fixtureDiffRows()} cursor={0} annotations={[]} />,
|
|
19
|
+
expectedColors: [DARK.insertedForeground, DARK.deletedForeground, DARK.textDim],
|
|
20
|
+
size: { width: 90, height: 16 },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const CursorOnAddedLine: Story = {
|
|
24
|
+
render: () => <DiffSheet rows={fixtureDiffRows()} cursor={4} annotations={[]} />,
|
|
25
|
+
expectedColors: [DARK.cursorBackground],
|
|
26
|
+
size: { width: 90, height: 16 },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const ComposingOnLine: Story = {
|
|
30
|
+
render: () => (
|
|
31
|
+
<DiffSheet
|
|
32
|
+
rows={fixtureDiffRows()}
|
|
33
|
+
cursor={4}
|
|
34
|
+
annotations={[]}
|
|
35
|
+
compose={{
|
|
36
|
+
kind: "comment",
|
|
37
|
+
rowIndex: 4,
|
|
38
|
+
quote: " private items = new Map();",
|
|
39
|
+
draft: {
|
|
40
|
+
text: "Map needs an eviction story.",
|
|
41
|
+
onInput: () => {},
|
|
42
|
+
onSave: () => {},
|
|
43
|
+
onCancel: () => {},
|
|
44
|
+
},
|
|
45
|
+
}}
|
|
46
|
+
/>
|
|
47
|
+
),
|
|
48
|
+
expectedColors: [DARK.accent],
|
|
49
|
+
size: { width: 90, height: 16 },
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const AnnotatedLine: Story = {
|
|
53
|
+
render: () => (
|
|
54
|
+
<DiffSheet
|
|
55
|
+
rows={fixtureDiffRows()}
|
|
56
|
+
cursor={0}
|
|
57
|
+
annotations={[
|
|
58
|
+
{
|
|
59
|
+
id: "a_story_diff",
|
|
60
|
+
kind: "comment",
|
|
61
|
+
anchor: {
|
|
62
|
+
// diff anchors quote the row text verbatim, trailing newline included
|
|
63
|
+
quote:
|
|
64
|
+
FIXTURE_PATCH.split("\n")
|
|
65
|
+
.find((line) => line.startsWith("+ "))!
|
|
66
|
+
.slice(1) + "\n",
|
|
67
|
+
prefix: "",
|
|
68
|
+
suffix: "",
|
|
69
|
+
},
|
|
70
|
+
body: "Map needs an eviction story.",
|
|
71
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
72
|
+
},
|
|
73
|
+
]}
|
|
74
|
+
/>
|
|
75
|
+
),
|
|
76
|
+
expectedColors: [DARK.markCommentBackground],
|
|
77
|
+
size: { width: 90, height: 16 },
|
|
78
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test } from "bun:test";
|
|
2
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
3
|
+
import type { RGBA } from "@opentui/core";
|
|
4
|
+
import React from "react";
|
|
5
|
+
import { DiffSheet } from "./DiffSheet";
|
|
6
|
+
import { diffRows } from "../view-diff";
|
|
7
|
+
import { DARK } from "../theme";
|
|
8
|
+
import { allowEventLoopUpdates } from "../test-support";
|
|
9
|
+
|
|
10
|
+
const PATCH = `diff --git a/src/x.ts b/src/x.ts
|
|
11
|
+
--- a/src/x.ts
|
|
12
|
+
+++ b/src/x.ts
|
|
13
|
+
@@ -1,3 +1,3 @@
|
|
14
|
+
export class Store {
|
|
15
|
+
- const limit = 100;
|
|
16
|
+
+ const limit = 250;
|
|
17
|
+
}
|
|
18
|
+
`;
|
|
19
|
+
|
|
20
|
+
function hex(color: RGBA): string {
|
|
21
|
+
const [red, green, blue] = color.toInts();
|
|
22
|
+
|
|
23
|
+
return "#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join("");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
beforeEach(() => allowEventLoopUpdates());
|
|
27
|
+
afterEach(() => allowEventLoopUpdates());
|
|
28
|
+
|
|
29
|
+
test("renders tree-sitter colors, with the changed word in the diff color on top", async () => {
|
|
30
|
+
// Arrange
|
|
31
|
+
const setup = await testRender(<DiffSheet rows={diffRows(PATCH)} cursor={0} annotations={[]} />, {
|
|
32
|
+
width: 60,
|
|
33
|
+
height: 12,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Act
|
|
37
|
+
// syntax highlighting resolves off the render path; pump until a keyword paints
|
|
38
|
+
let keywordAccent = false;
|
|
39
|
+
|
|
40
|
+
for (let attempt = 0; attempt < 60 && !keywordAccent; attempt++) {
|
|
41
|
+
await setup.renderOnce();
|
|
42
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
43
|
+
for (const line of setup.captureSpans().lines) {
|
|
44
|
+
for (const span of line.spans) {
|
|
45
|
+
if (span.text.includes("export") && hex(span.fg) === DARK.accent) keywordAccent = true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Assert
|
|
51
|
+
expect(keywordAccent).toBe(true);
|
|
52
|
+
// the intra-line changed number keeps the diff color over its syntax color
|
|
53
|
+
const changedColors = new Set<string>();
|
|
54
|
+
|
|
55
|
+
for (const line of setup.captureSpans().lines) {
|
|
56
|
+
for (const span of line.spans) {
|
|
57
|
+
if (span.text.includes("250")) changedColors.add(hex(span.fg));
|
|
58
|
+
if (span.text.includes("100")) changedColors.add(hex(span.fg));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
expect(changedColors.has(DARK.insertedForeground)).toBe(true);
|
|
62
|
+
expect(changedColors.has(DARK.deletedForeground)).toBe(true);
|
|
63
|
+
|
|
64
|
+
setup.renderer.destroy();
|
|
65
|
+
}, 25000);
|