anky-ui 0.0.0-stage → 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/LICENSE +15 -0
- package/README.md +473 -3
- package/package.json +71 -3
- package/packages/cli/dist/cli.js +205 -0
- package/packages/cli/dist/index.js +12 -0
- package/packages/cli/dist/project.js +77 -0
- package/packages/cli/dist/registry.js +157 -0
- package/packages/cli/dist/transform.js +41 -0
- package/packages/cli/package.json +19 -0
- package/packages/cli/registry/a11y/announce.ts +47 -0
- package/packages/cli/registry/adapters/editorAdapter.ts +76 -0
- package/packages/cli/registry/adapters/lexical.ts +128 -0
- package/packages/cli/registry/adapters/prosemirror.ts +108 -0
- package/packages/cli/registry/adapters/tiptap.ts +61 -0
- package/packages/cli/registry/adapters/useEditorAnchor.ts +143 -0
- package/packages/cli/registry/components/GhostInput.tsx +408 -0
- package/packages/cli/registry/components/InlineDiff.tsx +364 -0
- package/packages/cli/registry/components/SelectionPalette.tsx +359 -0
- package/packages/cli/registry/diff/myers.ts +300 -0
- package/packages/cli/registry/dom/useSelectionAnchor.ts +572 -0
- package/packages/cli/registry/hooks/useOptimisticAIState.ts +455 -0
- package/packages/cli/registry/stream/parser.ts +214 -0
- package/packages/cli/registry/stream/providers.ts +177 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ankush Patil
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
10
|
+
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
|
11
|
+
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
12
|
+
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
13
|
+
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
|
14
|
+
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
15
|
+
PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,473 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
# Anky UI
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@erankushpatil/anky-ui)
|
|
4
|
+
[](https://www.npmjs.com/package/anky-ui)
|
|
5
|
+
[](./LICENSE)
|
|
6
|
+
[](#api-reference)
|
|
7
|
+
|
|
8
|
+
**Anky UI is an AI UI library for React: ambient, inline AI primitives.** Select text to open a floating AI action palette, stream a rewrite into a live inline diff, accept or reject it, get Copilot-style ghost text autocomplete in a textarea, and undo/redo every AI change.
|
|
9
|
+
|
|
10
|
+
Headless by default, Tailwind-ready, zero runtime dependencies besides `@floating-ui/react`. Bring your own model endpoint. Works with Next.js (App Router and Pages), Vite and any React 18/19 app.
|
|
11
|
+
|
|
12
|
+
- Floating AI action palette anchored to the user's text selection (Cursor-like inline prompt)
|
|
13
|
+
- React inline diff with streaming tokens, word or character mode
|
|
14
|
+
- Ghost text autocomplete textarea with Tab / → to accept
|
|
15
|
+
- Optimistic AI state with rollback, undo/redo history and accept/reject
|
|
16
|
+
- Copy-paste distribution via CLI in the shadcn style: `npx anky-ui add ...`
|
|
17
|
+
|
|
18
|
+
## Contents
|
|
19
|
+
|
|
20
|
+
- [Install](#install)
|
|
21
|
+
- [Quick start](#quick-start)
|
|
22
|
+
- [Components](#components)
|
|
23
|
+
- [SelectionPalette](#selectionpalette)
|
|
24
|
+
- [InlineDiff](#inlinediff)
|
|
25
|
+
- [GhostInput](#ghostinput)
|
|
26
|
+
- [Hooks and utilities](#hooks-and-utilities)
|
|
27
|
+
- [useOptimisticAIState](#useoptimisticaistate)
|
|
28
|
+
- [useSelectionAnchor](#useselectionanchor)
|
|
29
|
+
- [computeDiff](#computediff)
|
|
30
|
+
- [streamTextReader](#streamtextreader)
|
|
31
|
+
- [Provider streaming recipes](#provider-streaming-recipes)
|
|
32
|
+
- [Editor adapters](#editor-adapters)
|
|
33
|
+
- [Accessibility](#accessibility)
|
|
34
|
+
- [Styling and Tailwind](#styling-and-tailwind)
|
|
35
|
+
- [CLI](#cli)
|
|
36
|
+
- [Server and Next.js notes](#server-and-nextjs-notes)
|
|
37
|
+
- [Playground](#playground)
|
|
38
|
+
- [Development](#development)
|
|
39
|
+
- [Contributing](#contributing)
|
|
40
|
+
- [FAQ](#faq)
|
|
41
|
+
- [Keywords](#keywords)
|
|
42
|
+
- [License](#license)
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
npm install @erankushpatil/anky-ui
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Requires React and React DOM 18 or 19. `@floating-ui/react` is installed as a dependency.
|
|
51
|
+
|
|
52
|
+
Prefer to own the source? Copy the components into your project instead:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
npx anky-ui add selection-palette
|
|
56
|
+
npx anky-ui add inline-diff
|
|
57
|
+
npx anky-ui add ghost-input
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Quick start
|
|
61
|
+
|
|
62
|
+
Highlight text, pick an action, watch the rewrite stream in, then accept or reject it.
|
|
63
|
+
|
|
64
|
+
```tsx
|
|
65
|
+
"use client";
|
|
66
|
+
|
|
67
|
+
import {
|
|
68
|
+
InlineDiff,
|
|
69
|
+
SelectionPalette,
|
|
70
|
+
useOptimisticAIState,
|
|
71
|
+
} from "@erankushpatil/anky-ui";
|
|
72
|
+
|
|
73
|
+
export function Editor() {
|
|
74
|
+
const ai = useOptimisticAIState({ initialState: { original: "", modified: "" } });
|
|
75
|
+
|
|
76
|
+
function run(prompt: string, selectedText: string) {
|
|
77
|
+
void ai.mutateWithStream(
|
|
78
|
+
(signal) =>
|
|
79
|
+
fetch("/api/rewrite", {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: { "Content-Type": "application/json" },
|
|
82
|
+
body: JSON.stringify({ prompt, text: selectedText }),
|
|
83
|
+
signal,
|
|
84
|
+
}),
|
|
85
|
+
(state, chunk) => ({ original: selectedText, modified: state.modified + chunk }),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<>
|
|
91
|
+
<article contentEditable suppressContentEditableWarning>
|
|
92
|
+
Select any sentence in this paragraph to try the AI palette.
|
|
93
|
+
</article>
|
|
94
|
+
|
|
95
|
+
<SelectionPalette onSelectPrompt={run} />
|
|
96
|
+
|
|
97
|
+
{ai.currentState.original && (
|
|
98
|
+
<InlineDiff
|
|
99
|
+
originalText={ai.currentState.original}
|
|
100
|
+
modifiedText={ai.currentState.modified}
|
|
101
|
+
isStreaming={ai.isStreaming}
|
|
102
|
+
onAccept={ai.accept}
|
|
103
|
+
onReject={ai.reject}
|
|
104
|
+
/>
|
|
105
|
+
)}
|
|
106
|
+
</>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The endpoint can return raw UTF-8 text or Server-Sent Events whose `data:` fields hold text. Convert provider-specific JSON (OpenAI, Anthropic, ...) to text on your server.
|
|
112
|
+
|
|
113
|
+
## Components
|
|
114
|
+
|
|
115
|
+
### SelectionPalette
|
|
116
|
+
|
|
117
|
+
A floating bar anchored to the highlighted text, built on `@floating-ui/react`. It flips and clamps to the viewport, follows scrolling overflow parents, and hides when the selection scrolls out of view.
|
|
118
|
+
|
|
119
|
+
```tsx
|
|
120
|
+
<SelectionPalette
|
|
121
|
+
actions={[
|
|
122
|
+
{ label: "Fix Grammar", prompt: "Fix the grammar of the following text." },
|
|
123
|
+
{ label: "Shorten", prompt: "Make this shorter.", icon: "↘" },
|
|
124
|
+
]}
|
|
125
|
+
onSelectPrompt={(prompt, selectedText) => runAI(prompt, selectedText)}
|
|
126
|
+
customInputPlaceholder="Ask AI to edit…"
|
|
127
|
+
containerRef={editorRef}
|
|
128
|
+
/>
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
| Prop | Type | Default | Description |
|
|
132
|
+
| --- | --- | --- | --- |
|
|
133
|
+
| `onSelectPrompt` | `(prompt: string, selectedText: string) => void` | required | Called with a preset prompt or the freeform input, and the highlighted text. |
|
|
134
|
+
| `actions` | `Array<{ label: string; icon?: ReactNode; prompt: string }>` | Fix Grammar, Shorten, Translate | Preset pills. Pass `[]` for none. |
|
|
135
|
+
| `customInputPlaceholder` | `string` | `"Ask AI to edit…"` | Placeholder of the freeform input. |
|
|
136
|
+
| `children` | `ReactNode` | none | Extra content at the end of the bar. |
|
|
137
|
+
| `containerRef` | `RefObject<HTMLElement \| null>` | `document.body` | Only selections inside this element open the palette. |
|
|
138
|
+
| `minChars` | `number` | `1` | Minimum selected characters (whitespace-trimmed). |
|
|
139
|
+
| `enabled` | `boolean` | `true` | Disable to detach all listeners. |
|
|
140
|
+
| `placement` | floating-ui `Placement` | `"top"` | Preferred side. |
|
|
141
|
+
| `sideOffset` | `number` | `8` | Gap to the selection, in px. |
|
|
142
|
+
| `closeOnSelect` | `boolean` | `true` | Close and restore the selection after a prompt is chosen. |
|
|
143
|
+
| `classNames` | `{ root?, actions?, action?, actionIcon?, input? }` | none | Extra classes per slot. |
|
|
144
|
+
| `aria-label` | `string` | `"AI actions"` | Accessible name of the toolbar. |
|
|
145
|
+
|
|
146
|
+
Keyboard: `Enter` in the input submits (ignored during IME composition). `Escape` closes the palette and returns focus and the selection to the editor. Arrow keys, `Home` and `End` move between pills.
|
|
147
|
+
|
|
148
|
+
Semantic classes: `anky-ui-palette-root`, `anky-ui-palette-actions`, `anky-ui-palette-action`, `anky-ui-palette-action-icon`, `anky-ui-palette-input`. Use `StyledSelectionPalette` for the Tailwind preset.
|
|
149
|
+
|
|
150
|
+
### InlineDiff
|
|
151
|
+
|
|
152
|
+
Live word or character comparison between the original and a streaming rewrite, with Accept and Reject controls.
|
|
153
|
+
|
|
154
|
+
```tsx
|
|
155
|
+
<InlineDiff
|
|
156
|
+
originalText={original}
|
|
157
|
+
modifiedText={modified}
|
|
158
|
+
mode="word"
|
|
159
|
+
isStreaming={isStreaming}
|
|
160
|
+
onAccept={() => commit(modified)}
|
|
161
|
+
onReject={() => rollback()}
|
|
162
|
+
/>
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
| Prop | Type | Default | Description |
|
|
166
|
+
| --- | --- | --- | --- |
|
|
167
|
+
| `originalText` | `string` | required | Text before the edit. |
|
|
168
|
+
| `modifiedText` | `string` | required | Text after the edit (may be partial while streaming). |
|
|
169
|
+
| `onAccept` | `() => void` | required | Accept. Triggered by the button or `Cmd/Ctrl+Enter`. Disabled while streaming. |
|
|
170
|
+
| `onReject` | `() => void` | required | Reject. Triggered by the button or `Escape`. |
|
|
171
|
+
| `mode` | `"word" \| "char"` | `"word"` | Diff granularity. |
|
|
172
|
+
| `isStreaming` | `boolean` | `false` | Pulses the diff, disables Accept, and shows the not-yet-rewritten tail as pending. |
|
|
173
|
+
| `toolbar` | `"floating" \| "inline" \| "none"` | `"floating"` | Where Accept / Reject render. |
|
|
174
|
+
| `toolbarPlacement` | floating-ui `Placement` | `"bottom-start"` | Floating toolbar placement. |
|
|
175
|
+
| `shortcuts` | `boolean` | `true` | Global `Cmd/Ctrl+Enter` and `Escape`. Ignored while typing in another field. |
|
|
176
|
+
| `unstyled` | `boolean` | `false` | Drop the built-in red and green colours. |
|
|
177
|
+
| `classNames` | `{ root?, delete?, insert?, pending?, toolbar?, action?, accept?, reject?, kbd? }` | none | Extra classes per slot. |
|
|
178
|
+
| `acceptLabel`, `rejectLabel` | `ReactNode` | `"Accept"`, `"Reject"` | Button labels. |
|
|
179
|
+
|
|
180
|
+
Deletions render as `<del class="anky-ui-diff-delete">` (red, strikethrough) and insertions as `<ins class="anky-ui-diff-insert">` (green). Theme with CSS variables `--anky-ui-diff-delete-bg`, `--anky-ui-diff-delete-fg`, `--anky-ui-diff-insert-bg` and `--anky-ui-diff-insert-fg`. The pulse uses the Web Animations API and respects `prefers-reduced-motion`. Use `StyledInlineDiff` for the Tailwind toolbar preset.
|
|
181
|
+
|
|
182
|
+
### GhostInput
|
|
183
|
+
|
|
184
|
+
Copilot-style inline autocomplete. A bottom layer renders your text plus a faint ghost suggestion (`#9CA3AF`); a transparent `<textarea>` on top handles input, caret and selection.
|
|
185
|
+
|
|
186
|
+
```tsx
|
|
187
|
+
const [value, setValue] = useState("");
|
|
188
|
+
|
|
189
|
+
<GhostInput
|
|
190
|
+
value={value}
|
|
191
|
+
onChange={setValue}
|
|
192
|
+
debounceMs={300}
|
|
193
|
+
placeholder="Start typing…"
|
|
194
|
+
className="rounded-lg border p-3 font-sans text-sm leading-6"
|
|
195
|
+
fetchSuggestion={async (current, { signal }) => {
|
|
196
|
+
const res = await fetch("/api/complete", {
|
|
197
|
+
method: "POST",
|
|
198
|
+
body: JSON.stringify({ text: current }),
|
|
199
|
+
signal,
|
|
200
|
+
});
|
|
201
|
+
return res.text(); // only the continuation, not the full text
|
|
202
|
+
}}
|
|
203
|
+
/>;
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
| Prop | Type | Default | Description |
|
|
207
|
+
| --- | --- | --- | --- |
|
|
208
|
+
| `value` | `string` | required | Controlled value. |
|
|
209
|
+
| `onChange` | `(value: string) => void` | required | Called on every edit and when a suggestion is accepted. |
|
|
210
|
+
| `fetchSuggestion` | `(current: string, options: { signal: AbortSignal }) => Promise<string>` | required | Return the text to append. Return `""` for none. |
|
|
211
|
+
| `debounceMs` | `number` | `300` | Idle time after the last keystroke before fetching. |
|
|
212
|
+
| `onSuggestionError` | `(error: unknown) => void` | none | Fetch failures never surface in the UI. |
|
|
213
|
+
| `onAcceptSuggestion` | `(suggestion: string) => void` | none | Called after Tab or → accepts. |
|
|
214
|
+
| `className`, `style` | | none | Applied to the root, which owns font, line-height, letter-spacing, padding, border and colour. Both layers inherit from it so they always line up. |
|
|
215
|
+
|
|
216
|
+
All native `<textarea>` props (such as `rows`, `placeholder`, `disabled`) are forwarded. A `ref` exposes `{ textarea, acceptSuggestion(), dismissSuggestion() }`.
|
|
217
|
+
|
|
218
|
+
Keyboard: `Tab` or `ArrowRight` with the caret at the end accepts. `Escape` clears the suggestion. Typing that matches the suggestion keeps the rest visible; anything else invalidates it. Stale responses are discarded. Override the colour with `--anky-ui-ghost-color`.
|
|
219
|
+
|
|
220
|
+
## Hooks and utilities
|
|
221
|
+
|
|
222
|
+
### useOptimisticAIState
|
|
223
|
+
|
|
224
|
+
Optimistic state for streamed AI output with snapshot rollback and a 20-entry undo/redo history.
|
|
225
|
+
|
|
226
|
+
```tsx
|
|
227
|
+
const ai = useOptimisticAIState({
|
|
228
|
+
initialState: { text: "" },
|
|
229
|
+
endpoint: "/api/rewrite", // optional, used by streamFromEndpoint
|
|
230
|
+
onStateChange: (state) => save(state),
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
await ai.mutateWithStream(
|
|
234
|
+
(signal) => fetch("/api/rewrite", { method: "POST", signal }),
|
|
235
|
+
(state, chunk) => ({ text: state.text + chunk }),
|
|
236
|
+
);
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
| Member | Description |
|
|
240
|
+
| --- | --- |
|
|
241
|
+
| `currentState` | Optimistic state, including partially streamed output. |
|
|
242
|
+
| `confirmedState` | Last state committed with `accept()`. |
|
|
243
|
+
| `history` | `{ past, future }` undo and redo stacks, 20 entries each. |
|
|
244
|
+
| `isStreaming`, `error` | Stream status and the last failure or user abort. |
|
|
245
|
+
| `canUndo`, `canRedo` | Whether history navigation is possible. |
|
|
246
|
+
| `mutateWithStream(source, transformFn, options?)` | Captures an atomic snapshot, sets `isStreaming`, folds each token in with `transformFn`. On error or abort it rolls back to the snapshot and sets `error`. Returns an outcome and never rejects. |
|
|
247
|
+
| `streamFromEndpoint(body, transformFn, options?)` | POSTs JSON to `endpoint` and streams the response through `mutateWithStream`. |
|
|
248
|
+
| `abort()` | Cancel the in-flight stream and roll back. |
|
|
249
|
+
| `undo()`, `redo()` | Move through history. While streaming, `undo()` cancels the stream. |
|
|
250
|
+
| `accept()` | Commit `currentState` as the new baseline and clear history. |
|
|
251
|
+
| `reject()` | Revert to `confirmedState` (undoable). |
|
|
252
|
+
| `update(next)` | Synchronous, undoable change, such as a manual user edit. |
|
|
253
|
+
|
|
254
|
+
Concurrency: the last call wins. Starting a stream while another is running cancels the older one, rolls it back, and builds the new one on the same pre-mutation snapshot. Stale streams can never write state. `source` accepts a `Response`, a `ReadableStream`, a promise of either, or a factory `(signal) => ...` so the hook can abort the underlying `fetch`.
|
|
255
|
+
|
|
256
|
+
### useSelectionAnchor
|
|
257
|
+
|
|
258
|
+
The primitive behind `SelectionPalette`. It returns a floating-ui virtual element for the current text selection.
|
|
259
|
+
|
|
260
|
+
```tsx
|
|
261
|
+
const { isOpen, selectedText, virtualElement, close, restoreSelection } = useSelectionAnchor({
|
|
262
|
+
minChars: 3,
|
|
263
|
+
containerRef: editorRef,
|
|
264
|
+
});
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
| Option | Default | Description |
|
|
268
|
+
| --- | --- | --- |
|
|
269
|
+
| `enabled` | `true` | Detach all listeners and reset state when `false`. |
|
|
270
|
+
| `minChars` | `1` | Minimum whitespace-trimmed selection length. |
|
|
271
|
+
| `containerRef` | `document.body` | Only selections inside this element are tracked. |
|
|
272
|
+
| `floatingRef` | none | Your floating element. Focusing or clicking inside it keeps the anchor open. |
|
|
273
|
+
|
|
274
|
+
Behaviour: `selectionchange` is debounced by 100 ms so dragging doesn't flicker; collapsed or too-short selections are ignored; both `mouseup` and `touchend` are handled without double-firing; the rectangle is recalculated on scroll and the anchor hides when the selection leaves the viewport. The virtual element is created only inside effects, so it is safe for server rendering and hydration. `restoreSelection()` re-selects the original text using saved range offsets, for example after an AI run re-rendered it, and returns `false` if the text can't be found.
|
|
275
|
+
|
|
276
|
+
### computeDiff
|
|
277
|
+
|
|
278
|
+
```ts
|
|
279
|
+
import { computeDiff, type DiffSegment } from "@erankushpatil/anky-ui";
|
|
280
|
+
|
|
281
|
+
const segments: DiffSegment[] = computeDiff("The quick fox", "The swift fox", "word");
|
|
282
|
+
// [{ type: "equal", value: "The " }, { type: "delete", value: "quick" },
|
|
283
|
+
// { type: "insert", value: "swift" }, { type: "equal", value: " fox" }]
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
`DiffSegment = { type: "equal" | "insert" | "delete"; value: string }`. Zero-dependency Myers diff with `"word"` and `"char"` modes, a work budget that bounds worst-case latency and memory (pass `{ maxCost }` as the fourth argument), and no module-level state.
|
|
287
|
+
|
|
288
|
+
### streamTextReader
|
|
289
|
+
|
|
290
|
+
```ts
|
|
291
|
+
import { streamTextReader } from "@erankushpatil/anky-ui";
|
|
292
|
+
|
|
293
|
+
const controller = new AbortController();
|
|
294
|
+
for await (const token of streamTextReader(await fetch(url), { signal: controller.signal })) {
|
|
295
|
+
output += token;
|
|
296
|
+
}
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
Accepts a `ReadableStream<Uint8Array>` or a `Response`. Decodes with `TextDecoderStream` or `TextDecoder`, handles multi-byte characters split across chunks, strips `data:` prefixes automatically for SSE, passes raw text through unchanged, and ends on `[DONE]`. Options: `signal`, `framing` (`"auto"`, `"sse"` or `"raw"`), `doneSentinel`. On abort it cancels the source and rejects with `signal.reason`.
|
|
300
|
+
|
|
301
|
+
## Provider streaming recipes
|
|
302
|
+
|
|
303
|
+
`providerTextReader` and `providerTextStream` decode vendor wire formats into plain text tokens, so you can point the hooks straight at a provider response.
|
|
304
|
+
|
|
305
|
+
```ts
|
|
306
|
+
import { providerTextStream, useOptimisticAIState } from "@erankushpatil/anky-ui";
|
|
307
|
+
|
|
308
|
+
const ai = useOptimisticAIState({ initialState: "" });
|
|
309
|
+
ai.mutateWithStream(
|
|
310
|
+
async () => providerTextStream(await fetch("/api/chat", { method: "POST", body }), "openai"),
|
|
311
|
+
(_current, chunk) => chunk,
|
|
312
|
+
{ framing: "raw" },
|
|
313
|
+
);
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
| Format | Value | Notes |
|
|
317
|
+
| --- | --- | --- |
|
|
318
|
+
| OpenAI | `"openai"` | Chat Completions deltas and Responses API `output_text.delta`. |
|
|
319
|
+
| Anthropic | `"anthropic"` | `content_block_delta` text deltas. Stream errors raise `ProviderStreamError`. |
|
|
320
|
+
| Vercel AI SDK | `"ai-sdk"` | v5 UI-message SSE (`text-delta`), v4 data stream (`0:"..."`) and plain text responses. |
|
|
321
|
+
|
|
322
|
+
Full server and client examples are on the **Streaming recipes** docs page.
|
|
323
|
+
|
|
324
|
+
## Editor adapters
|
|
325
|
+
|
|
326
|
+
`SelectionPalette` and `InlineDiff` work on plain DOM text. For rich-text editors, wrap the editor in an adapter and pass the result of `useEditorAnchor` to the palette. Adapters are structurally typed, so Anky UI never installs Tiptap, ProseMirror or Lexical.
|
|
327
|
+
|
|
328
|
+
```tsx
|
|
329
|
+
import { SelectionPalette, createTiptapAdapter, useEditorAnchor } from "@erankushpatil/anky-ui";
|
|
330
|
+
|
|
331
|
+
const adapter = useMemo(() => (editor ? createTiptapAdapter(editor) : null), [editor]);
|
|
332
|
+
const anchor = useEditorAnchor(adapter);
|
|
333
|
+
|
|
334
|
+
<SelectionPalette anchor={anchor} onSelectPrompt={async (prompt, text) => anchor.replaceSelection(await rewrite(prompt, text))} />;
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
Also available: `createProseMirrorAdapter(view, { setSelection })` and `createLexicalAdapter(editor, { $getSelection, $isRangeSelection, $createRangeSelection, $setSelection })`. Implement the small `EditorAdapter` interface for any other editor. Replacement refuses when the document changed under the range. The adapters are tested against structural fakes, so verify them against your editor version.
|
|
338
|
+
|
|
339
|
+
## Accessibility
|
|
340
|
+
|
|
341
|
+
- `InlineDiff` is a `role="group"` labelled "AI suggested edit" and announces progress and the final insertion/deletion counts through a polite live region.
|
|
342
|
+
- `SelectionPalette` is a labelled toolbar with arrow-key navigation, announces when it opens, and `Alt+/` moves focus into it. `Escape` closes it and returns focus to the editor.
|
|
343
|
+
- `GhostInput` announces suggestions through its own live region.
|
|
344
|
+
- `announce(message, "polite" | "assertive")` is exported for your own messages.
|
|
345
|
+
|
|
346
|
+
## Styling and Tailwind
|
|
347
|
+
|
|
348
|
+
Components are unstyled by default and only emit `anky-ui-*` classes (the diff also ships inline red and green colours unless `unstyled` is set). You have three options:
|
|
349
|
+
|
|
350
|
+
1. Style the `anky-ui-*` classes in your own CSS.
|
|
351
|
+
2. Pass `classNames` to add your own classes per slot.
|
|
352
|
+
3. Use the ready-made Tailwind presets: `StyledSelectionPalette`, `StyledInlineDiff`, `selectionPaletteTailwindPreset`, `inlineDiffTailwindPreset`.
|
|
353
|
+
|
|
354
|
+
Tailwind only generates classes it can see, so add the package to your sources:
|
|
355
|
+
|
|
356
|
+
```css
|
|
357
|
+
/* Tailwind v4 */
|
|
358
|
+
@source "../node_modules/@erankushpatil/anky-ui/dist";
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
```js
|
|
362
|
+
// Tailwind v3 (tailwind.config.js)
|
|
363
|
+
content: ["./src/**/*.{ts,tsx}", "./node_modules/@erankushpatil/anky-ui/dist/**/*.js"];
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
## CLI
|
|
367
|
+
|
|
368
|
+
Copy components straight into your project and own the code, as with shadcn/ui.
|
|
369
|
+
|
|
370
|
+
```bash
|
|
371
|
+
npx anky-ui add selection-palette
|
|
372
|
+
npx anky-ui add inline-diff ghost-input
|
|
373
|
+
npx anky-ui add --all
|
|
374
|
+
npx anky-ui list
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
Files are written to `src/components/anky/`, or `components/anky/` when the project has no `src` folder. The CLI looks for the nearest `package.json` and detects `tailwind.config.*`, TypeScript and your package manager. Internal imports are rewritten to sit side by side, and `@floating-ui/react` is the only dependency it installs.
|
|
378
|
+
|
|
379
|
+
| Command / flag | Description |
|
|
380
|
+
| --- | --- |
|
|
381
|
+
| `add <component...>` | Copy components and the files they import. |
|
|
382
|
+
| `list` | Show available components. |
|
|
383
|
+
| `-a`, `--all` | Add every component. |
|
|
384
|
+
| `-p`, `--path <dir>` | Output directory, relative to the project root. |
|
|
385
|
+
| `-o`, `--overwrite` | Replace existing files that differ. Without it, edited files are kept. |
|
|
386
|
+
| `--dry-run` | Print what would change without writing or installing. |
|
|
387
|
+
| `--no-install` | Print the dependency install command instead of running it. |
|
|
388
|
+
| `--cwd <dir>` | Run as if started in another directory. |
|
|
389
|
+
| `-h`, `--help`, `-v`, `--version` | Help and version. |
|
|
390
|
+
|
|
391
|
+
Components: `selection-palette`, `inline-diff`, `ghost-input`, `use-selection-anchor`, `use-optimistic-ai-state`, `diff`, `stream-parser`, `announce`, `stream-providers`, `editor-adapter`, `prosemirror-adapter`, `tiptap-adapter`, `lexical-adapter`, `use-editor-anchor`. Dependencies are added automatically, for example `selection-palette` also copies `use-selection-anchor`.
|
|
392
|
+
|
|
393
|
+
## Server and Next.js notes
|
|
394
|
+
|
|
395
|
+
- Components and hooks include the `"use client"` directive. Import them from Client Components in the App Router.
|
|
396
|
+
- Rendering is SSR-safe: selection virtual elements, media queries and platform detection are initialised in `useEffect`, so there are no hydration mismatches.
|
|
397
|
+
- A minimal streaming route handler:
|
|
398
|
+
|
|
399
|
+
```ts
|
|
400
|
+
// app/api/rewrite/route.ts
|
|
401
|
+
export async function POST(req: Request) {
|
|
402
|
+
const { prompt, text } = await req.json();
|
|
403
|
+
const upstream = await callYourModel(prompt, text); // returns a ReadableStream<Uint8Array> of plain text
|
|
404
|
+
return new Response(upstream, { headers: { "Content-Type": "text/plain; charset=utf-8" } });
|
|
405
|
+
}
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
## Playground
|
|
409
|
+
|
|
410
|
+
The repository is a [pnpm](https://pnpm.io) workspace (pnpm 12, Node 18.17+).
|
|
411
|
+
|
|
412
|
+
```bash
|
|
413
|
+
git clone https://github.com/ErAnkushPatil/anky-ui.git
|
|
414
|
+
cd anky-ui
|
|
415
|
+
pnpm install
|
|
416
|
+
pnpm dev:demo
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
The demo shows a 500-word editable article with the palette and inline diff, an email draft with ghost completions, and global undo (`Cmd/Ctrl+Z`) and redo (`Cmd/Ctrl+Shift+Z`). It uses a mock stream by default. Set `VITE_ANKY_ENDPOINT` to a POST endpoint that accepts `{ "prompt": "...", "text": "..." }` to use a real backend. Open `#stress` for the 50 tokens/second streaming stress test.
|
|
420
|
+
|
|
421
|
+
### Docs site
|
|
422
|
+
|
|
423
|
+
```bash
|
|
424
|
+
pnpm dev:docs
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
The docs site has live demos for the components, state hook, provider streaming recipes (OpenAI, Anthropic, Vercel AI SDK), editor adapters and accessibility announcements. Its **Benchmarks** page measures `computeDiff` speed (5 ms budget at 5,000 characters) and the streaming frame rate into `InlineDiff` at 50, 100 and 200 tokens/second, live in your browser.
|
|
428
|
+
|
|
429
|
+
## Development
|
|
430
|
+
|
|
431
|
+
```bash
|
|
432
|
+
pnpm install
|
|
433
|
+
pnpm verify # typecheck, test and build every package
|
|
434
|
+
pnpm test # tests only
|
|
435
|
+
pnpm typecheck # typecheck only
|
|
436
|
+
pnpm build # build the library, CLI and apps
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
The workspace root is the publishable `anky-ui` CLI package. `packages/cli` is its private implementation (`@anky/cli`). `packages/core` is published separately as `@erankushpatil/anky-ui`. `pnpm pack` at the root builds the CLI and bundles the component registry.
|
|
440
|
+
|
|
441
|
+
### Releasing
|
|
442
|
+
|
|
443
|
+
```bash
|
|
444
|
+
pnpm verify
|
|
445
|
+
pnpm --filter @erankushpatil/anky-ui publish # the library
|
|
446
|
+
pnpm publish # the anky-ui CLI
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
Bump `version` in `package.json` and `packages/core/package.json` first. The CLI copies the library's source, so release both together.
|
|
450
|
+
|
|
451
|
+
## Contributing
|
|
452
|
+
|
|
453
|
+
Issues and pull requests are welcome at [github.com/ErAnkushPatil/anky-ui](https://github.com/ErAnkushPatil/anky-ui/issues). Run `pnpm verify` before opening a pull request.
|
|
454
|
+
|
|
455
|
+
## FAQ
|
|
456
|
+
|
|
457
|
+
**Do I need a specific AI provider?** No. Anky UI only consumes a text stream (raw or SSE). Use any model behind your own endpoint.
|
|
458
|
+
|
|
459
|
+
**Does it need Tailwind?** No. Tailwind presets are optional, and the CLI copies unstyled components.
|
|
460
|
+
|
|
461
|
+
**Is it SSR and Next.js safe?** Yes. Client-only browser APIs are touched only inside effects.
|
|
462
|
+
|
|
463
|
+
**Does it work on touch devices?** Yes. Selection tracking handles `touchend` as well as `mouseup` without double triggers.
|
|
464
|
+
|
|
465
|
+
**How fast is the diff?** Strings up to 5,000 characters diff in under 5 ms, and a work budget bounds the worst case.
|
|
466
|
+
|
|
467
|
+
## Keywords
|
|
468
|
+
|
|
469
|
+
ai ui library, inline ai, ambient ai, copilot ui components, shadcn ai, react inline diff streaming, cursor-like inline prompt react, ghost text autocomplete textarea, floating ai action palette, undo redo ai state react, nextjs generative ui, headless ai primitives, tailwind ai components, radix floating ai toolbar.
|
|
470
|
+
|
|
471
|
+
## License
|
|
472
|
+
|
|
473
|
+
[ISC](./LICENSE)
|
package/package.json
CHANGED
|
@@ -1,6 +1,74 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anky-ui",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Copy Anky UI's React AI components into your project.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"anky-ui": "./packages/cli/dist/index.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"LICENSE",
|
|
10
|
+
"packages/cli/dist",
|
|
11
|
+
"packages/cli/registry",
|
|
12
|
+
"packages/cli/package.json"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18.17"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"ai ui library",
|
|
19
|
+
"inline ai",
|
|
20
|
+
"ambient ai",
|
|
21
|
+
"copilot ui components",
|
|
22
|
+
"shadcn ai",
|
|
23
|
+
"react inline diff streaming",
|
|
24
|
+
"cursor-like inline prompt react",
|
|
25
|
+
"ghost text autocomplete textarea",
|
|
26
|
+
"floating ai action palette",
|
|
27
|
+
"undo redo ai state react",
|
|
28
|
+
"nextjs generative ui",
|
|
29
|
+
"headless ai primitives",
|
|
30
|
+
"tailwind ai components",
|
|
31
|
+
"radix floating ai toolbar",
|
|
32
|
+
"cli"
|
|
33
|
+
],
|
|
34
|
+
"license": "ISC",
|
|
35
|
+
"author": "Ankush Patil",
|
|
36
|
+
"homepage": "https://github.com/ErAnkushPatil/anky-ui#readme",
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/ErAnkushPatil/anky-ui.git"
|
|
40
|
+
},
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/ErAnkushPatil/anky-ui/issues"
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
},
|
|
47
|
+
"devEngines": {
|
|
48
|
+
"packageManager": {
|
|
49
|
+
"name": "pnpm",
|
|
50
|
+
"version": "12.15.0",
|
|
51
|
+
"onFail": "download"
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"type": "module",
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@typescript/typescript6": "^6.0.2",
|
|
57
|
+
"@types/node": "^26.6.5",
|
|
58
|
+
"@types/react": "^19.3.0",
|
|
59
|
+
"@types/react-dom": "^19.3.0",
|
|
60
|
+
"tsup": "^9.0.0",
|
|
61
|
+
"typescript": "^7.0.2",
|
|
62
|
+
"vitest": "^5.0.5"
|
|
63
|
+
},
|
|
64
|
+
"scripts": {
|
|
65
|
+
"build": "pnpm -r build",
|
|
66
|
+
"build:cli": "pnpm --filter @anky/cli build",
|
|
67
|
+
"build:core": "pnpm --filter @erankushpatil/anky-ui build",
|
|
68
|
+
"dev:demo": "pnpm --filter @anky/demo dev",
|
|
69
|
+
"dev:docs": "pnpm --filter @anky/docs dev",
|
|
70
|
+
"test": "pnpm -r test",
|
|
71
|
+
"typecheck": "pnpm -r typecheck",
|
|
72
|
+
"verify": "pnpm -r typecheck && pnpm -r test && pnpm -r build"
|
|
73
|
+
}
|
|
6
74
|
}
|