@kanzo-tech/ai 0.12.0 → 0.13.1
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/complete.d.ts +5 -1
- package/dist/complete.d.ts.map +1 -1
- package/dist/complete.js +77 -73
- package/dist/complete.js.map +1 -1
- package/dist/conversation.d.ts +0 -6
- package/dist/conversation.d.ts.map +1 -1
- package/dist/conversation.js +51 -68
- package/dist/conversation.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +54 -55
- package/package.json +4 -4
package/dist/complete.d.ts
CHANGED
|
@@ -65,8 +65,12 @@ export declare function CompleteKeys({ acceptLabel, className, dismissLabel, }:
|
|
|
65
65
|
dismissLabel?: string;
|
|
66
66
|
}): React.JSX.Element | null;
|
|
67
67
|
/** Below-field alternative to `CompleteGhost`, for a field the overlay cannot sit over. */
|
|
68
|
-
export declare function CompleteHint({ className }: {
|
|
68
|
+
export declare function CompleteHint({ acceptLabel, className, dismissLabel, }: {
|
|
69
|
+
/** The word after the `Tab` key, as on `CompleteKeys`. */
|
|
70
|
+
acceptLabel?: string;
|
|
69
71
|
className?: string;
|
|
72
|
+
/** The word after the `Esc` key, as on `CompleteKeys`. */
|
|
73
|
+
dismissLabel?: string;
|
|
70
74
|
}): React.JSX.Element | null;
|
|
71
75
|
/**
|
|
72
76
|
* What went wrong, in place. Renders nothing when nothing did.
|
package/dist/complete.d.ts.map
CHANGED
|
@@ -1 +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;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;CAC3B;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,iBAAiB,qBAyLpD;AAED,wBAAgB,gBAAgB,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,EAAE,KAAK,CAAC,YAAY,CAAA;CAAE,qBAkB9E;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAC1B,KAAK,EAAE,IAAI,CAAC,WAAW,EAAE,MAAM,GAAG,UAAU,CAAC,GAAG;IAC9C;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB,qBAmBF;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,EAAE,SAAS,EAAE,EAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,qBA8FlE;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,YAAY,CAAC,EAC3B,WAAsB,EACtB,SAAS,EACT,YAAwB,GACzB,EAAE;IACD,oCAAoC;IACpC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,oCAAoC;IACpC,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,4BAgBA;AAED,2FAA2F;AAC3F,wBAAgB,YAAY,CAAC,
|
|
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;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;CAC3B;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,iBAAiB,qBAyLpD;AAED,wBAAgB,gBAAgB,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,EAAE,KAAK,CAAC,YAAY,CAAA;CAAE,qBAkB9E;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAC1B,KAAK,EAAE,IAAI,CAAC,WAAW,EAAE,MAAM,GAAG,UAAU,CAAC,GAAG;IAC9C;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB,qBAmBF;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,EAAE,SAAS,EAAE,EAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,qBA8FlE;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,YAAY,CAAC,EAC3B,WAAsB,EACtB,SAAS,EACT,YAAwB,GACzB,EAAE;IACD,oCAAoC;IACpC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,oCAAoC;IACpC,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,4BAgBA;AAED,2FAA2F;AAC3F,wBAAgB,YAAY,CAAC,EAC3B,WAAW,EACX,SAAS,EACT,YAAY,GACb,EAAE;IACD,0DAA0D;IAC1D,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,0DAA0D;IAC1D,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,4BAYA;AAGD;;;;;;;;GAQG;AACH,wBAAgB,aAAa,CAAC,EAAE,SAAS,EAAE,EAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,4BAYlE"}
|
package/dist/complete.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as m, jsxs as v } from "react/jsx-runtime";
|
|
3
3
|
import { ark as f } from "@ark-ui/react/factory";
|
|
4
|
-
import * as
|
|
4
|
+
import * as c from "react";
|
|
5
5
|
import { cn as y, Kbd as H } from "@kanzo-tech/ui";
|
|
6
6
|
import { AiMark as _ } from "./ai-mark.js";
|
|
7
7
|
import { useInlineCompletion as G, MIN_COMPLETE_LENGTH as O } from "./use-ai.js";
|
|
8
|
-
const M =
|
|
9
|
-
const e =
|
|
8
|
+
const M = c.createContext(null), x = (r) => {
|
|
9
|
+
const e = c.useContext(M);
|
|
10
10
|
if (!e) throw new Error(`${r} must render inside <CompleteRoot>`);
|
|
11
11
|
return e;
|
|
12
12
|
}, z = [
|
|
@@ -26,16 +26,16 @@ const M = o.createContext(null), x = (r) => {
|
|
|
26
26
|
"borderBottomWidth",
|
|
27
27
|
"borderLeftWidth"
|
|
28
28
|
], S = (r) => Number.parseFloat(r) || 0, V = (r, e) => {
|
|
29
|
-
const
|
|
29
|
+
const i = getComputedStyle(r), s = i.direction === "rtl", l = r.getBoundingClientRect(), a = e.getBoundingClientRect(), b = r.offsetWidth - r.clientWidth - S(i.borderLeftWidth) - S(i.borderRightWidth), u = {
|
|
30
30
|
borderStyle: "solid",
|
|
31
31
|
borderColor: "transparent",
|
|
32
|
-
insetBlockStart:
|
|
33
|
-
insetBlockEnd:
|
|
34
|
-
insetInlineStart:
|
|
35
|
-
insetInlineEnd: (
|
|
32
|
+
insetBlockStart: l.top - a.top,
|
|
33
|
+
insetBlockEnd: a.bottom - l.bottom,
|
|
34
|
+
insetInlineStart: s ? a.right - l.right : l.left - a.left,
|
|
35
|
+
insetInlineEnd: (s ? l.left - a.left : a.right - l.right) + (b > 0 ? b : 0)
|
|
36
36
|
};
|
|
37
|
-
for (const d of z)
|
|
38
|
-
return { metrics:
|
|
37
|
+
for (const d of z) u[d] = i[d];
|
|
38
|
+
return { metrics: u, rtl: s };
|
|
39
39
|
}, j = { metrics: {}, rtl: !1 }, q = "[mask-image:linear-gradient(to_bottom,#000_calc(100%-0.9lh),transparent)]", $ = (r) => {
|
|
40
40
|
var e;
|
|
41
41
|
return ((e = /^\s*\S+/.exec(r)) == null ? void 0 : e[0]) ?? "";
|
|
@@ -43,18 +43,18 @@ const M = o.createContext(null), x = (r) => {
|
|
|
43
43
|
function ee(r) {
|
|
44
44
|
const {
|
|
45
45
|
announcement: e = "Suggestion ready. Press Tab to accept, Escape to dismiss.",
|
|
46
|
-
complete:
|
|
47
|
-
value:
|
|
48
|
-
onValueChange:
|
|
49
|
-
debounceMs:
|
|
46
|
+
complete: i,
|
|
47
|
+
value: s,
|
|
48
|
+
onValueChange: l,
|
|
49
|
+
debounceMs: a,
|
|
50
50
|
minLength: b,
|
|
51
|
-
className:
|
|
51
|
+
className: u,
|
|
52
52
|
children: d
|
|
53
|
-
} = r,
|
|
54
|
-
|
|
53
|
+
} = r, o = G({ complete: i, debounceMs: a, minLength: b }), p = c.useRef(null), W = c.useRef(null), [h, R] = c.useState(s.length), [k, A] = c.useState(j), g = o.ghost, E = c.useRef(null);
|
|
54
|
+
c.useLayoutEffect(() => {
|
|
55
55
|
const t = p.current, n = E.current;
|
|
56
56
|
!t || n === null || (E.current = null, t.setSelectionRange(n, n), R(n));
|
|
57
|
-
}, [
|
|
57
|
+
}, [s]), c.useLayoutEffect(() => {
|
|
58
58
|
const t = p.current, n = W.current;
|
|
59
59
|
if (!t || !n) return;
|
|
60
60
|
const C = () => A(V(t, n));
|
|
@@ -62,7 +62,7 @@ function ee(r) {
|
|
|
62
62
|
const N = new ResizeObserver(C);
|
|
63
63
|
return N.observe(t), N.observe(n), () => N.disconnect();
|
|
64
64
|
}, []);
|
|
65
|
-
const K =
|
|
65
|
+
const K = o.dismiss, L = c.useCallback(() => {
|
|
66
66
|
const t = p.current;
|
|
67
67
|
if (!t || E.current !== null) return;
|
|
68
68
|
const n = t.selectionStart ?? t.value.length;
|
|
@@ -72,7 +72,7 @@ function ee(r) {
|
|
|
72
72
|
}
|
|
73
73
|
R(n);
|
|
74
74
|
}, [K]);
|
|
75
|
-
|
|
75
|
+
c.useEffect(() => {
|
|
76
76
|
const t = p.current;
|
|
77
77
|
if (!t) return;
|
|
78
78
|
const n = () => {
|
|
@@ -80,45 +80,45 @@ function ee(r) {
|
|
|
80
80
|
};
|
|
81
81
|
return document.addEventListener("selectionchange", n), () => document.removeEventListener("selectionchange", n);
|
|
82
82
|
}, [L]);
|
|
83
|
-
const D =
|
|
83
|
+
const D = c.useCallback(
|
|
84
84
|
(t) => {
|
|
85
85
|
const n = t.target, C = n.selectionStart ?? n.value.length;
|
|
86
|
-
R(C),
|
|
86
|
+
R(C), l(n.value), o.setValue(n.value, C);
|
|
87
87
|
},
|
|
88
|
-
[
|
|
89
|
-
), w =
|
|
88
|
+
[o, l]
|
|
89
|
+
), w = c.useCallback(
|
|
90
90
|
(t) => {
|
|
91
|
-
const n =
|
|
92
|
-
return E.current = h + t.length,
|
|
91
|
+
const n = s.slice(0, h) + t + s.slice(h);
|
|
92
|
+
return E.current = h + t.length, l(n), { next: n, at: h + t.length };
|
|
93
93
|
},
|
|
94
|
-
[h,
|
|
95
|
-
), T =
|
|
94
|
+
[h, l, s]
|
|
95
|
+
), T = c.useCallback(() => {
|
|
96
96
|
var t;
|
|
97
|
-
g && (w(g),
|
|
98
|
-
}, [
|
|
97
|
+
g && (w(g), o.dismiss(), (t = p.current) == null || t.focus());
|
|
98
|
+
}, [o, g, w]), B = c.useCallback(() => {
|
|
99
99
|
const t = $(g);
|
|
100
100
|
if (!t) return;
|
|
101
101
|
const { next: n, at: C } = w(t);
|
|
102
|
-
|
|
103
|
-
}, [
|
|
102
|
+
o.setValue(n, C);
|
|
103
|
+
}, [o, g, w]), I = c.useCallback(() => {
|
|
104
104
|
const t = p.current, n = t && t === document.activeElement ? t.selectionStart ?? h : h;
|
|
105
|
-
R(n),
|
|
106
|
-
}, [h,
|
|
105
|
+
R(n), o.ask(s, n), t == null || t.focus(), t == null || t.setSelectionRange(n, n);
|
|
106
|
+
}, [h, o, s]), P = c.useCallback(
|
|
107
107
|
(t) => {
|
|
108
108
|
if (t.defaultPrevented || !g) return;
|
|
109
109
|
const n = k.rtl ? "ArrowLeft" : "ArrowRight";
|
|
110
|
-
t.key === "Tab" ? (t.preventDefault(), T()) : t.key === n && (t.ctrlKey || t.metaKey) ? (t.preventDefault(), B()) : t.key === "Escape" &&
|
|
110
|
+
t.key === "Tab" ? (t.preventDefault(), T()) : t.key === n && (t.ctrlKey || t.metaKey) ? (t.preventDefault(), B()) : t.key === "Escape" && o.dismiss();
|
|
111
111
|
},
|
|
112
|
-
[T, B,
|
|
112
|
+
[T, B, o, k.rtl, g]
|
|
113
113
|
), F = {
|
|
114
114
|
fieldRef: p,
|
|
115
|
-
value:
|
|
115
|
+
value: s,
|
|
116
116
|
ghost: g,
|
|
117
117
|
caret: h,
|
|
118
118
|
rtl: k.rtl,
|
|
119
|
-
status:
|
|
120
|
-
error:
|
|
121
|
-
askable:
|
|
119
|
+
status: o.status,
|
|
120
|
+
error: o.error,
|
|
121
|
+
askable: s.trim().length >= (b ?? O),
|
|
122
122
|
metrics: k.metrics,
|
|
123
123
|
onChange: D,
|
|
124
124
|
onKeyDown: P,
|
|
@@ -126,7 +126,7 @@ function ee(r) {
|
|
|
126
126
|
accept: T,
|
|
127
127
|
request: I
|
|
128
128
|
};
|
|
129
|
-
return /* @__PURE__ */ m(M.Provider, { value: F, children: /* @__PURE__ */ v(f.div, { className: y("relative w-full min-w-0",
|
|
129
|
+
return /* @__PURE__ */ m(M.Provider, { value: F, children: /* @__PURE__ */ v(f.div, { className: y("relative w-full min-w-0", u), "data-slot": "complete", ref: W, children: [
|
|
130
130
|
d,
|
|
131
131
|
/* @__PURE__ */ m(f.span, { "aria-live": "polite", className: "sr-only", "data-slot": "complete-status", children: g ? e : "" })
|
|
132
132
|
] }) });
|
|
@@ -151,39 +151,39 @@ function te({ children: r }) {
|
|
|
151
151
|
);
|
|
152
152
|
}
|
|
153
153
|
function ne(r) {
|
|
154
|
-
const { label: e = "AI assist", offeringLabel:
|
|
154
|
+
const { label: e = "AI assist", offeringLabel: i = "Accept suggestion", ...s } = r, l = x("CompleteMark"), a = l.ghost.length > 0;
|
|
155
155
|
return /* @__PURE__ */ m(
|
|
156
156
|
_,
|
|
157
157
|
{
|
|
158
|
-
busy:
|
|
159
|
-
disabled: !
|
|
160
|
-
label:
|
|
161
|
-
offering:
|
|
162
|
-
onClick:
|
|
163
|
-
...
|
|
158
|
+
busy: l.status === "loading" && !a,
|
|
159
|
+
disabled: !a && !l.askable,
|
|
160
|
+
label: a ? i : e,
|
|
161
|
+
offering: a,
|
|
162
|
+
onClick: a ? l.accept : l.request,
|
|
163
|
+
...s
|
|
164
164
|
}
|
|
165
165
|
);
|
|
166
166
|
}
|
|
167
167
|
function re({ className: r }) {
|
|
168
|
-
const e = x("CompleteGhost"),
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
if (!
|
|
168
|
+
const e = x("CompleteGhost"), i = c.useRef(null), [s, l] = c.useState(!1);
|
|
169
|
+
c.useLayoutEffect(() => {
|
|
170
|
+
const u = e.fieldRef.current, d = i.current;
|
|
171
|
+
if (!u || !d) return;
|
|
172
172
|
if (!e.ghost) {
|
|
173
|
-
|
|
173
|
+
u.style.removeProperty("min-height");
|
|
174
174
|
return;
|
|
175
175
|
}
|
|
176
|
-
const
|
|
177
|
-
p >
|
|
178
|
-
}, [e.fieldRef, e.ghost, e.value]),
|
|
179
|
-
const
|
|
180
|
-
if (!
|
|
181
|
-
const
|
|
182
|
-
d.scrollLeft =
|
|
176
|
+
const o = getComputedStyle(u), p = d.scrollHeight + S(o.borderTopWidth) + S(o.borderBottomWidth);
|
|
177
|
+
p > u.getBoundingClientRect().height + 1 && u.style.setProperty("min-height", `${p}px`);
|
|
178
|
+
}, [e.fieldRef, e.ghost, e.value]), c.useLayoutEffect(() => {
|
|
179
|
+
const u = e.fieldRef.current, d = i.current;
|
|
180
|
+
if (!u || !d) return;
|
|
181
|
+
const o = () => {
|
|
182
|
+
d.scrollLeft = u.scrollLeft, d.scrollTop = u.scrollTop;
|
|
183
183
|
};
|
|
184
|
-
return
|
|
184
|
+
return o(), l(d.scrollHeight > d.clientHeight), u.addEventListener("scroll", o), () => u.removeEventListener("scroll", o);
|
|
185
185
|
}, [e.caret, e.fieldRef, e.value, e.ghost]);
|
|
186
|
-
const
|
|
186
|
+
const a = e.ghost.length > 0, b = Math.min(e.caret, e.value.length);
|
|
187
187
|
return /* @__PURE__ */ v(
|
|
188
188
|
f.div,
|
|
189
189
|
{
|
|
@@ -195,12 +195,12 @@ function re({ className: r }) {
|
|
|
195
195
|
// `overflow: hidden`, so without this a too-long ghost ends on a hard edge — mid-letter
|
|
196
196
|
// against the ✨. Applied only when the measurement above says it is genuinely cut, which
|
|
197
197
|
// is now only when a caller has pinned the field's height.
|
|
198
|
-
|
|
199
|
-
!
|
|
198
|
+
s && q,
|
|
199
|
+
!a && "invisible",
|
|
200
200
|
r
|
|
201
201
|
),
|
|
202
202
|
"data-slot": "complete-ghost",
|
|
203
|
-
ref:
|
|
203
|
+
ref: i,
|
|
204
204
|
style: e.metrics,
|
|
205
205
|
children: [
|
|
206
206
|
/* @__PURE__ */ m(f.span, { className: "invisible", children: e.value.slice(0, b) }),
|
|
@@ -213,7 +213,7 @@ function re({ className: r }) {
|
|
|
213
213
|
function U({
|
|
214
214
|
acceptLabel: r = "accept",
|
|
215
215
|
className: e,
|
|
216
|
-
dismissLabel:
|
|
216
|
+
dismissLabel: i = "dismiss"
|
|
217
217
|
}) {
|
|
218
218
|
return x("CompleteKeys").ghost ? /* @__PURE__ */ v(
|
|
219
219
|
f.span,
|
|
@@ -229,23 +229,27 @@ function U({
|
|
|
229
229
|
/* @__PURE__ */ v(f.span, { className: "inline-flex items-center gap-1 whitespace-nowrap", children: [
|
|
230
230
|
/* @__PURE__ */ m(H, { children: "Esc" }),
|
|
231
231
|
" ",
|
|
232
|
-
|
|
232
|
+
i
|
|
233
233
|
] })
|
|
234
234
|
]
|
|
235
235
|
}
|
|
236
236
|
) : null;
|
|
237
237
|
}
|
|
238
|
-
function se({
|
|
239
|
-
|
|
240
|
-
|
|
238
|
+
function se({
|
|
239
|
+
acceptLabel: r,
|
|
240
|
+
className: e,
|
|
241
|
+
dismissLabel: i
|
|
242
|
+
}) {
|
|
243
|
+
const s = x("CompleteHint");
|
|
244
|
+
return s.ghost ? /* @__PURE__ */ v(
|
|
241
245
|
f.p,
|
|
242
246
|
{
|
|
243
|
-
className: y("mt-1.5 whitespace-pre-wrap break-words text-muted-foreground text-sm",
|
|
247
|
+
className: y("mt-1.5 whitespace-pre-wrap break-words text-muted-foreground text-sm", e),
|
|
244
248
|
"data-slot": "complete-hint",
|
|
245
249
|
children: [
|
|
246
|
-
/* @__PURE__ */ m(f.span, { className: "text-faint", children:
|
|
250
|
+
/* @__PURE__ */ m(f.span, { className: "text-faint", children: s.ghost.replace(/^\s+/, "") }),
|
|
247
251
|
" ",
|
|
248
|
-
/* @__PURE__ */ m(U, {})
|
|
252
|
+
/* @__PURE__ */ m(U, { acceptLabel: r, dismissLabel: i })
|
|
249
253
|
]
|
|
250
254
|
}
|
|
251
255
|
) : null;
|
package/dist/complete.js.map
CHANGED
|
@@ -1 +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 /**\n * What a screen reader hears once, when a suggestion lands.\n *\n * @default \"Suggestion ready. Press Tab to accept, Escape to dismiss.\"\n */\n announcement?: string;\n className?: string;\n children: React.ReactNode;\n}\n\nexport function CompleteRoot(props: CompleteRootProps) {\n const {\n announcement = \"Suggestion ready. Press Tab to accept, Escape to dismiss.\",\n complete,\n value,\n onValueChange,\n debounceMs,\n minLength,\n className,\n children,\n } = 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 ? announcement : \"\"}\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(\n props: Omit<AiMarkProps, \"busy\" | \"offering\"> & {\n /**\n * The accessible name while a suggestion is on offer, when `label` names the mark at rest.\n *\n * @default \"Accept suggestion\"\n */\n offeringLabel?: string;\n }\n) {\n const { label = \"AI assist\", offeringLabel = \"Accept suggestion\", ...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 ? offeringLabel : 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({\n acceptLabel = \"accept\",\n className,\n dismissLabel = \"dismiss\",\n}: {\n /** The word after the `Tab` key. */\n acceptLabel?: string;\n className?: string;\n /** The word after the `Esc` key. */\n dismissLabel?: string;\n}) {\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> {acceptLabel}\n </ark.span>\n <ark.span className=\"inline-flex items-center gap-1 whitespace-nowrap\">\n <Kbd>Esc</Kbd> {dismissLabel}\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;AAkB3D;AACL;AAAM;AACW;AACf;AACA;AACA;AACA;AACA;AACA;AACA;AAeF;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;AAUL;AAIA;AACE;AAAC;AAAA;AACoC;AAIP;AACM;AAClC;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;AAAsB;AACb;AACd;AAEF;AAQE;AAEE;AAAK;AAAJ;AACwF;AAC7E;AAEV;AACE;AAAQ;AAAM;AAAE;AAClB;AAEE;AAAQ;AAAM;AAAE;AAClB;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;;;;;;;;;;"}
|
|
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 /**\n * What a screen reader hears once, when a suggestion lands.\n *\n * @default \"Suggestion ready. Press Tab to accept, Escape to dismiss.\"\n */\n announcement?: string;\n className?: string;\n children: React.ReactNode;\n}\n\nexport function CompleteRoot(props: CompleteRootProps) {\n const {\n announcement = \"Suggestion ready. Press Tab to accept, Escape to dismiss.\",\n complete,\n value,\n onValueChange,\n debounceMs,\n minLength,\n className,\n children,\n } = 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 ? announcement : \"\"}\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(\n props: Omit<AiMarkProps, \"busy\" | \"offering\"> & {\n /**\n * The accessible name while a suggestion is on offer, when `label` names the mark at rest.\n *\n * @default \"Accept suggestion\"\n */\n offeringLabel?: string;\n }\n) {\n const { label = \"AI assist\", offeringLabel = \"Accept suggestion\", ...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 ? offeringLabel : 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({\n acceptLabel = \"accept\",\n className,\n dismissLabel = \"dismiss\",\n}: {\n /** The word after the `Tab` key. */\n acceptLabel?: string;\n className?: string;\n /** The word after the `Esc` key. */\n dismissLabel?: string;\n}) {\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> {acceptLabel}\n </ark.span>\n <ark.span className=\"inline-flex items-center gap-1 whitespace-nowrap\">\n <Kbd>Esc</Kbd> {dismissLabel}\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({\n acceptLabel,\n className,\n dismissLabel,\n}: {\n /** The word after the `Tab` key, as on `CompleteKeys`. */\n acceptLabel?: string;\n className?: string;\n /** The word after the `Esc` key, as on `CompleteKeys`. */\n dismissLabel?: string;\n}) {\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 acceptLabel={acceptLabel} dismissLabel={dismissLabel} />\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;AAkB3D;AACL;AAAM;AACW;AACf;AACA;AACA;AACA;AACA;AACA;AACA;AAeF;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;AAUL;AAIA;AACE;AAAC;AAAA;AACoC;AAIP;AACM;AAClC;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;AAAsB;AACb;AACd;AAEF;AAQE;AAEE;AAAK;AAAJ;AACwF;AAC7E;AAEV;AACE;AAAQ;AAAM;AAAE;AAClB;AAEE;AAAQ;AAAM;AAAE;AAClB;AAAA;AAAA;AAGN;AAGO;AAAsB;AAC3B;AACA;AAEF;AAOE;AACA;AAEE;AAAK;AAAJ;AACgG;AACrF;AAEV;AAAgE;AAAY;AACR;AAAA;AAAA;AAG1E;AAYO;AACL;AACA;AAEE;AAAK;AAAJ;AACsE;AAC3D;AACL;AAEA;AAAA;AAGX;;;;;;;;;;"}
|
package/dist/conversation.d.ts
CHANGED
|
@@ -20,12 +20,6 @@ export declare const Conversation: (props: React.ComponentProps<typeof ark.div>)
|
|
|
20
20
|
* declares a role — a message is content, not a widget — so there is no keyboard contract to owe.
|
|
21
21
|
*/
|
|
22
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 the caller's markup is 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
23
|
/**
|
|
30
24
|
* Present only while the pin is released, and pressing it takes the pin back.
|
|
31
25
|
*
|
|
@@ -1 +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
|
|
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;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,wBAAwB,GAAI,OAAO,KAAK,CAAC,cAAc,CAAC,OAAO,MAAM,CAAC,6BAmClF,CAAC"}
|
package/dist/conversation.js
CHANGED
|
@@ -1,118 +1,101 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx as
|
|
3
|
-
import { ark as
|
|
2
|
+
import { jsx as i } from "react/jsx-runtime";
|
|
3
|
+
import { ark as m } from "@ark-ui/react/factory";
|
|
4
4
|
import { ArrowDownIcon as E } from "lucide-react";
|
|
5
|
-
import * as
|
|
6
|
-
import { cn as
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
if (!
|
|
10
|
-
return
|
|
11
|
-
},
|
|
12
|
-
const { className:
|
|
13
|
-
t && (
|
|
5
|
+
import * as r from "react";
|
|
6
|
+
import { cn as v, Button as N } from "@kanzo-tech/ui";
|
|
7
|
+
const h = r.createContext(null), w = (s) => {
|
|
8
|
+
const o = r.useContext(h);
|
|
9
|
+
if (!o) throw new Error(`${s} must render inside <Conversation>`);
|
|
10
|
+
return o;
|
|
11
|
+
}, S = 24, k = (s) => {
|
|
12
|
+
const { className: o, children: n, slot: l, ...a } = s, [t, u] = r.useState(null), [c, C] = r.useState(null), [g, x] = r.useState(!0), f = r.useRef(!0), R = r.useCallback(() => {
|
|
13
|
+
t && (f.current = !0, x(!0), t.scrollTop = t.scrollHeight);
|
|
14
14
|
}, [t]);
|
|
15
|
-
return
|
|
15
|
+
return r.useEffect(() => {
|
|
16
16
|
if (!t) return;
|
|
17
|
-
const
|
|
18
|
-
const p = t.scrollHeight - t.scrollTop - t.clientHeight <=
|
|
19
|
-
|
|
17
|
+
const d = () => {
|
|
18
|
+
const p = t.scrollHeight - t.scrollTop - t.clientHeight <= S;
|
|
19
|
+
f.current = p, x(p);
|
|
20
20
|
};
|
|
21
|
-
|
|
21
|
+
d(), t.addEventListener("scroll", d, { passive: !0 });
|
|
22
22
|
const b = () => {
|
|
23
|
-
|
|
24
|
-
},
|
|
25
|
-
return
|
|
26
|
-
t.removeEventListener("scroll",
|
|
23
|
+
f.current && (t.scrollTop = t.scrollHeight);
|
|
24
|
+
}, e = typeof ResizeObserver > "u" ? null : new ResizeObserver(b);
|
|
25
|
+
return e == null || e.observe(t), c && (e == null || e.observe(c)), () => {
|
|
26
|
+
t.removeEventListener("scroll", d), e == null || e.disconnect();
|
|
27
27
|
};
|
|
28
|
-
}, [t,
|
|
29
|
-
|
|
28
|
+
}, [t, c]), /* @__PURE__ */ i(
|
|
29
|
+
h.Provider,
|
|
30
30
|
{
|
|
31
|
-
value: { viewportRef: u, contentRef: C, pinned: g, scrollToEnd:
|
|
32
|
-
children: /* @__PURE__ */
|
|
33
|
-
|
|
31
|
+
value: { viewportRef: u, contentRef: C, pinned: g, scrollToEnd: R },
|
|
32
|
+
children: /* @__PURE__ */ i(
|
|
33
|
+
m.div,
|
|
34
34
|
{
|
|
35
|
-
className:
|
|
35
|
+
className: v("relative flex min-h-0 flex-1 flex-col", o),
|
|
36
36
|
...a,
|
|
37
|
-
"data-slot":
|
|
38
|
-
children:
|
|
37
|
+
"data-slot": l ?? "conversation",
|
|
38
|
+
children: n
|
|
39
39
|
}
|
|
40
40
|
)
|
|
41
41
|
}
|
|
42
42
|
);
|
|
43
|
-
}, B = (
|
|
44
|
-
const { className:
|
|
45
|
-
return /* @__PURE__ */
|
|
46
|
-
|
|
43
|
+
}, B = (s) => {
|
|
44
|
+
const { className: o, children: n, slot: l, ...a } = s, t = w("ConversationContent");
|
|
45
|
+
return /* @__PURE__ */ i(
|
|
46
|
+
m.div,
|
|
47
47
|
{
|
|
48
|
-
className:
|
|
48
|
+
className: v(
|
|
49
49
|
"flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain",
|
|
50
50
|
"px-4 py-6",
|
|
51
|
-
|
|
51
|
+
o
|
|
52
52
|
),
|
|
53
53
|
ref: t.viewportRef,
|
|
54
54
|
role: "log",
|
|
55
55
|
...a,
|
|
56
|
-
"data-slot":
|
|
57
|
-
children: /* @__PURE__ */
|
|
58
|
-
|
|
56
|
+
"data-slot": l ?? "conversation-content",
|
|
57
|
+
children: /* @__PURE__ */ i(
|
|
58
|
+
m.div,
|
|
59
59
|
{
|
|
60
60
|
className: "mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6",
|
|
61
61
|
ref: t.contentRef,
|
|
62
|
-
children:
|
|
62
|
+
children: n
|
|
63
63
|
}
|
|
64
64
|
)
|
|
65
65
|
}
|
|
66
66
|
);
|
|
67
|
-
}, P = (
|
|
68
|
-
const { className:
|
|
69
|
-
return /* @__PURE__ */
|
|
70
|
-
|
|
67
|
+
}, P = (s) => {
|
|
68
|
+
const { className: o, children: n, onClick: l, slot: a, ...t } = s, u = w("ConversationScrollButton");
|
|
69
|
+
return u.pinned ? null : /* @__PURE__ */ i(
|
|
70
|
+
N,
|
|
71
71
|
{
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
"mx-auto max-w-md px-4 py-12",
|
|
75
|
-
"text-balance text-center text-muted-foreground text-sm",
|
|
76
|
-
"[&_svg:not([class*='size-'])]:size-6",
|
|
77
|
-
e
|
|
78
|
-
),
|
|
79
|
-
...s,
|
|
80
|
-
"data-slot": o ?? "conversation-empty"
|
|
81
|
-
}
|
|
82
|
-
);
|
|
83
|
-
}, j = (n) => {
|
|
84
|
-
const { className: e, children: o, onClick: s, slot: a, ...t } = n, u = h("ConversationScrollButton");
|
|
85
|
-
return u.pinned ? null : /* @__PURE__ */ c(
|
|
86
|
-
R,
|
|
87
|
-
{
|
|
88
|
-
"aria-label": o === void 0 ? "Scroll to the latest message" : void 0,
|
|
89
|
-
className: m(
|
|
72
|
+
"aria-label": n === void 0 ? "Scroll to the latest message" : void 0,
|
|
73
|
+
className: v(
|
|
90
74
|
"absolute inset-x-0 bottom-4 z-10 mx-auto",
|
|
91
75
|
// `inset-x-0` is what centres it, and it also stretches it, so the width has to be stated.
|
|
92
76
|
// The icon size states its own — `w-(--size)`, square — and a `w-fit` here would win the
|
|
93
77
|
// merge and hand it the icon's width against the control's height.
|
|
94
|
-
|
|
78
|
+
n === void 0 ? void 0 : "w-fit",
|
|
95
79
|
"[--btn-bg:var(--background)] shadow-lg/5",
|
|
96
80
|
"fade-in-0 zoom-in-95 slide-in-from-bottom-2 animate-in",
|
|
97
81
|
"motion-reduce:animate-none!",
|
|
98
|
-
|
|
82
|
+
o
|
|
99
83
|
),
|
|
100
|
-
onClick: (
|
|
101
|
-
|
|
84
|
+
onClick: (c) => {
|
|
85
|
+
l == null || l(c), c.defaultPrevented || u.scrollToEnd();
|
|
102
86
|
},
|
|
103
87
|
pill: !0,
|
|
104
|
-
size:
|
|
88
|
+
size: n === void 0 ? "icon-sm" : "sm",
|
|
105
89
|
variant: "outline",
|
|
106
90
|
...t,
|
|
107
91
|
slot: a ?? "conversation-scroll-button",
|
|
108
|
-
children:
|
|
92
|
+
children: n ?? /* @__PURE__ */ i(E, {})
|
|
109
93
|
}
|
|
110
94
|
);
|
|
111
95
|
};
|
|
112
96
|
export {
|
|
113
97
|
k as Conversation,
|
|
114
98
|
B as ConversationContent,
|
|
115
|
-
P as
|
|
116
|
-
j as ConversationScrollButton
|
|
99
|
+
P as ConversationScrollButton
|
|
117
100
|
};
|
|
118
101
|
//# sourceMappingURL=conversation.js.map
|
package/dist/conversation.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"conversation.js","sources":["../src/conversation.tsx"],"sourcesContent":["\"use client\";\n\nimport { ark } from \"@ark-ui/react/factory\";\nimport { ArrowDownIcon } from \"lucide-react\";\nimport * as React from \"react\";\nimport { Button, cn } from \"@kanzo-tech/ui\";\n\ninterface ConversationCtx {\n viewportRef: (el: HTMLDivElement | null) => void;\n contentRef: (el: HTMLDivElement | null) => void;\n pinned: boolean;\n scrollToEnd: () => void;\n}\n\nconst Ctx = React.createContext<ConversationCtx | null>(null);\nconst useCtx = (part: string) => {\n const c = React.useContext(Ctx);\n if (!c) throw new Error(`${part} must render inside <Conversation>`);\n return c;\n};\n\n/** A scroll position is fractional and rarely lands on the end exactly; this is the allowance. */\nconst AT_END = 24;\n\n/**\n * The transcript's scroll container, and the pin is the whole component.\n *\n * **The naive version, which both real consumers wrote:** an effect on the message array calling\n * `scrollTo({ top: scrollHeight, behavior: \"smooth\" })`. It follows the tail unconditionally, so a\n * reader who scrolled up to re-read something is dragged back down by the next token, every token,\n * for as long as the answer is streaming. Following is only correct while the reader is *already*\n * at the tail: scrolling up releases the pin, and `ConversationScrollButton` is how they take it\n * back. Roughly forty lines of listener and observer, and no dependency.\n */\nexport const Conversation = (props: React.ComponentProps<typeof ark.div>) => {\n const { className, children, slot, ...rest } = props;\n const [viewport, setViewport] = React.useState<HTMLDivElement | null>(null);\n const [content, setContent] = React.useState<HTMLDivElement | null>(null);\n const [pinned, setPinned] = React.useState(true);\n const pinnedRef = React.useRef(true);\n\n const scrollToEnd = React.useCallback(() => {\n if (!viewport) return;\n pinnedRef.current = true;\n setPinned(true);\n viewport.scrollTop = viewport.scrollHeight;\n }, [viewport]);\n\n React.useEffect(() => {\n if (!viewport) return;\n\n const sync = () => {\n const atEnd = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight <= AT_END;\n pinnedRef.current = atEnd;\n setPinned(atEnd);\n };\n sync();\n viewport.addEventListener(\"scroll\", sync, { passive: true });\n\n // A stream produces *growth*, not scrolling: the content gets taller under a stationary\n // `scrollTop`, and no scroll event is fired for that. Observing the viewport alone never sees\n // it either — its own box did not change — which is why the inner element is observed too.\n const follow = () => {\n if (pinnedRef.current) viewport.scrollTop = viewport.scrollHeight;\n };\n const observer = typeof ResizeObserver === \"undefined\" ? null : new ResizeObserver(follow);\n observer?.observe(viewport);\n if (content) observer?.observe(content);\n\n return () => {\n viewport.removeEventListener(\"scroll\", sync);\n observer?.disconnect();\n };\n }, [viewport, content]);\n\n return (\n <Ctx.Provider\n value={{ viewportRef: setViewport, contentRef: setContent, pinned, scrollToEnd }}\n >\n <ark.div\n className={cn(\"relative flex min-h-0 flex-1 flex-col\", className)}\n {...rest}\n data-slot={slot ?? \"conversation\"}\n >\n {children}\n </ark.div>\n </Ctx.Provider>\n );\n};\n\n/**\n * The scrolling region.\n *\n * ARIA: `role=\"log\"` carries an implicit `aria-live=\"polite\"`, so a message appended while the\n * reader is somewhere else is announced after what they are on rather than over it. Nothing inside\n * declares a role — a message is content, not a widget — so there is no keyboard contract to owe.\n */\nexport const ConversationContent = (props: React.ComponentProps<typeof ark.div>) => {\n const { className, children, slot, ...rest } = props;\n const ctx = useCtx(\"ConversationContent\");\n\n return (\n <ark.div\n className={cn(\n \"flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain\",\n \"px-4 py-6\",\n className\n )}\n ref={ctx.viewportRef}\n role=\"log\"\n {...rest}\n data-slot={slot ?? \"conversation-content\"}\n >\n {/* `flex-1` gives the inner element the viewport's height when the transcript is short, which\n is what lets `
|
|
1
|
+
{"version":3,"file":"conversation.js","sources":["../src/conversation.tsx"],"sourcesContent":["\"use client\";\n\nimport { ark } from \"@ark-ui/react/factory\";\nimport { ArrowDownIcon } from \"lucide-react\";\nimport * as React from \"react\";\nimport { Button, cn } from \"@kanzo-tech/ui\";\n\ninterface ConversationCtx {\n viewportRef: (el: HTMLDivElement | null) => void;\n contentRef: (el: HTMLDivElement | null) => void;\n pinned: boolean;\n scrollToEnd: () => void;\n}\n\nconst Ctx = React.createContext<ConversationCtx | null>(null);\nconst useCtx = (part: string) => {\n const c = React.useContext(Ctx);\n if (!c) throw new Error(`${part} must render inside <Conversation>`);\n return c;\n};\n\n/** A scroll position is fractional and rarely lands on the end exactly; this is the allowance. */\nconst AT_END = 24;\n\n/**\n * The transcript's scroll container, and the pin is the whole component.\n *\n * **The naive version, which both real consumers wrote:** an effect on the message array calling\n * `scrollTo({ top: scrollHeight, behavior: \"smooth\" })`. It follows the tail unconditionally, so a\n * reader who scrolled up to re-read something is dragged back down by the next token, every token,\n * for as long as the answer is streaming. Following is only correct while the reader is *already*\n * at the tail: scrolling up releases the pin, and `ConversationScrollButton` is how they take it\n * back. Roughly forty lines of listener and observer, and no dependency.\n */\nexport const Conversation = (props: React.ComponentProps<typeof ark.div>) => {\n const { className, children, slot, ...rest } = props;\n const [viewport, setViewport] = React.useState<HTMLDivElement | null>(null);\n const [content, setContent] = React.useState<HTMLDivElement | null>(null);\n const [pinned, setPinned] = React.useState(true);\n const pinnedRef = React.useRef(true);\n\n const scrollToEnd = React.useCallback(() => {\n if (!viewport) return;\n pinnedRef.current = true;\n setPinned(true);\n viewport.scrollTop = viewport.scrollHeight;\n }, [viewport]);\n\n React.useEffect(() => {\n if (!viewport) return;\n\n const sync = () => {\n const atEnd = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight <= AT_END;\n pinnedRef.current = atEnd;\n setPinned(atEnd);\n };\n sync();\n viewport.addEventListener(\"scroll\", sync, { passive: true });\n\n // A stream produces *growth*, not scrolling: the content gets taller under a stationary\n // `scrollTop`, and no scroll event is fired for that. Observing the viewport alone never sees\n // it either — its own box did not change — which is why the inner element is observed too.\n const follow = () => {\n if (pinnedRef.current) viewport.scrollTop = viewport.scrollHeight;\n };\n const observer = typeof ResizeObserver === \"undefined\" ? null : new ResizeObserver(follow);\n observer?.observe(viewport);\n if (content) observer?.observe(content);\n\n return () => {\n viewport.removeEventListener(\"scroll\", sync);\n observer?.disconnect();\n };\n }, [viewport, content]);\n\n return (\n <Ctx.Provider\n value={{ viewportRef: setViewport, contentRef: setContent, pinned, scrollToEnd }}\n >\n <ark.div\n className={cn(\"relative flex min-h-0 flex-1 flex-col\", className)}\n {...rest}\n data-slot={slot ?? \"conversation\"}\n >\n {children}\n </ark.div>\n </Ctx.Provider>\n );\n};\n\n/**\n * The scrolling region.\n *\n * ARIA: `role=\"log\"` carries an implicit `aria-live=\"polite\"`, so a message appended while the\n * reader is somewhere else is announced after what they are on rather than over it. Nothing inside\n * declares a role — a message is content, not a widget — so there is no keyboard contract to owe.\n */\nexport const ConversationContent = (props: React.ComponentProps<typeof ark.div>) => {\n const { className, children, slot, ...rest } = props;\n const ctx = useCtx(\"ConversationContent\");\n\n return (\n <ark.div\n className={cn(\n \"flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain\",\n \"px-4 py-6\",\n className\n )}\n ref={ctx.viewportRef}\n role=\"log\"\n {...rest}\n data-slot={slot ?? \"conversation-content\"}\n >\n {/* `flex-1` gives the inner element the viewport's height when the transcript is short, which\n is what lets an `EmptyRoot` centre the zero state in the panel rather than sit at the top. */}\n <ark.div\n className=\"mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6\"\n ref={ctx.contentRef}\n >\n {children}\n </ark.div>\n </ark.div>\n );\n};\n\n/**\n * Present only while the pin is released, and pressing it takes the pin back.\n *\n * It floats *over* a transcript that is still growing underneath it, which is why it enters rather\n * than appears and why it paints its own ground: `variant=\"outline\"` sets `--btn-bg: transparent`,\n * so without a ground of its own the message it covers reads straight through it.\n *\n * It asks for that ground by **moving the variable**, not by writing `bg-background` on top. The\n * two look identical at rest and diverge the moment you hover: the wash is\n * `color-mix(…, var(--btn-bg), …)`, so a `bg-background` that only overrode the resting fill left\n * the hover mixing from `transparent` and the transcript showed through the button exactly when the\n * pointer was on it. `--btn-bg` is the seam the recipe documents; this is what it is for.\n */\nexport const ConversationScrollButton = (props: React.ComponentProps<typeof Button>) => {\n const { className, children, onClick, slot, ...rest } = props;\n const ctx = useCtx(\"ConversationScrollButton\");\n\n if (ctx.pinned) return null;\n\n return (\n <Button\n aria-label={children === undefined ? \"Scroll to the latest message\" : undefined}\n className={cn(\n \"absolute inset-x-0 bottom-4 z-10 mx-auto\",\n // `inset-x-0` is what centres it, and it also stretches it, so the width has to be stated.\n // The icon size states its own — `w-(--size)`, square — and a `w-fit` here would win the\n // merge and hand it the icon's width against the control's height.\n children === undefined ? undefined : \"w-fit\",\n \"[--btn-bg:var(--background)] shadow-lg/5\",\n \"fade-in-0 zoom-in-95 slide-in-from-bottom-2 animate-in\",\n \"motion-reduce:animate-none!\",\n className\n )}\n onClick={(event) => {\n onClick?.(event);\n if (!event.defaultPrevented) ctx.scrollToEnd();\n }}\n pill\n // Icon-only by default; give it words and it becomes a pill wide enough to hold them, the way\n // `PromptInputSubmit` does.\n size={children === undefined ? \"icon-sm\" : \"sm\"}\n variant=\"outline\"\n {...rest}\n slot={slot ?? \"conversation-scroll-button\"}\n >\n {children ?? <ArrowDownIcon />}\n </Button>\n );\n};\n"],"names":[],"mappings":";;;;;;AAcA;AAEE;AACA;AACA;AACF;AAgBE;AAOE;AAG8B;AAGhC;AACE;AAEA;AACE;AACA;AACe;AAEjB;AAMA;AACE;AAAqD;AAGvD;AAIE;AACU;AACZ;AAIA;AAAK;AAAJ;AACoE;AAEnE;AAAK;AAAJ;AACiE;AAC5D;AACe;AAElB;AAAA;AAAA;AACH;AAGN;AAUE;AAGA;AACE;AAAK;AAAJ;AACY;AACT;AACA;AACA;AAAA;AAEO;AACJ;AACD;AACe;AAInB;AAAK;AAAJ;AACW;AACD;AAER;AAAA;AAAA;AACH;AAGN;AAgBE;AAGA;AAGE;AAAC;AAAA;AACuE;AAC3D;AACT;AAAA;AAAA;AAAA;AAIqC;AACrC;AACA;AACA;AACA;AAAA;AAGA;AACiC;AACnC;AACI;AAGuC;AACnC;AACJ;AACU;AAEc;AAAA;AAGlC;;;;;;"}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { Conversation, ConversationContent,
|
|
1
|
+
export { Conversation, ConversationContent, ConversationScrollButton, } from './conversation.js';
|
|
2
2
|
export { Message, MessageActions, MessageAvatar, MessageContent, MessageList, } from './message.js';
|
|
3
3
|
export { MessageText } from './message-text.js';
|
|
4
4
|
export type { MessageProps, MessageAvatarProps, MessageRole } from './message.js';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAYA,OAAO,EACL,YAAY,EACZ,mBAAmB,EACnB,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAYA,OAAO,EACL,YAAY,EACZ,mBAAmB,EACnB,wBAAwB,GACzB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EACL,OAAO,EACP,cAAc,EACd,aAAa,EACb,cAAc,EACd,WAAW,GACZ,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,YAAY,EAAE,YAAY,EAAE,kBAAkB,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAClF,YAAY,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAG1D,OAAO,EACL,WAAW,EACX,iBAAiB,EACjB,mBAAmB,EACnB,kBAAkB,GACnB,MAAM,mBAAmB,CAAC;AAC3B,YAAY,EAAE,sBAAsB,EAAE,MAAM,mBAAmB,CAAC;AAGhE,OAAO,EAAE,SAAS,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAC/E,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AACrD,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,UAAU,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AACjF,YAAY,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AACxD,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAClE,YAAY,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AAGrD,OAAO,EACL,YAAY,EACZ,gBAAgB,EAChB,aAAa,EACb,YAAY,EACZ,YAAY,EACZ,aAAa,EACb,YAAY,GACb,MAAM,eAAe,CAAC;AAIvB,OAAO,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AACtC,YAAY,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAChD,OAAO,EAAE,WAAW,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AACrE,YAAY,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AACvF,YAAY,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAEvD,OAAO,EAAE,WAAW,EAAE,mBAAmB,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAC3F,YAAY,EACV,QAAQ,EACR,QAAQ,EACR,gBAAgB,EAChB,uBAAuB,EACvB,uBAAuB,EACvB,qBAAqB,EACrB,0BAA0B,EAC1B,qBAAqB,GACtB,MAAM,aAAa,CAAC;AAErB,YAAY,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAI5C,OAAO,EAAE,UAAU,EAAE,eAAe,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AACxF,YAAY,EACV,SAAS,EACT,aAAa,EACb,eAAe,EACf,UAAU,EACV,UAAU,EACV,UAAU,EACV,WAAW,GACZ,MAAM,mBAAmB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,60 +1,59 @@
|
|
|
1
|
-
import { Conversation as
|
|
2
|
-
import { Message as
|
|
3
|
-
import { MessageText as
|
|
4
|
-
import { PromptInput as
|
|
5
|
-
import { Reasoning as
|
|
6
|
-
import { Tool as
|
|
7
|
-
import { Task as
|
|
8
|
-
import { CompleteError as
|
|
9
|
-
import { AiMark as
|
|
10
|
-
import { SuggestList as
|
|
11
|
-
import { cleanGhost as
|
|
12
|
-
import { isReasoningPart as
|
|
1
|
+
import { Conversation as t, ConversationContent as r, ConversationScrollButton as s } from "./conversation.js";
|
|
2
|
+
import { Message as n, MessageActions as p, MessageAvatar as m, MessageContent as i, MessageList as g } from "./message.js";
|
|
3
|
+
import { MessageText as T } from "./message-text.js";
|
|
4
|
+
import { PromptInput as x, PromptInputSubmit as C, PromptInputTextarea as f, PromptInputToolbar as M } from "./prompt-input.js";
|
|
5
|
+
import { Reasoning as P, ReasoningContent as S, ReasoningTrigger as I } from "./reasoning.js";
|
|
6
|
+
import { Tool as v, ToolContent as A, ToolHeader as c, ToolInput as L, ToolOutput as b } from "./tool.js";
|
|
7
|
+
import { Task as G, TaskList as H, TaskStatus as d, TaskTitle as y } from "./task.js";
|
|
8
|
+
import { CompleteError as E, CompleteGhost as K, CompleteHint as O, CompleteKeys as j, CompleteMark as q, CompleteRoot as w, CompleteTextarea as z } from "./complete.js";
|
|
9
|
+
import { AiMark as F } from "./ai-mark.js";
|
|
10
|
+
import { SuggestList as N, SuggestMark as Q, SuggestRoot as U } from "./suggest.js";
|
|
11
|
+
import { cleanGhost as W, useAiStream as X, useInlineCompletion as Y, useSuggestions as Z } from "./use-ai.js";
|
|
12
|
+
import { isReasoningPart as $, isTaskPart as oo, isTextPart as eo, isToolPart as to } from "./message-part.js";
|
|
13
13
|
export {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
14
|
+
F as AiMark,
|
|
15
|
+
E as CompleteError,
|
|
16
|
+
K as CompleteGhost,
|
|
17
|
+
O as CompleteHint,
|
|
18
|
+
j as CompleteKeys,
|
|
19
|
+
q as CompleteMark,
|
|
20
|
+
w as CompleteRoot,
|
|
21
|
+
z as CompleteTextarea,
|
|
22
|
+
t as Conversation,
|
|
23
23
|
r as ConversationContent,
|
|
24
|
-
s as
|
|
25
|
-
|
|
26
|
-
p as
|
|
27
|
-
m as
|
|
28
|
-
i as
|
|
29
|
-
g as
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
C as
|
|
33
|
-
f as
|
|
34
|
-
M as
|
|
35
|
-
|
|
36
|
-
S as
|
|
37
|
-
I as
|
|
38
|
-
|
|
39
|
-
Q as
|
|
40
|
-
U as
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
A as
|
|
47
|
-
c as
|
|
48
|
-
L as
|
|
49
|
-
b as
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
oo as
|
|
53
|
-
to as isTaskPart,
|
|
24
|
+
s as ConversationScrollButton,
|
|
25
|
+
n as Message,
|
|
26
|
+
p as MessageActions,
|
|
27
|
+
m as MessageAvatar,
|
|
28
|
+
i as MessageContent,
|
|
29
|
+
g as MessageList,
|
|
30
|
+
T as MessageText,
|
|
31
|
+
x as PromptInput,
|
|
32
|
+
C as PromptInputSubmit,
|
|
33
|
+
f as PromptInputTextarea,
|
|
34
|
+
M as PromptInputToolbar,
|
|
35
|
+
P as Reasoning,
|
|
36
|
+
S as ReasoningContent,
|
|
37
|
+
I as ReasoningTrigger,
|
|
38
|
+
N as SuggestList,
|
|
39
|
+
Q as SuggestMark,
|
|
40
|
+
U as SuggestRoot,
|
|
41
|
+
G as Task,
|
|
42
|
+
H as TaskList,
|
|
43
|
+
d as TaskStatus,
|
|
44
|
+
y as TaskTitle,
|
|
45
|
+
v as Tool,
|
|
46
|
+
A as ToolContent,
|
|
47
|
+
c as ToolHeader,
|
|
48
|
+
L as ToolInput,
|
|
49
|
+
b as ToolOutput,
|
|
50
|
+
W as cleanGhost,
|
|
51
|
+
$ as isReasoningPart,
|
|
52
|
+
oo as isTaskPart,
|
|
54
53
|
eo as isTextPart,
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
54
|
+
to as isToolPart,
|
|
55
|
+
X as useAiStream,
|
|
56
|
+
Y as useInlineCompletion,
|
|
57
|
+
Z as useSuggestions
|
|
59
58
|
};
|
|
60
59
|
//# sourceMappingURL=index.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kanzo-tech/ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.1",
|
|
4
4
|
"description": "Kanzo AI surfaces — the transcript, the composer, a model's reasoning and the calls it makes, plus the field affordances that stream into a value. Sibling of @kanzo-tech/ui, not part of it: a Message has a role and one of the roles is `assistant`, which is domain knowledge the generic vocabulary does not carry.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"react": ">=19",
|
|
34
34
|
"react-dom": ">=19",
|
|
35
35
|
"streamdown": ">=2",
|
|
36
|
-
"@kanzo-tech/ui": "0.
|
|
36
|
+
"@kanzo-tech/ui": "0.13.1"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"tailwind-variants": "^3.2.2"
|
|
@@ -51,8 +51,8 @@
|
|
|
51
51
|
"react-dom": "^19.0.0",
|
|
52
52
|
"rollup-plugin-preserve-directives": "^0.4.0",
|
|
53
53
|
"streamdown": "^2.5.0",
|
|
54
|
-
"@kanzo-tech/theme": "0.
|
|
55
|
-
"@kanzo-tech/ui": "0.
|
|
54
|
+
"@kanzo-tech/theme": "0.13.1",
|
|
55
|
+
"@kanzo-tech/ui": "0.13.1"
|
|
56
56
|
},
|
|
57
57
|
"//size-limit": "First measurement taken at the split, so this budget is a change detector from day one rather than a number inherited from somewhere. The four AI-assist modules that moved here cost @kanzo-tech/ui's root barrel about 4 kB when they arrived (38 -> 42 kB, recorded in that package's own note); the barrel is rebaselined down by the same move.",
|
|
58
58
|
"size-limit": [
|