@kanzo-tech/ai 0.1.0
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/dist/ai-mark.d.ts +54 -0
- package/dist/ai-mark.d.ts.map +1 -0
- package/dist/ai-mark.js +68 -0
- package/dist/ai-mark.js.map +1 -0
- package/dist/complete.d.ts +66 -0
- package/dist/complete.d.ts.map +1 -0
- package/dist/complete.js +259 -0
- package/dist/complete.js.map +1 -0
- package/dist/conversation.d.ts +38 -0
- package/dist/conversation.d.ts.map +1 -0
- package/dist/conversation.js +118 -0
- package/dist/conversation.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +60 -0
- package/dist/index.js.map +1 -0
- package/dist/markdown.d.ts +50 -0
- package/dist/markdown.d.ts.map +1 -0
- package/dist/markdown.js +36 -0
- package/dist/markdown.js.map +1 -0
- package/dist/message-part.d.ts +122 -0
- package/dist/message-part.d.ts.map +1 -0
- package/dist/message-part.js +8 -0
- package/dist/message-part.js.map +1 -0
- package/dist/message-text.d.ts +44 -0
- package/dist/message-text.d.ts.map +1 -0
- package/dist/message-text.js +49 -0
- package/dist/message-text.js.map +1 -0
- package/dist/message.d.ts +73 -0
- package/dist/message.d.ts.map +1 -0
- package/dist/message.js +120 -0
- package/dist/message.js.map +1 -0
- package/dist/prompt-input.d.ts +30 -0
- package/dist/prompt-input.d.ts.map +1 -0
- package/dist/prompt-input.js +113 -0
- package/dist/prompt-input.js.map +1 -0
- package/dist/reasoning.d.ts +32 -0
- package/dist/reasoning.d.ts.map +1 -0
- package/dist/reasoning.js +94 -0
- package/dist/reasoning.js.map +1 -0
- package/dist/styles.css +2 -0
- package/dist/suggest.d.ts +75 -0
- package/dist/suggest.d.ts.map +1 -0
- package/dist/suggest.js +123 -0
- package/dist/suggest.js.map +1 -0
- package/dist/task.d.ts +48 -0
- package/dist/task.d.ts.map +1 -0
- package/dist/task.js +91 -0
- package/dist/task.js.map +1 -0
- package/dist/tool.d.ts +56 -0
- package/dist/tool.d.ts.map +1 -0
- package/dist/tool.js +122 -0
- package/dist/tool.js.map +1 -0
- package/dist/types.d.ts +10 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/use-ai.d.ts +170 -0
- package/dist/use-ai.d.ts.map +1 -0
- package/dist/use-ai.js +186 -0
- package/dist/use-ai.js.map +1 -0
- package/package.json +111 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { InputGroupButton } from '@kanzo-tech/ui';
|
|
2
|
+
import type * as React from "react";
|
|
3
|
+
export interface AiMarkProps extends Omit<React.ComponentProps<typeof InputGroupButton>, "children" | "size"> {
|
|
4
|
+
/** Something is on offer right now — paints only; see {@link AiMarkProps.label}. */
|
|
5
|
+
offering?: boolean;
|
|
6
|
+
/** A request is in flight. */
|
|
7
|
+
busy?: boolean;
|
|
8
|
+
/**
|
|
9
|
+
* The whole accessible name, and the only thing that carries state to a reader who cannot see
|
|
10
|
+
* the colour — so a caller that sets `offering` names the offer too, the way `CompleteMark`
|
|
11
|
+
* does.
|
|
12
|
+
*/
|
|
13
|
+
label?: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The ✨ that says a field is model-assisted.
|
|
17
|
+
*
|
|
18
|
+
* It is a **statement before it is a button**: quiet at rest, so a reader can tell an assisted
|
|
19
|
+
* field from a plain one without touching it, brighter while something is on offer, a spinner
|
|
20
|
+
* while a request is out.
|
|
21
|
+
*
|
|
22
|
+
* ## Both compounds bind it the same way, and that is a rule
|
|
23
|
+
*
|
|
24
|
+
* A mark that paints one way over a ghost and another over a candidate strip reads as two products
|
|
25
|
+
* rather than one library, so `CompleteMark` and `SuggestMark` derive their props identically:
|
|
26
|
+
*
|
|
27
|
+
* - `offering` — *there is something on offer*: a ghost, or candidates on screen. It used to be set
|
|
28
|
+
* only over a ghost, so a strip full of candidates left the mark at rest.
|
|
29
|
+
* - `busy` — *a request is out and nothing has arrived yet*, `loading && !offering`. A bare
|
|
30
|
+
* `loading` spun on top of candidates that were already there.
|
|
31
|
+
* - The press means **give me the assistance**, and each compound reads that in its own domain: a
|
|
32
|
+
* ghost is one offer that needs a gesture, so pressing takes it; a strip is N offers that carry
|
|
33
|
+
* their own buttons, so pressing asks for a different set. Neither is ever inert.
|
|
34
|
+
*
|
|
35
|
+
* The only binding that differs is `disabled`, and it differs for a reason rather than by accident:
|
|
36
|
+
* there is nothing to continue below a minimum length, while a candidate source can answer from an
|
|
37
|
+
* empty field.
|
|
38
|
+
*
|
|
39
|
+
* **Nothing here keys off `:hover`.** A hover-revealed mark is invisible to touch and to the
|
|
40
|
+
* keyboard, and a hover-fired request bills a model for a pointer crossing the field. The state
|
|
41
|
+
* comes from the caller's own knowledge of the stream; focus and a press are the gestures.
|
|
42
|
+
*
|
|
43
|
+
* An `InputGroupButton` rather than a bare `Button` because the mark belongs **inside** the field,
|
|
44
|
+
* in an `InputGroupAddon`: what it marks is the control, so it sits in the control's box and stays
|
|
45
|
+
* with it whether the row above is a label, a legend or nothing — and the addon's coarse-pointer
|
|
46
|
+
* hit area already applies there. It renders standalone too; the group selectors simply never
|
|
47
|
+
* match.
|
|
48
|
+
*
|
|
49
|
+
* There was a `data-[state=open]:text-primary` rule here for the days when pressing it opened a
|
|
50
|
+
* candidate popover and `PopoverTrigger asChild` wrote `data-state` onto it. Nothing opens now, so
|
|
51
|
+
* nothing writes that attribute and the rule matched nothing.
|
|
52
|
+
*/
|
|
53
|
+
export declare function AiMark(props: AiMarkProps): React.JSX.Element;
|
|
54
|
+
//# sourceMappingURL=ai-mark.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ai-mark.d.ts","sourceRoot":"","sources":["../src/ai-mark.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,KAAK,KAAK,MAAM,OAAO,CAAC;AAEpC,OAAO,EAAM,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AA8CtD,MAAM,WAAW,WACf,SAAQ,IAAI,CAAC,KAAK,CAAC,cAAc,CAAC,OAAO,gBAAgB,CAAC,EAAE,UAAU,GAAG,MAAM,CAAC;IAChF,oFAAoF;IACpF,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,8BAA8B;IAC9B,IAAI,CAAC,EAAE,OAAO,CAAC;IACf;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,wBAAgB,MAAM,CAAC,KAAK,EAAE,WAAW,qBA0BxC"}
|
package/dist/ai-mark.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { jsx as o } from "react/jsx-runtime";
|
|
2
|
+
import { SparklesIcon as f } from "lucide-react";
|
|
3
|
+
import { tv as l } from "tailwind-variants";
|
|
4
|
+
import { InputGroupButton as p, cn as c } from "@kanzo-tech/ui";
|
|
5
|
+
const u = l({
|
|
6
|
+
base: [
|
|
7
|
+
"text-muted-foreground",
|
|
8
|
+
"transition-colors",
|
|
9
|
+
"motion-reduce:transition-none!"
|
|
10
|
+
],
|
|
11
|
+
variants: {
|
|
12
|
+
tone: {
|
|
13
|
+
rest: "",
|
|
14
|
+
/**
|
|
15
|
+
* Lit, not merely recoloured.
|
|
16
|
+
*
|
|
17
|
+
* `text-primary` alone was the whole offering state, and at 16px against a resting
|
|
18
|
+
* `text-muted-foreground` it is a change a reader has to be looking for. The mark is the one
|
|
19
|
+
* thing on screen that says *there is something here* to somebody whose eyes are on the field
|
|
20
|
+
* rather than on the ghost, so it carries the tenant's brand the way every other "this one is
|
|
21
|
+
* chosen" surface in the library does — `RadioGroupCard`, `Questionnaire` and `CodeEditor`'s
|
|
22
|
+
* selection all spell it `bg-primary/17`.
|
|
23
|
+
*
|
|
24
|
+
* **No border, and that is geometry rather than taste.** `RadioGroupCard` pairs this wash with
|
|
25
|
+
* `border-primary` and it is the right pair — but there is nowhere to draw it here. Measured
|
|
26
|
+
* on `docs/ai/fields`: the group is 32px *including* its 1px border, so its content box is
|
|
27
|
+
* 30px, and `InputGroupButton` is 32px because 24px failed WCAG 2.5.8 at compact density. The
|
|
28
|
+
* mark therefore overhangs the content box by exactly 1px top and bottom, which is precisely
|
|
29
|
+
* where the group's own border is drawn — so a border on the mark lands on top of the field's.
|
|
30
|
+
* Fixing that is `InputGroupAddon`'s to fix, and it is filed.
|
|
31
|
+
*
|
|
32
|
+
* What this costs: on a palette whose brand is neutral, `--brand-a5` resolves to `#0000001f`,
|
|
33
|
+
* black at 12%, and the lit state reads closer to hover than to lit. That is the palette
|
|
34
|
+
* having no hue to light up with rather than this rule being wrong, and it comes right the
|
|
35
|
+
* moment a tenant chooses one.
|
|
36
|
+
*
|
|
37
|
+
* The hover pair is repeated on purpose: `Button`'s ghost variant sets `hover:bg-accent`, and
|
|
38
|
+
* without these the mark would go *out* under the pointer at the exact moment it is being
|
|
39
|
+
* reached for. No animation of its own — the base already carries `transition-colors` and the
|
|
40
|
+
* `motion-reduce` escape, so it comes up rather than snapping, and stays still for a reader
|
|
41
|
+
* who asked for that.
|
|
42
|
+
*/
|
|
43
|
+
offer: "bg-primary/17 text-primary hover:bg-primary/30 hover:text-primary"
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
defaultVariants: { tone: "rest" }
|
|
47
|
+
});
|
|
48
|
+
function x(e) {
|
|
49
|
+
const { busy: a = !1, className: i, label: r = "AI assist", offering: t = !1, slot: s, title: n, ...m } = e;
|
|
50
|
+
return /* @__PURE__ */ o(
|
|
51
|
+
p,
|
|
52
|
+
{
|
|
53
|
+
"aria-label": r,
|
|
54
|
+
className: c(u({ tone: t ? "offer" : "rest" }), i),
|
|
55
|
+
"data-offering": t || void 0,
|
|
56
|
+
isLoading: a,
|
|
57
|
+
size: "icon-sm",
|
|
58
|
+
title: n ?? r,
|
|
59
|
+
...m,
|
|
60
|
+
slot: s ?? "ai-mark",
|
|
61
|
+
children: /* @__PURE__ */ o(f, {})
|
|
62
|
+
}
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
export {
|
|
66
|
+
x as AiMark
|
|
67
|
+
};
|
|
68
|
+
//# sourceMappingURL=ai-mark.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ai-mark.js","sources":["../src/ai-mark.tsx"],"sourcesContent":["import { SparklesIcon } from \"lucide-react\";\nimport type * as React from \"react\";\nimport { tv } from \"tailwind-variants\";\nimport { cn, InputGroupButton } from \"@kanzo-tech/ui\";\n\nconst aiMarkVariants = tv({\n base: [\n \"text-muted-foreground\",\n \"transition-colors\",\n \"motion-reduce:transition-none!\",\n ],\n variants: {\n tone: {\n rest: \"\",\n /**\n * Lit, not merely recoloured.\n *\n * `text-primary` alone was the whole offering state, and at 16px against a resting\n * `text-muted-foreground` it is a change a reader has to be looking for. The mark is the one\n * thing on screen that says *there is something here* to somebody whose eyes are on the field\n * rather than on the ghost, so it carries the tenant's brand the way every other \"this one is\n * chosen\" surface in the library does — `RadioGroupCard`, `Questionnaire` and `CodeEditor`'s\n * selection all spell it `bg-primary/17`.\n *\n * **No border, and that is geometry rather than taste.** `RadioGroupCard` pairs this wash with\n * `border-primary` and it is the right pair — but there is nowhere to draw it here. Measured\n * on `docs/ai/fields`: the group is 32px *including* its 1px border, so its content box is\n * 30px, and `InputGroupButton` is 32px because 24px failed WCAG 2.5.8 at compact density. The\n * mark therefore overhangs the content box by exactly 1px top and bottom, which is precisely\n * where the group's own border is drawn — so a border on the mark lands on top of the field's.\n * Fixing that is `InputGroupAddon`'s to fix, and it is filed.\n *\n * What this costs: on a palette whose brand is neutral, `--brand-a5` resolves to `#0000001f`,\n * black at 12%, and the lit state reads closer to hover than to lit. That is the palette\n * having no hue to light up with rather than this rule being wrong, and it comes right the\n * moment a tenant chooses one.\n *\n * The hover pair is repeated on purpose: `Button`'s ghost variant sets `hover:bg-accent`, and\n * without these the mark would go *out* under the pointer at the exact moment it is being\n * reached for. No animation of its own — the base already carries `transition-colors` and the\n * `motion-reduce` escape, so it comes up rather than snapping, and stays still for a reader\n * who asked for that.\n */\n offer: \"bg-primary/17 text-primary hover:bg-primary/30 hover:text-primary\",\n },\n },\n defaultVariants: { tone: \"rest\" },\n});\n\nexport interface AiMarkProps\n extends Omit<React.ComponentProps<typeof InputGroupButton>, \"children\" | \"size\"> {\n /** Something is on offer right now — paints only; see {@link AiMarkProps.label}. */\n offering?: boolean;\n /** A request is in flight. */\n busy?: boolean;\n /**\n * The whole accessible name, and the only thing that carries state to a reader who cannot see\n * the colour — so a caller that sets `offering` names the offer too, the way `CompleteMark`\n * does.\n */\n label?: string;\n}\n\n/**\n * The ✨ that says a field is model-assisted.\n *\n * It is a **statement before it is a button**: quiet at rest, so a reader can tell an assisted\n * field from a plain one without touching it, brighter while something is on offer, a spinner\n * while a request is out.\n *\n * ## Both compounds bind it the same way, and that is a rule\n *\n * A mark that paints one way over a ghost and another over a candidate strip reads as two products\n * rather than one library, so `CompleteMark` and `SuggestMark` derive their props identically:\n *\n * - `offering` — *there is something on offer*: a ghost, or candidates on screen. It used to be set\n * only over a ghost, so a strip full of candidates left the mark at rest.\n * - `busy` — *a request is out and nothing has arrived yet*, `loading && !offering`. A bare\n * `loading` spun on top of candidates that were already there.\n * - The press means **give me the assistance**, and each compound reads that in its own domain: a\n * ghost is one offer that needs a gesture, so pressing takes it; a strip is N offers that carry\n * their own buttons, so pressing asks for a different set. Neither is ever inert.\n *\n * The only binding that differs is `disabled`, and it differs for a reason rather than by accident:\n * there is nothing to continue below a minimum length, while a candidate source can answer from an\n * empty field.\n *\n * **Nothing here keys off `:hover`.** A hover-revealed mark is invisible to touch and to the\n * keyboard, and a hover-fired request bills a model for a pointer crossing the field. The state\n * comes from the caller's own knowledge of the stream; focus and a press are the gestures.\n *\n * An `InputGroupButton` rather than a bare `Button` because the mark belongs **inside** the field,\n * in an `InputGroupAddon`: what it marks is the control, so it sits in the control's box and stays\n * with it whether the row above is a label, a legend or nothing — and the addon's coarse-pointer\n * hit area already applies there. It renders standalone too; the group selectors simply never\n * match.\n *\n * There was a `data-[state=open]:text-primary` rule here for the days when pressing it opened a\n * candidate popover and `PopoverTrigger asChild` wrote `data-state` onto it. Nothing opens now, so\n * nothing writes that attribute and the rule matched nothing.\n */\nexport function AiMark(props: AiMarkProps) {\n const { busy = false, className, label = \"AI assist\", offering = false, slot, title, ...rest } = props;\n\n return (\n <InputGroupButton\n aria-label={label}\n className={cn(aiMarkVariants({ tone: offering ? \"offer\" : \"rest\" }), className)}\n data-offering={offering || undefined}\n // `Button`'s own loading contract, not a spinner of ours. It used to render `<Spinner />` in\n // place of the glyph and write its own `aria-busy` — which never reached the DOM, because\n // `Button` writes `aria-busy={isLoading}` AFTER spreading props and always won. So the mark\n // spun on screen while telling a screen reader it was idle, and stayed pressable while it\n // did. One machine now: the spinner, `aria-busy`, `aria-disabled` and `data-state` all come\n // from the primitive, and a spinner you can press stops being a lie.\n isLoading={busy}\n size=\"icon-sm\"\n // The browser's own tooltip, for the same reason `SuggestList` gives a candidate's rationale\n // one: it reaches the keyboard through focus and costs no box. A 16px glyph with an\n // accessible name and no visible one tells a pointer reader nothing at all.\n title={title ?? label}\n {...rest}\n slot={slot ?? \"ai-mark\"}\n >\n <SparklesIcon />\n </InputGroupButton>\n );\n}\n"],"names":["aiMarkVariants","tv","AiMark","props","busy","className","label","offering","slot","title","rest","jsx","InputGroupButton","cn","SparklesIcon"],"mappings":";;;;AAKA,MAAMA,IAAiBC,EAAG;AAAA,EACxB,MAAM;AAAA,IACJ;AAAA,IACA;AAAA,IACA;AAAA,EAAA;AAAA,EAEF,UAAU;AAAA,IACR,MAAM;AAAA,MACJ,MAAM;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,MA8BN,OAAO;AAAA,IAAA;AAAA,EACT;AAAA,EAEF,iBAAiB,EAAE,MAAM,OAAA;AAC3B,CAAC;AAsDM,SAASC,EAAOC,GAAoB;AACzC,QAAM,EAAE,MAAAC,IAAO,IAAO,WAAAC,GAAW,OAAAC,IAAQ,aAAa,UAAAC,IAAW,IAAO,MAAAC,GAAM,OAAAC,GAAO,GAAGC,EAAA,IAASP;AAEjG,SACE,gBAAAQ;AAAA,IAACC;AAAA,IAAA;AAAA,MACC,cAAYN;AAAA,MACZ,WAAWO,EAAGb,EAAe,EAAE,MAAMO,IAAW,UAAU,QAAQ,GAAGF,CAAS;AAAA,MAC9E,iBAAeE,KAAY;AAAA,MAO3B,WAAWH;AAAA,MACX,MAAK;AAAA,MAIL,OAAOK,KAASH;AAAA,MACf,GAAGI;AAAA,MACJ,MAAMF,KAAQ;AAAA,MAEd,4BAACM,GAAA,CAAA,CAAa;AAAA,IAAA;AAAA,EAAA;AAGpB;"}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { AiMarkProps } from './ai-mark.js';
|
|
2
|
+
import { InlineCompletionRequest } from './use-ai.js';
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
export interface CompleteRootProps {
|
|
5
|
+
complete: (request: InlineCompletionRequest) => AsyncIterable<string>;
|
|
6
|
+
value: string;
|
|
7
|
+
onValueChange: (value: string) => void;
|
|
8
|
+
debounceMs?: number;
|
|
9
|
+
minLength?: number;
|
|
10
|
+
className?: string;
|
|
11
|
+
children: React.ReactNode;
|
|
12
|
+
}
|
|
13
|
+
export declare function CompleteRoot(props: CompleteRootProps): React.JSX.Element;
|
|
14
|
+
export declare function CompleteTextarea({ children }: {
|
|
15
|
+
children: React.ReactElement;
|
|
16
|
+
}): React.JSX.Element;
|
|
17
|
+
/**
|
|
18
|
+
* The ✨, bound to the stream — the same mark `SuggestTrigger` wears, so an assisted field looks
|
|
19
|
+
* assisted whichever of the two it has.
|
|
20
|
+
*
|
|
21
|
+
* Pressing it **takes what is on offer and asks when there is nothing**. That is one gesture with
|
|
22
|
+
* two readings on purpose: it is the only way to accept without a keyboard, and the only way back
|
|
23
|
+
* after Escape, which otherwise ends the field's assistance until the next keystroke.
|
|
24
|
+
*/
|
|
25
|
+
export declare function CompleteMark(props: Omit<AiMarkProps, "busy" | "offering">): React.JSX.Element;
|
|
26
|
+
/**
|
|
27
|
+
* The continuation drawn where it will land, over the field itself. It wraps and scrolls with the
|
|
28
|
+
* field rather than measuring whether a line of it fits.
|
|
29
|
+
*/
|
|
30
|
+
export declare function CompleteGhost({ className }: {
|
|
31
|
+
className?: string;
|
|
32
|
+
}): React.JSX.Element;
|
|
33
|
+
/**
|
|
34
|
+
* The keys, while there is something to take.
|
|
35
|
+
*
|
|
36
|
+
* The compound already told one class of reader: the `sr-only` region announces *Press Tab to
|
|
37
|
+
* accept, Escape to dismiss* the moment an offer lands. A sighted keyboard user got nothing —
|
|
38
|
+
* `CompleteHint` carries the keys too, so a composition that uses the ghost taught the gesture
|
|
39
|
+
* nowhere. This is the visible twin of that sentence, and it is a part rather than a fixture
|
|
40
|
+
* because only the caller knows where it fits: beside the ✨ in a `block-end` addon, under the
|
|
41
|
+
* field, nowhere at all on a form of eleven where one line of instruction beats eleven.
|
|
42
|
+
*
|
|
43
|
+
* There is no reference to copy. Gmail taught Tab with a popup shown once ever; Copilot puts a
|
|
44
|
+
* toolbar under the pointer, which is a gesture a keyboard cannot make. Naming the key next to the
|
|
45
|
+
* offer is the version that works for both.
|
|
46
|
+
*/
|
|
47
|
+
export declare function CompleteKeys({ className }: {
|
|
48
|
+
className?: string;
|
|
49
|
+
}): React.JSX.Element | null;
|
|
50
|
+
/** Below-field alternative to `CompleteGhost`, for a field the overlay cannot sit over. */
|
|
51
|
+
export declare function CompleteHint({ className }: {
|
|
52
|
+
className?: string;
|
|
53
|
+
}): React.JSX.Element | null;
|
|
54
|
+
/**
|
|
55
|
+
* What went wrong, in place. Renders nothing when nothing did.
|
|
56
|
+
*
|
|
57
|
+
* `Suggest` has always shown its failures — the strip prints them where the candidates would be —
|
|
58
|
+
* and this side showed none at all: the ✨ stopped spinning and the field said nothing, because
|
|
59
|
+
* `error` was on the hook and no part read it. It is a separate part rather than a line inside
|
|
60
|
+
* `CompleteHint` because the ghost surface is usually `CompleteGhost`, an overlay with no room
|
|
61
|
+
* under it, and a failure has to be readable in both.
|
|
62
|
+
*/
|
|
63
|
+
export declare function CompleteError({ className }: {
|
|
64
|
+
className?: string;
|
|
65
|
+
}): React.JSX.Element | null;
|
|
66
|
+
//# sourceMappingURL=complete.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"complete.d.ts","sourceRoot":"","sources":["../src/complete.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAE/B,OAAO,EAAU,KAAK,WAAW,EAAE,MAAM,cAAc,CAAC;AACxD,OAAO,EAEL,KAAK,uBAAuB,EAG7B,MAAM,aAAa,CAAC;AA+FrB,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,CAAC,OAAO,EAAE,uBAAuB,KAAK,aAAa,CAAC,MAAM,CAAC,CAAC;IACtE,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACvC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;CAC3B;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,iBAAiB,qBAgLpD;AAED,wBAAgB,gBAAgB,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,EAAE,KAAK,CAAC,YAAY,CAAA;CAAE,qBAkB9E;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,IAAI,CAAC,WAAW,EAAE,MAAM,GAAG,UAAU,CAAC,qBAkBzE;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,EAAE,SAAS,EAAE,EAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,qBA8FlE;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,YAAY,CAAC,EAAE,SAAS,EAAE,EAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,4BAgBjE;AAED,2FAA2F;AAC3F,wBAAgB,YAAY,CAAC,EAAE,SAAS,EAAE,EAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,4BAYjE;AAGD;;;;;;;;GAQG;AACH,wBAAgB,aAAa,CAAC,EAAE,SAAS,EAAE,EAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,4BAYlE"}
|
package/dist/complete.js
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as f, jsxs as b } from "react/jsx-runtime";
|
|
3
|
+
import { ark as d } from "@ark-ui/react/factory";
|
|
4
|
+
import * as o from "react";
|
|
5
|
+
import { cn as y, Kbd as B } from "@kanzo-tech/ui";
|
|
6
|
+
import { AiMark as F } from "./ai-mark.js";
|
|
7
|
+
import { useInlineCompletion as _, MIN_COMPLETE_LENGTH as G } from "./use-ai.js";
|
|
8
|
+
const H = o.createContext(null), v = (s) => {
|
|
9
|
+
const e = o.useContext(H);
|
|
10
|
+
if (!e) throw new Error(`${s} must render inside <CompleteRoot>`);
|
|
11
|
+
return e;
|
|
12
|
+
}, O = [
|
|
13
|
+
"fontFamily",
|
|
14
|
+
"fontSize",
|
|
15
|
+
"fontWeight",
|
|
16
|
+
"fontStyle",
|
|
17
|
+
"letterSpacing",
|
|
18
|
+
"lineHeight",
|
|
19
|
+
"textTransform",
|
|
20
|
+
"paddingTop",
|
|
21
|
+
"paddingRight",
|
|
22
|
+
"paddingBottom",
|
|
23
|
+
"paddingLeft",
|
|
24
|
+
"borderTopWidth",
|
|
25
|
+
"borderRightWidth",
|
|
26
|
+
"borderBottomWidth",
|
|
27
|
+
"borderLeftWidth"
|
|
28
|
+
], S = (s) => Number.parseFloat(s) || 0, z = (s, e) => {
|
|
29
|
+
const c = getComputedStyle(s), l = c.direction === "rtl", i = s.getBoundingClientRect(), m = e.getBoundingClientRect(), g = s.offsetWidth - s.clientWidth - S(c.borderLeftWidth) - S(c.borderRightWidth), a = {
|
|
30
|
+
borderStyle: "solid",
|
|
31
|
+
borderColor: "transparent",
|
|
32
|
+
insetBlockStart: i.top - m.top,
|
|
33
|
+
insetBlockEnd: m.bottom - i.bottom,
|
|
34
|
+
insetInlineStart: l ? m.right - i.right : i.left - m.left,
|
|
35
|
+
insetInlineEnd: (l ? i.left - m.left : m.right - i.right) + (g > 0 ? g : 0)
|
|
36
|
+
};
|
|
37
|
+
for (const r of O) a[r] = c[r];
|
|
38
|
+
return { metrics: a, rtl: l };
|
|
39
|
+
}, V = { metrics: {}, rtl: !1 }, j = "[mask-image:linear-gradient(to_bottom,#000_calc(100%-0.9lh),transparent)]", q = (s) => {
|
|
40
|
+
var e;
|
|
41
|
+
return ((e = /^\s*\S+/.exec(s)) == null ? void 0 : e[0]) ?? "";
|
|
42
|
+
};
|
|
43
|
+
function Z(s) {
|
|
44
|
+
const { complete: e, value: c, onValueChange: l, debounceMs: i, minLength: m, className: g, children: a } = s, r = _({ complete: e, debounceMs: i, minLength: m }), u = o.useRef(null), x = o.useRef(null), [p, R] = o.useState(c.length), [k, M] = o.useState(V), h = r.ghost, E = o.useRef(null);
|
|
45
|
+
o.useLayoutEffect(() => {
|
|
46
|
+
const t = u.current, n = E.current;
|
|
47
|
+
!t || n === null || (E.current = null, t.setSelectionRange(n, n), R(n));
|
|
48
|
+
}, [c]), o.useLayoutEffect(() => {
|
|
49
|
+
const t = u.current, n = x.current;
|
|
50
|
+
if (!t || !n) return;
|
|
51
|
+
const C = () => M(z(t, n));
|
|
52
|
+
if (C(), typeof ResizeObserver > "u") return;
|
|
53
|
+
const N = new ResizeObserver(C);
|
|
54
|
+
return N.observe(t), N.observe(n), () => N.disconnect();
|
|
55
|
+
}, []);
|
|
56
|
+
const W = r.dismiss, L = o.useCallback(() => {
|
|
57
|
+
const t = u.current;
|
|
58
|
+
if (!t || E.current !== null) return;
|
|
59
|
+
const n = t.selectionStart ?? t.value.length;
|
|
60
|
+
if ((t.selectionEnd ?? n) !== n) {
|
|
61
|
+
W();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
R(n);
|
|
65
|
+
}, [W]);
|
|
66
|
+
o.useEffect(() => {
|
|
67
|
+
const t = u.current;
|
|
68
|
+
if (!t) return;
|
|
69
|
+
const n = () => {
|
|
70
|
+
document.activeElement === t && L();
|
|
71
|
+
};
|
|
72
|
+
return document.addEventListener("selectionchange", n), () => document.removeEventListener("selectionchange", n);
|
|
73
|
+
}, [L]);
|
|
74
|
+
const A = o.useCallback(
|
|
75
|
+
(t) => {
|
|
76
|
+
const n = t.target, C = n.selectionStart ?? n.value.length;
|
|
77
|
+
R(C), l(n.value), r.setValue(n.value, C);
|
|
78
|
+
},
|
|
79
|
+
[r, l]
|
|
80
|
+
), w = o.useCallback(
|
|
81
|
+
(t) => {
|
|
82
|
+
const n = c.slice(0, p) + t + c.slice(p);
|
|
83
|
+
return E.current = p + t.length, l(n), { next: n, at: p + t.length };
|
|
84
|
+
},
|
|
85
|
+
[p, l, c]
|
|
86
|
+
), T = o.useCallback(() => {
|
|
87
|
+
var t;
|
|
88
|
+
h && (w(h), r.dismiss(), (t = u.current) == null || t.focus());
|
|
89
|
+
}, [r, h, w]), K = o.useCallback(() => {
|
|
90
|
+
const t = q(h);
|
|
91
|
+
if (!t) return;
|
|
92
|
+
const { next: n, at: C } = w(t);
|
|
93
|
+
r.setValue(n, C);
|
|
94
|
+
}, [r, h, w]), D = o.useCallback(() => {
|
|
95
|
+
const t = u.current, n = t && t === document.activeElement ? t.selectionStart ?? p : p;
|
|
96
|
+
R(n), r.ask(c, n), t == null || t.focus(), t == null || t.setSelectionRange(n, n);
|
|
97
|
+
}, [p, r, c]), I = o.useCallback(
|
|
98
|
+
(t) => {
|
|
99
|
+
if (t.defaultPrevented || !h) return;
|
|
100
|
+
const n = k.rtl ? "ArrowLeft" : "ArrowRight";
|
|
101
|
+
t.key === "Tab" ? (t.preventDefault(), T()) : t.key === n && (t.ctrlKey || t.metaKey) ? (t.preventDefault(), K()) : t.key === "Escape" && r.dismiss();
|
|
102
|
+
},
|
|
103
|
+
[T, K, r, k.rtl, h]
|
|
104
|
+
), P = {
|
|
105
|
+
fieldRef: u,
|
|
106
|
+
value: c,
|
|
107
|
+
ghost: h,
|
|
108
|
+
caret: p,
|
|
109
|
+
rtl: k.rtl,
|
|
110
|
+
status: r.status,
|
|
111
|
+
error: r.error,
|
|
112
|
+
askable: c.trim().length >= (m ?? G),
|
|
113
|
+
metrics: k.metrics,
|
|
114
|
+
onChange: A,
|
|
115
|
+
onKeyDown: I,
|
|
116
|
+
syncCaret: L,
|
|
117
|
+
accept: T,
|
|
118
|
+
request: D
|
|
119
|
+
};
|
|
120
|
+
return /* @__PURE__ */ f(H.Provider, { value: P, children: /* @__PURE__ */ b(d.div, { className: y("relative w-full min-w-0", g), "data-slot": "complete", ref: x, children: [
|
|
121
|
+
a,
|
|
122
|
+
/* @__PURE__ */ f(d.span, { "aria-live": "polite", className: "sr-only", "data-slot": "complete-status", children: h ? "Suggestion ready. Press Tab to accept, Escape to dismiss." : "" })
|
|
123
|
+
] }) });
|
|
124
|
+
}
|
|
125
|
+
function ee({ children: s }) {
|
|
126
|
+
const e = v("CompleteTextarea");
|
|
127
|
+
return /* @__PURE__ */ f(
|
|
128
|
+
d.textarea,
|
|
129
|
+
{
|
|
130
|
+
"aria-keyshortcuts": e.ghost ? "Tab Escape" : void 0,
|
|
131
|
+
asChild: !0,
|
|
132
|
+
"data-slot": "complete-textarea",
|
|
133
|
+
onChange: e.onChange,
|
|
134
|
+
onClick: e.syncCaret,
|
|
135
|
+
onKeyDown: e.onKeyDown,
|
|
136
|
+
onKeyUp: e.syncCaret,
|
|
137
|
+
onSelect: e.syncCaret,
|
|
138
|
+
ref: e.fieldRef,
|
|
139
|
+
value: e.value,
|
|
140
|
+
children: s
|
|
141
|
+
}
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
function te(s) {
|
|
145
|
+
const { label: e = "AI assist", ...c } = s, l = v("CompleteMark"), i = l.ghost.length > 0;
|
|
146
|
+
return /* @__PURE__ */ f(
|
|
147
|
+
F,
|
|
148
|
+
{
|
|
149
|
+
busy: l.status === "loading" && !i,
|
|
150
|
+
disabled: !i && !l.askable,
|
|
151
|
+
label: i ? "Accept suggestion" : e,
|
|
152
|
+
offering: i,
|
|
153
|
+
onClick: i ? l.accept : l.request,
|
|
154
|
+
...c
|
|
155
|
+
}
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
function ne({ className: s }) {
|
|
159
|
+
const e = v("CompleteGhost"), c = o.useRef(null), [l, i] = o.useState(!1);
|
|
160
|
+
o.useLayoutEffect(() => {
|
|
161
|
+
const a = e.fieldRef.current, r = c.current;
|
|
162
|
+
if (!a || !r) return;
|
|
163
|
+
if (!e.ghost) {
|
|
164
|
+
a.style.removeProperty("min-height");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const u = getComputedStyle(a), x = r.scrollHeight + S(u.borderTopWidth) + S(u.borderBottomWidth);
|
|
168
|
+
x > a.getBoundingClientRect().height + 1 && a.style.setProperty("min-height", `${x}px`);
|
|
169
|
+
}, [e.fieldRef, e.ghost, e.value]), o.useLayoutEffect(() => {
|
|
170
|
+
const a = e.fieldRef.current, r = c.current;
|
|
171
|
+
if (!a || !r) return;
|
|
172
|
+
const u = () => {
|
|
173
|
+
r.scrollLeft = a.scrollLeft, r.scrollTop = a.scrollTop;
|
|
174
|
+
};
|
|
175
|
+
return u(), i(r.scrollHeight > r.clientHeight), a.addEventListener("scroll", u), () => a.removeEventListener("scroll", u);
|
|
176
|
+
}, [e.caret, e.fieldRef, e.value, e.ghost]);
|
|
177
|
+
const m = e.ghost.length > 0, g = Math.min(e.caret, e.value.length);
|
|
178
|
+
return /* @__PURE__ */ b(
|
|
179
|
+
d.div,
|
|
180
|
+
{
|
|
181
|
+
"aria-hidden": !0,
|
|
182
|
+
className: y(
|
|
183
|
+
"pointer-events-none absolute inset-0 overflow-hidden text-transparent",
|
|
184
|
+
"whitespace-pre-wrap break-words",
|
|
185
|
+
// **A continuation that does not fit fades; it is not guillotined.** The box is
|
|
186
|
+
// `overflow: hidden`, so without this a too-long ghost ends on a hard edge — mid-letter
|
|
187
|
+
// against the ✨. Applied only when the measurement above says it is genuinely cut, which
|
|
188
|
+
// is now only when a caller has pinned the field's height.
|
|
189
|
+
l && j,
|
|
190
|
+
!m && "invisible",
|
|
191
|
+
s
|
|
192
|
+
),
|
|
193
|
+
"data-slot": "complete-ghost",
|
|
194
|
+
ref: c,
|
|
195
|
+
style: e.metrics,
|
|
196
|
+
children: [
|
|
197
|
+
/* @__PURE__ */ f(d.span, { className: "invisible", children: e.value.slice(0, g) }),
|
|
198
|
+
/* @__PURE__ */ f(d.span, { className: "text-faint", children: e.ghost }),
|
|
199
|
+
/* @__PURE__ */ f(d.span, { className: "invisible", children: e.value.slice(g) })
|
|
200
|
+
]
|
|
201
|
+
}
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
function $({ className: s }) {
|
|
205
|
+
return v("CompleteKeys").ghost ? /* @__PURE__ */ b(
|
|
206
|
+
d.span,
|
|
207
|
+
{
|
|
208
|
+
className: y("inline-flex items-center gap-2 text-muted-foreground text-xs", s),
|
|
209
|
+
"data-slot": "complete-keys",
|
|
210
|
+
children: [
|
|
211
|
+
/* @__PURE__ */ b(d.span, { className: "inline-flex items-center gap-1 whitespace-nowrap", children: [
|
|
212
|
+
/* @__PURE__ */ f(B, { children: "Tab" }),
|
|
213
|
+
" accept"
|
|
214
|
+
] }),
|
|
215
|
+
/* @__PURE__ */ b(d.span, { className: "inline-flex items-center gap-1 whitespace-nowrap", children: [
|
|
216
|
+
/* @__PURE__ */ f(B, { children: "Esc" }),
|
|
217
|
+
" dismiss"
|
|
218
|
+
] })
|
|
219
|
+
]
|
|
220
|
+
}
|
|
221
|
+
) : null;
|
|
222
|
+
}
|
|
223
|
+
function re({ className: s }) {
|
|
224
|
+
const e = v("CompleteHint");
|
|
225
|
+
return e.ghost ? /* @__PURE__ */ b(
|
|
226
|
+
d.p,
|
|
227
|
+
{
|
|
228
|
+
className: y("mt-1.5 whitespace-pre-wrap break-words text-muted-foreground text-sm", s),
|
|
229
|
+
"data-slot": "complete-hint",
|
|
230
|
+
children: [
|
|
231
|
+
/* @__PURE__ */ f(d.span, { className: "text-faint", children: e.ghost.replace(/^\s+/, "") }),
|
|
232
|
+
" ",
|
|
233
|
+
/* @__PURE__ */ f($, {})
|
|
234
|
+
]
|
|
235
|
+
}
|
|
236
|
+
) : null;
|
|
237
|
+
}
|
|
238
|
+
function se({ className: s }) {
|
|
239
|
+
const e = v("CompleteError");
|
|
240
|
+
return e.error === null ? null : /* @__PURE__ */ f(
|
|
241
|
+
d.p,
|
|
242
|
+
{
|
|
243
|
+
className: y("mt-1.5 text-destructive-foreground text-sm", s),
|
|
244
|
+
"data-slot": "complete-error",
|
|
245
|
+
role: "alert",
|
|
246
|
+
children: e.error
|
|
247
|
+
}
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
export {
|
|
251
|
+
se as CompleteError,
|
|
252
|
+
ne as CompleteGhost,
|
|
253
|
+
re as CompleteHint,
|
|
254
|
+
$ as CompleteKeys,
|
|
255
|
+
te as CompleteMark,
|
|
256
|
+
Z as CompleteRoot,
|
|
257
|
+
ee as CompleteTextarea
|
|
258
|
+
};
|
|
259
|
+
//# sourceMappingURL=complete.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"complete.js","sources":["../src/complete.tsx"],"sourcesContent":["\"use client\";\n\nimport { ark } from \"@ark-ui/react/factory\";\nimport * as React from \"react\";\nimport { cn, Kbd } from \"@kanzo-tech/ui\";\nimport { AiMark, type AiMarkProps } from \"./ai-mark.js\";\nimport {\n type AiStatus,\n type InlineCompletionRequest,\n MIN_COMPLETE_LENGTH,\n useInlineCompletion,\n} from \"./use-ai.js\";\n\n/**\n * A `<textarea>`, and there is no `<input>` branch any more.\n *\n * **A one-line field takes candidates, not a continuation.** A continuation drawn over an\n * `<input>` can only ever show what fits in the remaining width — the field's own `scrollLeft`\n * cannot reveal text that is not in its value — so a long offer was unreadable by any gesture and\n * accepting it meant accepting blind. No reference does it either: Gmail continues a body, Copilot\n * an editor, and every single-line field in the wild offers a *list*. That is `Suggest`, and it is\n * already here.\n */\ntype FieldEl = HTMLTextAreaElement;\n\ninterface CompleteCtx {\n fieldRef: React.RefObject<FieldEl | null>;\n value: string;\n /** What is on offer at the caret. Empty when there is nothing. */\n ghost: string;\n /** Where the offer goes. The ghost is drawn here, not at the end of the value. */\n caret: number;\n rtl: boolean;\n status: AiStatus;\n error: string | null;\n /** The value is long enough for `complete` to be asked at all. */\n askable: boolean;\n metrics: React.CSSProperties;\n onChange: (e: React.ChangeEvent<FieldEl>) => void;\n onKeyDown: (e: React.KeyboardEvent<FieldEl>) => void;\n syncCaret: () => void;\n accept: () => void;\n request: () => void;\n}\n\nconst Ctx = React.createContext<CompleteCtx | null>(null);\nconst useCtx = (part: string) => {\n const c = React.useContext(Ctx);\n if (!c) throw new Error(`${part} must render inside <CompleteRoot>`);\n return c;\n};\n\nconst METRICS = [\n \"fontFamily\", \"fontSize\", \"fontWeight\", \"fontStyle\", \"letterSpacing\", \"lineHeight\",\n \"textTransform\", \"paddingTop\", \"paddingRight\", \"paddingBottom\", \"paddingLeft\",\n \"borderTopWidth\", \"borderRightWidth\", \"borderBottomWidth\", \"borderLeftWidth\",\n] as const;\n\ninterface FieldShape {\n metrics: React.CSSProperties;\n rtl: boolean;\n}\n\n// The overlay is positioned against the FIELD, not against the root, because the two are not the\n// same box once the ✨ lives inside an `InputGroup`: the group carries the border the input no\n// longer has, and a mirror pinned to the root paints a character-width off.\nconst px = (v: string) => Number.parseFloat(v) || 0;\n\nconst readField = (el: FieldEl, root: HTMLElement): FieldShape => {\n const cs = getComputedStyle(el);\n const rtl = cs.direction === \"rtl\";\n const box = el.getBoundingClientRect();\n const outer = root.getBoundingClientRect();\n // A scrolling textarea takes its scrollbar out of the text's width, and it takes it from the\n // inline-END side in both directions. Without this the mirror wraps a word earlier or later than\n // the field does wherever scrollbars are not overlays — which is not macOS, so it is exactly the\n // kind of divergence nobody sees here.\n const gutter = el.offsetWidth - el.clientWidth - px(cs.borderLeftWidth) - px(cs.borderRightWidth);\n const metrics: Record<string, string | number> = {\n borderStyle: \"solid\",\n borderColor: \"transparent\",\n insetBlockStart: box.top - outer.top,\n insetBlockEnd: outer.bottom - box.bottom,\n insetInlineStart: rtl ? outer.right - box.right : box.left - outer.left,\n insetInlineEnd:\n (rtl ? box.left - outer.left : outer.right - box.right) + (gutter > 0 ? gutter : 0),\n };\n for (const k of METRICS) metrics[k] = cs[k as keyof CSSStyleDeclaration] as string;\n return { metrics: metrics as React.CSSProperties, rtl };\n};\n\nconst REST: FieldShape = { metrics: {}, rtl: false };\n\n/**\n * A safety net rather than the answer. The field grows to fit its offer, so it runs out of height\n * only where a caller has pinned a `max-height` — and there a fade beats a slice.\n *\n * There were three of these. The two horizontal ones masked a continuation that overran a\n * single-line field, which is the case that no longer exists.\n */\nconst GHOST_FADE =\n \"[mask-image:linear-gradient(to_bottom,#000_calc(100%-0.9lh),transparent)]\";\n\n/** Leading space plus the next word — what one press of the accept-a-word key takes. */\nconst nextWord = (ghost: string) => /^\\s*\\S+/.exec(ghost)?.[0] ?? \"\";\n\nexport interface CompleteRootProps {\n complete: (request: InlineCompletionRequest) => AsyncIterable<string>;\n value: string;\n onValueChange: (value: string) => void;\n debounceMs?: number;\n minLength?: number;\n className?: string;\n children: React.ReactNode;\n}\n\nexport function CompleteRoot(props: CompleteRootProps) {\n const { complete, value, onValueChange, debounceMs, minLength, className, children } = props;\n const completion = useInlineCompletion({ complete, debounceMs, minLength });\n const fieldRef = React.useRef<FieldEl | null>(null);\n const rootRef = React.useRef<HTMLDivElement>(null);\n const [caret, setCaret] = React.useState(value.length);\n const [field, setField] = React.useState<FieldShape>(REST);\n\n const ghost = completion.ghost;\n\n // Where the caret must land after WE changed the value. The browser puts it wherever it likes\n // when React writes a new `value`, and taking a word out of the middle of a sentence is the case\n // that makes it obvious.\n const landing = React.useRef<number | null>(null);\n\n React.useLayoutEffect(() => {\n const el = fieldRef.current;\n const at = landing.current;\n if (!el || at === null) return;\n landing.current = null;\n el.setSelectionRange(at, at);\n setCaret(at);\n }, [value]);\n\n // The ghost mirror wears the field's real metrics, so it aligns whatever the size/className.\n React.useLayoutEffect(() => {\n const el = fieldRef.current;\n const root = rootRef.current;\n if (!el || !root) return;\n const sync = () => setField(readField(el, root));\n sync();\n if (typeof ResizeObserver === \"undefined\") return;\n const ro = new ResizeObserver(sync);\n ro.observe(el);\n ro.observe(root);\n return () => ro.disconnect();\n }, []);\n\n const dismiss = completion.dismiss;\n\n /**\n * **A range is not an insertion point, so an offer cannot survive one.**\n *\n * `selectionStart` is the HEAD of a selection, not a caret. Selecting a sentence — or pressing\n * ⌘A — moved our caret to the start of it while the offer stayed live, and the continuation was\n * then drawn from there: on top of the words it was made to follow. Taking it with Tab spliced it\n * in at the same wrong place, because `insert` splits the value at this number too.\n *\n * The fix is not to draw it somewhere else. `InlineCompletionRequest` is explicitly the\n * degenerate range — an insertion point — so while text is selected there is no place the offer\n * *could* go, and the honest answer is to let it go. Collapsing the selection asks again, which\n * costs a debounce and no more.\n */\n const syncCaret = React.useCallback(() => {\n const el = fieldRef.current;\n if (!el || landing.current !== null) return;\n const start = el.selectionStart ?? el.value.length;\n if ((el.selectionEnd ?? start) !== start) {\n dismiss();\n return;\n }\n setCaret(start);\n }, [dismiss]);\n\n /**\n * The caret is where the ghost is **drawn**, so ours drifting from the field's is not a missed\n * update — it paints a continuation on top of the value. Seen live: a ghost starting at character\n * zero, over the sentence it was continuing.\n *\n * `selectionchange` is the only event that reports every way a caret moves — arrows, clicks,\n * drags, Home/End, and a script calling `setSelectionRange`. `click` and `keyup` catch most of\n * them and are kept because they fire first; this catches the rest.\n */\n React.useEffect(() => {\n const el = fieldRef.current;\n if (!el) return;\n const onSelectionChange = () => {\n if (document.activeElement === el) syncCaret();\n };\n document.addEventListener(\"selectionchange\", onSelectionChange);\n return () => document.removeEventListener(\"selectionchange\", onSelectionChange);\n }, [syncCaret]);\n\n const onChange = React.useCallback(\n (e: React.ChangeEvent<FieldEl>) => {\n const el = e.target;\n const at = el.selectionStart ?? el.value.length;\n setCaret(at);\n onValueChange(el.value);\n completion.setValue(el.value, at);\n },\n [completion, onValueChange],\n );\n\n /** Put `text` in at the caret and leave the caret after it. */\n const insert = React.useCallback(\n (text: string) => {\n const next = value.slice(0, caret) + text + value.slice(caret);\n landing.current = caret + text.length;\n onValueChange(next);\n return { next, at: caret + text.length };\n },\n [caret, onValueChange, value],\n );\n\n const accept = React.useCallback(() => {\n if (!ghost) return;\n insert(ghost);\n completion.dismiss();\n fieldRef.current?.focus();\n }, [completion, ghost, insert]);\n\n // Taking one word is not a new request and not a second machine: the word goes in, the caret\n // moves past it, and the offer survives by the same rule that keeps it alive when the reader\n // types that word themselves. There used to be a `taken` offset here doing it by hand.\n const acceptWord = React.useCallback(() => {\n const word = nextWord(ghost);\n if (!word) return;\n const { next, at } = insert(word);\n completion.setValue(next, at);\n }, [completion, ghost, insert]);\n\n const request = React.useCallback(() => {\n const el = fieldRef.current;\n // Where the offer goes, and the two have to agree. Focusing a field nobody has focused yet puts\n // the DOM caret at 0 while our own is still at the end, and the ghost would then be drawn in one\n // place and inserted in another.\n const at = el && el === document.activeElement ? (el.selectionStart ?? caret) : caret;\n setCaret(at);\n completion.ask(value, at);\n el?.focus();\n el?.setSelectionRange(at, at);\n }, [caret, completion, value]);\n\n const onKeyDown = React.useCallback(\n (e: React.KeyboardEvent<FieldEl>) => {\n if (e.defaultPrevented || !ghost) return;\n // Forward, not right: the key that means \"next word\" is mirrored under RTL, and this is the\n // one place in the compound where a key stands for a direction.\n const forward = field.rtl ? \"ArrowLeft\" : \"ArrowRight\";\n if (e.key === \"Tab\") {\n e.preventDefault();\n accept();\n } else if (e.key === forward && (e.ctrlKey || e.metaKey)) {\n e.preventDefault();\n acceptWord();\n } else if (e.key === \"Escape\") {\n completion.dismiss();\n }\n },\n [accept, acceptWord, completion, field.rtl, ghost],\n );\n\n const ctx: CompleteCtx = {\n fieldRef, value, ghost, caret,\n rtl: field.rtl,\n status: completion.status,\n error: completion.error,\n askable: value.trim().length >= (minLength ?? MIN_COMPLETE_LENGTH),\n metrics: field.metrics,\n onChange, onKeyDown, syncCaret, accept, request,\n };\n\n return (\n <Ctx.Provider value={ctx}>\n <ark.div className={cn(\"relative w-full min-w-0\", className)} data-slot=\"complete\" ref={rootRef}>\n {children}\n {/* The ghost itself is `aria-hidden` and streams a chunk a frame, so announcing IT would\n read the same sentence twenty times. This sentence changes once, when there is\n something to take. */}\n <ark.span aria-live=\"polite\" className=\"sr-only\" data-slot=\"complete-status\">\n {ghost ? \"Suggestion ready. Press Tab to accept, Escape to dismiss.\" : \"\"}\n </ark.span>\n </ark.div>\n </Ctx.Provider>\n );\n}\n\nexport function CompleteTextarea({ children }: { children: React.ReactElement }) {\n const ctx = useCtx(\"CompleteTextarea\");\n return (\n <ark.textarea\n aria-keyshortcuts={ctx.ghost ? \"Tab Escape\" : undefined}\n asChild\n data-slot=\"complete-textarea\"\n onChange={ctx.onChange}\n onClick={ctx.syncCaret}\n onKeyDown={ctx.onKeyDown}\n onKeyUp={ctx.syncCaret}\n onSelect={ctx.syncCaret}\n ref={ctx.fieldRef as React.Ref<HTMLTextAreaElement>}\n value={ctx.value}\n >\n {children}\n </ark.textarea>\n );\n}\n\n/**\n * The ✨, bound to the stream — the same mark `SuggestTrigger` wears, so an assisted field looks\n * assisted whichever of the two it has.\n *\n * Pressing it **takes what is on offer and asks when there is nothing**. That is one gesture with\n * two readings on purpose: it is the only way to accept without a keyboard, and the only way back\n * after Escape, which otherwise ends the field's assistance until the next keystroke.\n */\nexport function CompleteMark(props: Omit<AiMarkProps, \"busy\" | \"offering\">) {\n const { label = \"AI assist\", ...rest } = props;\n const ctx = useCtx(\"CompleteMark\");\n const offering = ctx.ghost.length > 0;\n\n return (\n <AiMark\n busy={ctx.status === \"loading\" && !offering}\n // The one binding that legitimately differs from `SuggestMark`, and it is domain and not\n // taste: there is nothing to continue below `minLength`, while a candidate source can answer\n // from an empty field. Everything else about the two marks is bound identically.\n disabled={!offering && !ctx.askable}\n label={offering ? \"Accept suggestion\" : label}\n offering={offering}\n onClick={offering ? ctx.accept : ctx.request}\n {...rest}\n />\n );\n}\n\n/**\n * The continuation drawn where it will land, over the field itself. It wraps and scrolls with the\n * field rather than measuring whether a line of it fits.\n */\nexport function CompleteGhost({ className }: { className?: string }) {\n const ctx = useCtx(\"CompleteGhost\");\n const boxRef = React.useRef<HTMLDivElement>(null);\n const [clipped, setClipped] = React.useState(false);\n\n /**\n * **A continuation that does not fit makes the field taller. It is not cut.**\n *\n * The fade below was the old answer and it was the wrong one: a wrapping field runs out of\n * *height*, and a mask on the last line still reads as a sentence sliced through the middle of a\n * line of type — with, on a `block-end` addon, the ✨ and the keys painted over the slice. Seen on\n * `docs/ai/fields`. An editor gets away with clipping because it can push the following lines\n * down; a `<textarea>` cannot, because the ghost is not in its value. So the field takes the\n * height the offer needs while the offer is on the table, and gives it back when it is not — which\n * is what a textarea does anyway when you type into it. `rows` becomes a floor rather than a fixed\n * size, which is what `rows` already means.\n *\n * **It grows and never shrinks while one offer is live.** Taking a word makes the ghost shorter,\n * and re-measuring down on every word would make the box jitter under the reader mid-sentence for\n * no gain — somebody taking a word is about to take another. It resets the moment there is no\n * ghost, which is every accept, every dismiss and every keystroke that kills the offer.\n *\n */\n React.useLayoutEffect(() => {\n const el = ctx.fieldRef.current;\n const box = boxRef.current;\n if (!el || !box) return;\n if (!ctx.ghost) {\n el.style.removeProperty(\"min-height\");\n return;\n }\n // `minHeight` and not `height`, measured: a `block-end` addon turns the group into a column\n // flex container and the field is `flex: 1 1 0%` in it, so the flex algorithm computes the used\n // height and an explicit `height` is ignored outright — the inline `104px` was on the element\n // and the box stayed 64. Raising the floor is what the flex algorithm does honour, and it is\n // also what this means: `rows` is a floor, and the offer asks for a taller one.\n const cs = getComputedStyle(el);\n const needed = box.scrollHeight + px(cs.borderTopWidth) + px(cs.borderBottomWidth);\n if (needed > el.getBoundingClientRect().height + 1)\n el.style.setProperty(\"min-height\", `${needed}px`);\n }, [ctx.fieldRef, ctx.ghost, ctx.value]);\n\n // An `overflow: hidden` box is still scrollable from script, so the mirror can be parked exactly\n // where the field parked its own text — which is what lets a ghost longer than the remaining\n // line be clipped at the edge instead of hidden entirely.\n React.useLayoutEffect(() => {\n const el = ctx.fieldRef.current;\n const box = boxRef.current;\n if (!el || !box) return;\n const sync = () => {\n box.scrollLeft = el.scrollLeft;\n box.scrollTop = el.scrollTop;\n };\n sync();\n // Does the continuation fit in the space the field actually has? Measured rather than\n // assumed, because the answer changes with the field's size, with the value, and with every\n // chunk that arrives — and after the effect above it should be `false` unless a caller pinned\n // the height.\n setClipped(box.scrollHeight > box.clientHeight);\n el.addEventListener(\"scroll\", sync);\n return () => el.removeEventListener(\"scroll\", sync);\n }, [ctx.caret, ctx.fieldRef, ctx.value, ctx.ghost]);\n\n\n const show = ctx.ghost.length > 0;\n // A caret past the end of the value would split it in the wrong place; the value is the authority.\n const at = Math.min(ctx.caret, ctx.value.length);\n\n return (\n <ark.div\n aria-hidden\n className={cn(\n \"pointer-events-none absolute inset-0 overflow-hidden text-transparent\",\n \"whitespace-pre-wrap break-words\",\n // **A continuation that does not fit fades; it is not guillotined.** The box is\n // `overflow: hidden`, so without this a too-long ghost ends on a hard edge — mid-letter\n // against the ✨. Applied only when the measurement above says it is genuinely cut, which\n // is now only when a caller has pinned the field's height.\n clipped && GHOST_FADE,\n !show && \"invisible\",\n className,\n )}\n data-slot=\"complete-ghost\"\n ref={boxRef}\n style={ctx.metrics}\n >\n {/* The value holds its own space so the ghost starts exactly at the caret — `hidden` would\n collapse it. Split in two rather than one leading run: an offer is made *at* the caret,\n and what comes after it has to keep its place or the tail of the sentence shifts. */}\n <ark.span className=\"invisible\">{ctx.value.slice(0, at)}</ark.span>\n <ark.span className=\"text-faint\">{ctx.ghost}</ark.span>\n <ark.span className=\"invisible\">{ctx.value.slice(at)}</ark.span>\n </ark.div>\n );\n}\n\n/**\n * The keys, while there is something to take.\n *\n * The compound already told one class of reader: the `sr-only` region announces *Press Tab to\n * accept, Escape to dismiss* the moment an offer lands. A sighted keyboard user got nothing —\n * `CompleteHint` carries the keys too, so a composition that uses the ghost taught the gesture\n * nowhere. This is the visible twin of that sentence, and it is a part rather than a fixture\n * because only the caller knows where it fits: beside the ✨ in a `block-end` addon, under the\n * field, nowhere at all on a form of eleven where one line of instruction beats eleven.\n *\n * There is no reference to copy. Gmail taught Tab with a popup shown once ever; Copilot puts a\n * toolbar under the pointer, which is a gesture a keyboard cannot make. Naming the key next to the\n * offer is the version that works for both.\n */\nexport function CompleteKeys({ className }: { className?: string }) {\n const ctx = useCtx(\"CompleteKeys\");\n if (!ctx.ghost) return null;\n return (\n <ark.span\n className={cn(\"inline-flex items-center gap-2 text-muted-foreground text-xs\", className)}\n data-slot=\"complete-keys\"\n >\n <ark.span className=\"inline-flex items-center gap-1 whitespace-nowrap\">\n <Kbd>Tab</Kbd> accept\n </ark.span>\n <ark.span className=\"inline-flex items-center gap-1 whitespace-nowrap\">\n <Kbd>Esc</Kbd> dismiss\n </ark.span>\n </ark.span>\n );\n}\n\n/** Below-field alternative to `CompleteGhost`, for a field the overlay cannot sit over. */\nexport function CompleteHint({ className }: { className?: string }) {\n const ctx = useCtx(\"CompleteHint\");\n if (!ctx.ghost) return null;\n return (\n <ark.p\n className={cn(\"mt-1.5 whitespace-pre-wrap break-words text-muted-foreground text-sm\", className)}\n data-slot=\"complete-hint\"\n >\n <ark.span className=\"text-faint\">{ctx.ghost.replace(/^\\s+/, \"\")}</ark.span>{\" \"}\n <CompleteKeys />\n </ark.p>\n );\n}\n\n\n/**\n * What went wrong, in place. Renders nothing when nothing did.\n *\n * `Suggest` has always shown its failures — the strip prints them where the candidates would be —\n * and this side showed none at all: the ✨ stopped spinning and the field said nothing, because\n * `error` was on the hook and no part read it. It is a separate part rather than a line inside\n * `CompleteHint` because the ghost surface is usually `CompleteGhost`, an overlay with no room\n * under it, and a failure has to be readable in both.\n */\nexport function CompleteError({ className }: { className?: string }) {\n const ctx = useCtx(\"CompleteError\");\n if (ctx.error === null) return null;\n return (\n <ark.p\n className={cn(\"mt-1.5 text-destructive-foreground text-sm\", className)}\n data-slot=\"complete-error\"\n role=\"alert\"\n >\n {ctx.error}\n </ark.p>\n );\n}\n"],"names":[],"mappings":";;;;;;;AA6CA;AAEE;AACA;AACA;AACF;AAEgB;AACd;AAAc;AAAY;AAAc;AAAa;AAAiB;AACtE;AAAiB;AAAc;AAAgB;AAAiB;AAChE;AAAkB;AAAoB;AACxC;AAaE;AASiD;AAClC;AACA;AACoB;AACC;AACiC;AAEgB;AAErF;AACA;AACF;;AAeoC;AAA8B;AAY3D;AACL;AAcA;AACE;AAEA;AAGW;AAKX;AAEA;AACA;AAEA;AACA;AACA;AAEgB;AAGlB;AAgBE;AACA;AACA;AACA;AACE;AACA;AAAA;AAEF;AAAc;AAYhB;AACE;AACA;AACA;AACE;AAAmC;AAErC;AAC8E;AAGhF;AAAuB;AAEnB;AAEA;AAEgC;AAClC;AAC0B;AAIP;AAEjB;AACA;AAEgC;AAClC;AAC4B;;AAI5B;AAGkB;AAOlB;AACA;AACA;AACA;AAA4B;AAI5B;AAKA;AAG0B;AAGJ;AAEpB;AAGA;AACA;AAOa;AAEf;AACiD;AAG1B;AACvB;AAAU;AAAO;AAAO;AACb;AACQ;AACD;AAC4B;AAC/B;AACf;AAAU;AAAW;AAAW;AAAQ;AAG1C;AAGO;AAAA;AAMD;AAIR;AAEO;AACL;AACA;AACE;AAAK;AAAJ;AAC+C;AACvC;AACG;AACI;AACD;AACE;AACF;AACC;AACL;AACE;AAEV;AAAA;AAGP;AAUO;AACL;AAIA;AACE;AAAC;AAAA;AACoC;AAIP;AACY;AACxC;AACqC;AACjC;AAAA;AAGV;AAMO;AACL;AAsBA;AACE;AAEA;AACA;AACE;AACA;AAAA;AAOF;AAEA;AACkD;AAOlD;AAEA;AACA;AACE;AACmB;AAErB;AAOkD;AAIpD;AAIA;AACE;AAAK;AAAJ;AACY;AACA;AACT;AACA;AAAA;AAAA;AAAA;AAAA;AAKW;AACF;AACT;AAAA;AAEQ;AACL;AACM;AAKX;AAAwD;AACZ;AACS;AAAA;AAAA;AAG3D;AAgBO;AAEL;AAEE;AAAK;AAAJ;AACwF;AAC7E;AAEV;AACE;AAAQ;AAAM;AAChB;AAEE;AAAQ;AAAM;AAChB;AAAA;AAAA;AAGN;AAGO;AACL;AACA;AAEE;AAAK;AAAJ;AACgG;AACrF;AAEV;AAAgE;AAAY;AAC9D;AAAA;AAAA;AAGpB;AAYO;AACL;AACA;AAEE;AAAK;AAAJ;AACsE;AAC3D;AACL;AAEA;AAAA;AAGX;;;;;;;;;;"}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { ark } from '@ark-ui/react/factory';
|
|
2
|
+
import { Button } from '@kanzo-tech/ui';
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
/**
|
|
5
|
+
* The transcript's scroll container, and the pin is the whole component.
|
|
6
|
+
*
|
|
7
|
+
* **The naive version, which both real consumers wrote:** an effect on the message array calling
|
|
8
|
+
* `scrollTo({ top: scrollHeight, behavior: "smooth" })`. It follows the tail unconditionally, so a
|
|
9
|
+
* reader who scrolled up to re-read something is dragged back down by the next token, every token,
|
|
10
|
+
* for as long as the answer is streaming. Following is only correct while the reader is *already*
|
|
11
|
+
* at the tail: scrolling up releases the pin, and `ConversationScrollButton` is how they take it
|
|
12
|
+
* back. Roughly forty lines of listener and observer, and no dependency.
|
|
13
|
+
*/
|
|
14
|
+
export declare const Conversation: (props: React.ComponentProps<typeof ark.div>) => React.JSX.Element;
|
|
15
|
+
/**
|
|
16
|
+
* The scrolling region.
|
|
17
|
+
*
|
|
18
|
+
* ARIA: `role="log"` carries an implicit `aria-live="polite"`, so a message appended while the
|
|
19
|
+
* reader is somewhere else is announced after what they are on rather than over it. Nothing inside
|
|
20
|
+
* declares a role — a message is content, not a widget — so there is no keyboard contract to owe.
|
|
21
|
+
*/
|
|
22
|
+
export declare const ConversationContent: (props: React.ComponentProps<typeof ark.div>) => React.JSX.Element;
|
|
23
|
+
/**
|
|
24
|
+
* The stage for the zero state, not the zero state itself: a centred column with a reading measure,
|
|
25
|
+
* which `Item` and its parts are then composed into. There is deliberately no `title` / `description`
|
|
26
|
+
* / `icon` prop — `EmptyState` was exactly that and it is a tombstone in `@kanzo-tech/ui`.
|
|
27
|
+
*/
|
|
28
|
+
export declare const ConversationEmpty: (props: React.ComponentProps<typeof ark.div>) => React.JSX.Element;
|
|
29
|
+
/**
|
|
30
|
+
* Present only while the pin is released, and pressing it takes the pin back.
|
|
31
|
+
*
|
|
32
|
+
* It floats *over* a transcript that is still growing underneath it, which is why it enters rather
|
|
33
|
+
* than appears and why it paints its own ground: `variant="outline"` is `bg-transparent` in light
|
|
34
|
+
* mode, so without `bg-background` the message it covers reads straight through it. Dark keeps the
|
|
35
|
+
* variant's own `bg-field`.
|
|
36
|
+
*/
|
|
37
|
+
export declare const ConversationScrollButton: (props: React.ComponentProps<typeof Button>) => React.JSX.Element | null;
|
|
38
|
+
//# sourceMappingURL=conversation.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"conversation.d.ts","sourceRoot":"","sources":["../src/conversation.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAE,GAAG,EAAE,MAAM,uBAAuB,CAAC;AAE5C,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAC/B,OAAO,EAAE,MAAM,EAAM,MAAM,gBAAgB,CAAC;AAmB5C;;;;;;;;;GASG;AACH,eAAO,MAAM,YAAY,GAAI,OAAO,KAAK,CAAC,cAAc,CAAC,OAAO,GAAG,CAAC,GAAG,CAAC,sBAsDvE,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,mBAAmB,GAAI,OAAO,KAAK,CAAC,cAAc,CAAC,OAAO,GAAG,CAAC,GAAG,CAAC,sBA0B9E,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,GAAI,OAAO,KAAK,CAAC,cAAc,CAAC,OAAO,GAAG,CAAC,GAAG,CAAC,sBAgB5E,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,wBAAwB,GAAI,OAAO,KAAK,CAAC,cAAc,CAAC,OAAO,MAAM,CAAC,6BAmClF,CAAC"}
|