neeter 0.6.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 +21 -0
- package/README.md +361 -0
- package/dist/react/AgentProvider.d.ts +15 -0
- package/dist/react/AgentProvider.js +30 -0
- package/dist/react/ApprovalButtons.d.ts +5 -0
- package/dist/react/ApprovalButtons.js +30 -0
- package/dist/react/ChatInput.d.ts +6 -0
- package/dist/react/ChatInput.js +41 -0
- package/dist/react/CollapsibleCard.d.ts +9 -0
- package/dist/react/CollapsibleCard.js +9 -0
- package/dist/react/MessageList.d.ts +3 -0
- package/dist/react/MessageList.js +32 -0
- package/dist/react/PendingPermissions.d.ts +3 -0
- package/dist/react/PendingPermissions.js +35 -0
- package/dist/react/StatusDot.d.ts +8 -0
- package/dist/react/StatusDot.js +15 -0
- package/dist/react/TextMessage.d.ts +5 -0
- package/dist/react/TextMessage.js +8 -0
- package/dist/react/ThinkingBlock.d.ts +5 -0
- package/dist/react/ThinkingBlock.js +38 -0
- package/dist/react/ThinkingIndicator.d.ts +3 -0
- package/dist/react/ThinkingIndicator.js +5 -0
- package/dist/react/ToolApprovalCard.d.ts +7 -0
- package/dist/react/ToolApprovalCard.js +11 -0
- package/dist/react/ToolCallCard.d.ts +5 -0
- package/dist/react/ToolCallCard.js +59 -0
- package/dist/react/UserQuestionCard.d.ts +6 -0
- package/dist/react/UserQuestionCard.js +120 -0
- package/dist/react/approval-matching.d.ts +13 -0
- package/dist/react/approval-matching.js +30 -0
- package/dist/react/cn.d.ts +2 -0
- package/dist/react/cn.js +5 -0
- package/dist/react/icons.d.ts +7 -0
- package/dist/react/icons.js +8 -0
- package/dist/react/index.d.ts +28 -0
- package/dist/react/index.js +28 -0
- package/dist/react/markdown-overrides.d.ts +2 -0
- package/dist/react/markdown-overrides.js +8 -0
- package/dist/react/registry.d.ts +4 -0
- package/dist/react/registry.js +10 -0
- package/dist/react/store.d.ts +34 -0
- package/dist/react/store.js +141 -0
- package/dist/react/use-agent.d.ts +12 -0
- package/dist/react/use-agent.js +119 -0
- package/dist/react/widgets/AskUserQuestionWidget.d.ts +1 -0
- package/dist/react/widgets/AskUserQuestionWidget.js +42 -0
- package/dist/react/widgets/BashWidget.d.ts +1 -0
- package/dist/react/widgets/BashWidget.js +33 -0
- package/dist/react/widgets/EditWidget.d.ts +1 -0
- package/dist/react/widgets/EditWidget.js +36 -0
- package/dist/react/widgets/GlobWidget.d.ts +1 -0
- package/dist/react/widgets/GlobWidget.js +31 -0
- package/dist/react/widgets/GrepWidget.d.ts +1 -0
- package/dist/react/widgets/GrepWidget.js +36 -0
- package/dist/react/widgets/NotebookEditWidget.d.ts +1 -0
- package/dist/react/widgets/NotebookEditWidget.js +47 -0
- package/dist/react/widgets/ReadWidget.d.ts +1 -0
- package/dist/react/widgets/ReadWidget.js +46 -0
- package/dist/react/widgets/TodoWriteWidget.d.ts +1 -0
- package/dist/react/widgets/TodoWriteWidget.js +40 -0
- package/dist/react/widgets/WebFetchWidget.d.ts +1 -0
- package/dist/react/widgets/WebFetchWidget.js +48 -0
- package/dist/react/widgets/WebSearchWidget.d.ts +1 -0
- package/dist/react/widgets/WebSearchWidget.js +85 -0
- package/dist/react/widgets/WriteWidget.d.ts +1 -0
- package/dist/react/widgets/WriteWidget.js +30 -0
- package/dist/server/index.d.ts +6 -0
- package/dist/server/index.js +5 -0
- package/dist/server/permission-gate.d.ts +12 -0
- package/dist/server/permission-gate.js +41 -0
- package/dist/server/push-channel.d.ts +8 -0
- package/dist/server/push-channel.js +40 -0
- package/dist/server/router.d.ts +8 -0
- package/dist/server/router.js +67 -0
- package/dist/server/session.d.ts +40 -0
- package/dist/server/session.js +117 -0
- package/dist/server/translator.d.ts +15 -0
- package/dist/server/translator.js +236 -0
- package/dist/types.d.ts +79 -0
- package/dist/types.js +1 -0
- package/package.json +72 -0
- package/src/theme.css +170 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dan Leeper
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
# neeter
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/neeter)
|
|
4
|
+
[](https://www.npmjs.com/package/neeter)
|
|
5
|
+
[](./LICENSE)
|
|
6
|
+
|
|
7
|
+
A React + Hono toolkit that puts a browser UI on the [Claude Agent SDK](https://github.com/anthropics/claude-agent-sdk) — the same agentic framework that powers Claude Code. Streams tool calls, file edits, permissions, and multi-turn sessions over SSE into ready-made React components.
|
|
8
|
+
|
|
9
|
+
## Why neeter
|
|
10
|
+
|
|
11
|
+
The Claude Agent SDK gives you a powerful agentic loop — but it's a server-side `AsyncGenerator` with no opinion on how to get those events to a browser. neeter bridges that gap:
|
|
12
|
+
|
|
13
|
+
- **Multi-turn persistent sessions** — `PushChannel` + `SessionManager` let users send messages at any time. Messages queue and the SDK picks them up when ready — no "wait for the agent to finish" lockout.
|
|
14
|
+
- **Named SSE event routing** — The SDK yields a flat stream of internal message types. The `MessageTranslator` reshapes them into semantically named SSE events (`text_delta`, `tool_start`, `tool_call`, `tool_result`, ...) that the browser's `EventSource` can route with native `addEventListener`.
|
|
15
|
+
- **UI-friendly tool lifecycle** — Tool calls move through `pending` → `streaming_input` → `running` → `complete` phases with streaming JSON input, giving your UI fine-grained control over loading states and progressive rendering.
|
|
16
|
+
- **Structured custom events** — Hook into tool results with `onToolResult` and emit typed `{ name, value }` events for app-specific reactivity (e.g. "document saved", "data refreshed") without touching the core protocol.
|
|
17
|
+
- **Browser-side tool approval** — The SDK's `canUseTool` callback fires on the server, but your users are in the browser. `PermissionGate` bridges the gap with deferred promises, SSE events, and an HTTP POST endpoint — the agent blocks until the user clicks Allow/Deny or answers a clarifying question.
|
|
18
|
+
- **Client-server separation** — Server handles transport (SSE encoding, session routing). Client handles state (Zustand store, React components). The translator is the clean seam between them.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pnpm add neeter
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Peer dependencies:
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{
|
|
30
|
+
"@anthropic-ai/claude-agent-sdk": ">=0.2.0",
|
|
31
|
+
"hono": ">=4.0.0",
|
|
32
|
+
"react": ">=18.0.0",
|
|
33
|
+
"react-markdown": ">=10.0.0",
|
|
34
|
+
"zustand": ">=5.0.0",
|
|
35
|
+
"immer": ">=10.0.0",
|
|
36
|
+
"tailwindcss": ">=4.0.0"
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Server
|
|
41
|
+
|
|
42
|
+
`neeter/server` gives you a Hono router that manages Agent SDK sessions and streams events to the client over SSE.
|
|
43
|
+
|
|
44
|
+
The Claude Agent SDK reads your API key from the environment automatically. Make sure it's set before starting your server:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
export ANTHROPIC_API_KEY=your-api-key
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```typescript
|
|
51
|
+
import { Hono } from "hono";
|
|
52
|
+
import { serve } from "@hono/node-server";
|
|
53
|
+
import {
|
|
54
|
+
createAgentRouter,
|
|
55
|
+
SessionManager,
|
|
56
|
+
MessageTranslator,
|
|
57
|
+
} from "neeter/server";
|
|
58
|
+
|
|
59
|
+
const sessions = new SessionManager(() => ({
|
|
60
|
+
context: {},
|
|
61
|
+
model: "claude-sonnet-4-5-20250929",
|
|
62
|
+
systemPrompt: "You are a helpful assistant.",
|
|
63
|
+
maxTurns: 50,
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
const translator = new MessageTranslator();
|
|
67
|
+
|
|
68
|
+
const app = new Hono();
|
|
69
|
+
app.route("/", createAgentRouter({ sessions, translator }));
|
|
70
|
+
|
|
71
|
+
serve({ fetch: app.fetch, port: 3000 });
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Extended thinking
|
|
75
|
+
|
|
76
|
+
Enable Claude's chain-of-thought reasoning by setting `thinking` in your session config:
|
|
77
|
+
|
|
78
|
+
```typescript
|
|
79
|
+
const sessions = new SessionManager(() => ({
|
|
80
|
+
context: {},
|
|
81
|
+
model: "claude-sonnet-4-5-20250929",
|
|
82
|
+
systemPrompt: "You are a helpful assistant.",
|
|
83
|
+
thinking: { type: "enabled", budgetTokens: 10000 },
|
|
84
|
+
}));
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
When enabled, thinking blocks stream to the client as `thinking_delta` SSE events and render as collapsible cards in `MessageList`. Set `{ type: "disabled" }` to explicitly turn thinking off (it's off by default).
|
|
88
|
+
|
|
89
|
+
This gives you four endpoints:
|
|
90
|
+
|
|
91
|
+
| Method | Path | Description |
|
|
92
|
+
|--------|------|-------------|
|
|
93
|
+
| `POST` | `/api/sessions` | Create a session, returns `{ sessionId }` |
|
|
94
|
+
| `POST` | `/api/sessions/:id/messages` | Send `{ text }` to a session |
|
|
95
|
+
| `GET` | `/api/sessions/:id/events` | SSE stream of agent events |
|
|
96
|
+
| `POST` | `/api/sessions/:id/permissions` | Respond to a permission request (see [Permissions](#permissions)) |
|
|
97
|
+
|
|
98
|
+
### Session context
|
|
99
|
+
|
|
100
|
+
`SessionManager` takes a factory function that runs once per session. The generic type parameter lets you attach per-session state:
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
interface MyContext {
|
|
104
|
+
history: string[];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const sessions = new SessionManager<MyContext>(() => ({
|
|
108
|
+
context: { history: [] },
|
|
109
|
+
model: "claude-sonnet-4-5-20250929",
|
|
110
|
+
systemPrompt: "You are a helpful assistant.",
|
|
111
|
+
mcpServers: { myServer: createMyServer() },
|
|
112
|
+
allowedTools: ["mcp__myServer__*"],
|
|
113
|
+
maxTurns: 100,
|
|
114
|
+
}));
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The context is available in translator hooks (see below).
|
|
118
|
+
|
|
119
|
+
### Reacting to tool results
|
|
120
|
+
|
|
121
|
+
Use `onToolResult` to inspect what the agent did and emit structured custom events:
|
|
122
|
+
|
|
123
|
+
```typescript
|
|
124
|
+
const translator = new MessageTranslator<MyContext>({
|
|
125
|
+
onToolResult: (toolName, result, session) => {
|
|
126
|
+
if (toolName === "save_note") {
|
|
127
|
+
session.context.history.push(result);
|
|
128
|
+
return [{ name: "notes_updated", value: session.context.history }];
|
|
129
|
+
}
|
|
130
|
+
return [];
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Each returned `{ name, value }` object is sent to the client as a `custom` SSE event.
|
|
136
|
+
|
|
137
|
+
### Permissions
|
|
138
|
+
|
|
139
|
+
By default sessions run with `permissionMode: "bypassPermissions"` — all tools execute automatically. Set `permissionMode` to `"default"` (or `"acceptEdits"`) to require browser-side approval before each tool runs:
|
|
140
|
+
|
|
141
|
+
```typescript
|
|
142
|
+
const sessions = new SessionManager(() => ({
|
|
143
|
+
context: {},
|
|
144
|
+
model: "claude-sonnet-4-5-20250929",
|
|
145
|
+
systemPrompt: "You are a helpful assistant.",
|
|
146
|
+
permissionMode: "default",
|
|
147
|
+
}));
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
When `permissionMode` is not `"bypassPermissions"`:
|
|
151
|
+
|
|
152
|
+
1. Every tool call blocks the SDK until the user responds
|
|
153
|
+
2. `AskUserQuestion` calls surface as structured questions with options
|
|
154
|
+
3. `permission_request` SSE events fire to the client
|
|
155
|
+
4. The user's response is POSTed back to `/api/sessions/:id/permissions`
|
|
156
|
+
|
|
157
|
+
The `PermissionGate` on each session manages the deferred promises internally — no additional wiring needed.
|
|
158
|
+
|
|
159
|
+
| Mode | Behavior |
|
|
160
|
+
|------|----------|
|
|
161
|
+
| `"bypassPermissions"` | All tools auto-approved (default) |
|
|
162
|
+
| `"default"` | Every tool call requires explicit approval |
|
|
163
|
+
| `"acceptEdits"` | File edits auto-approved, other tools require approval |
|
|
164
|
+
| `"plan"` | Planning mode — SDK-defined behavior |
|
|
165
|
+
|
|
166
|
+
## Client
|
|
167
|
+
|
|
168
|
+
`neeter/react` provides a drop-in chat UI that connects to your server.
|
|
169
|
+
|
|
170
|
+
```tsx
|
|
171
|
+
import { AgentProvider, MessageList, ChatInput, useAgentContext } from "neeter/react";
|
|
172
|
+
|
|
173
|
+
function App() {
|
|
174
|
+
return (
|
|
175
|
+
<AgentProvider>
|
|
176
|
+
<Chat />
|
|
177
|
+
</AgentProvider>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function Chat() {
|
|
182
|
+
const { sendMessage } = useAgentContext();
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<div className="flex h-screen flex-col">
|
|
186
|
+
<MessageList className="flex-1" />
|
|
187
|
+
<ChatInput onSend={sendMessage} />
|
|
188
|
+
</div>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Components use Tailwind utility classes and accept `className` for overrides.
|
|
194
|
+
|
|
195
|
+
### Custom events
|
|
196
|
+
|
|
197
|
+
If your server emits custom events (via `onToolResult`), handle them with `onCustomEvent`:
|
|
198
|
+
|
|
199
|
+
```tsx
|
|
200
|
+
<AgentProvider
|
|
201
|
+
onCustomEvent={(e) => {
|
|
202
|
+
if (e.name === "notes_updated") {
|
|
203
|
+
myStore.getState().setNotes(e.value);
|
|
204
|
+
}
|
|
205
|
+
}}
|
|
206
|
+
>
|
|
207
|
+
<Chat />
|
|
208
|
+
</AgentProvider>
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Each event is a typed `CustomEvent<T>` with `name` and `value` fields.
|
|
212
|
+
|
|
213
|
+
### Widgets
|
|
214
|
+
|
|
215
|
+
When you add tools to your `SessionManager`, neeter automatically renders them with purpose-built widgets — diff views for edits, code blocks for file reads, expandable link pills for web searches, and so on. No configuration needed.
|
|
216
|
+
|
|
217
|
+
- **[Built-in widgets](docs/built-in-widgets.md)** — what ships out of the box for the 11 supported SDK tools, how approval previews work, and how to extend or override them
|
|
218
|
+
- **[Custom widgets](docs/custom-widgets.md)** — register your own components for MCP tools or app-specific rendering
|
|
219
|
+
|
|
220
|
+
Tool calls without a registered widget fall back to a minimal status indicator.
|
|
221
|
+
|
|
222
|
+
### Tool call lifecycle
|
|
223
|
+
|
|
224
|
+
Each tool call moves through phases, reflected in `WidgetProps.phase`:
|
|
225
|
+
|
|
226
|
+
| Phase | Trigger | What's available |
|
|
227
|
+
|-------|---------|-----------------:|
|
|
228
|
+
| `pending` | `tool_start` SSE event | `input: {}` |
|
|
229
|
+
| `streaming_input` | `tool_input_delta` events | `partialInput` accumulates |
|
|
230
|
+
| `running` | `tool_call` event (input finalized) | `input` is complete |
|
|
231
|
+
| `complete` | `tool_result` event | `result` is JSON-parsed |
|
|
232
|
+
| `error` | Error during execution | `error` message |
|
|
233
|
+
|
|
234
|
+
## Styling
|
|
235
|
+
|
|
236
|
+
Neeter components use Tailwind v4 utility classes and [shadcn/ui](https://ui.shadcn.com)-compatible CSS variable names (`bg-primary`, `text-muted-foreground`, `border-border`, etc.).
|
|
237
|
+
|
|
238
|
+
### With shadcn/ui
|
|
239
|
+
|
|
240
|
+
Your existing theme variables are already compatible. Add one line to your main CSS so Tailwind scans neeter's component source for utility classes:
|
|
241
|
+
|
|
242
|
+
```css
|
|
243
|
+
@import "tailwindcss";
|
|
244
|
+
@source "../node_modules/neeter/src";
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
The `@source` path is relative to your CSS file — adjust if your stylesheet lives in a nested directory (e.g. `../../node_modules/neeter/src`).
|
|
248
|
+
|
|
249
|
+
### Without shadcn/ui
|
|
250
|
+
|
|
251
|
+
Import the bundled theme, which includes source scanning automatically:
|
|
252
|
+
|
|
253
|
+
```css
|
|
254
|
+
@import "tailwindcss";
|
|
255
|
+
@import "neeter/theme.css";
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
This provides a neutral OKLCH palette with light + dark mode support and the Tailwind v4 `@theme inline` variable bridge.
|
|
259
|
+
|
|
260
|
+
### Dark mode
|
|
261
|
+
|
|
262
|
+
Dark mode activates via:
|
|
263
|
+
- `.dark` class on `<html>` (recommended), or
|
|
264
|
+
- `prefers-color-scheme: dark` system preference (automatic)
|
|
265
|
+
|
|
266
|
+
Add `.light` to `<html>` to force light mode when using system preference detection.
|
|
267
|
+
|
|
268
|
+
### Switching to shadcn later
|
|
269
|
+
|
|
270
|
+
Drop the `neeter/theme.css` import and add `@source` — your shadcn theme takes over with zero migration.
|
|
271
|
+
|
|
272
|
+
## API Reference
|
|
273
|
+
|
|
274
|
+
### `neeter/server`
|
|
275
|
+
|
|
276
|
+
| Export | Description |
|
|
277
|
+
|--------|-------------|
|
|
278
|
+
| `SessionManager<TCtx>` | Manages agent sessions with per-session context |
|
|
279
|
+
| `Session<TCtx>` | A single session — `id`, `context`, `pushMessage()`, `permissionGate`, `abort()` |
|
|
280
|
+
| `SessionInit<TCtx>` | Factory return type — `model`, `systemPrompt`, `permissionMode`, `mcpServers`, etc. |
|
|
281
|
+
| `MessageTranslator<TCtx>` | Converts SDK messages to SSE events |
|
|
282
|
+
| `TranslatorConfig<TCtx>` | Translator options — `onToolResult` hook |
|
|
283
|
+
| `createAgentRouter<TCtx>(config)` | Returns a Hono app with session, SSE, and permission routes |
|
|
284
|
+
| `PermissionGate` | Per-session deferred-promise map for tool approval and user questions |
|
|
285
|
+
| `PushChannel<T>` | Async iterable queue for feeding messages to the SDK |
|
|
286
|
+
| `sseEncode(event)` | Formats an `SSEEvent` as an SSE string |
|
|
287
|
+
| `streamSession(session, translator)` | Async generator yielding `SSEEvent`s |
|
|
288
|
+
|
|
289
|
+
### `neeter/react`
|
|
290
|
+
|
|
291
|
+
| Export | Description |
|
|
292
|
+
|--------|-------------|
|
|
293
|
+
| `AgentProvider` | Context provider — wraps store + SSE connection |
|
|
294
|
+
| `useAgentContext()` | Returns `{ sessionId, sendMessage, respondToPermission, store }` |
|
|
295
|
+
| `useChatStore(selector)` | Zustand selector hook into chat state |
|
|
296
|
+
| `createChatStore()` | Creates a vanilla Zustand store (for advanced use) |
|
|
297
|
+
| `useAgent(store, config?)` | SSE connection hook (used internally by `AgentProvider`) |
|
|
298
|
+
| `MessageList` | Auto-scrolling message list with pending permissions and thinking indicator |
|
|
299
|
+
| `TextMessage` | Markdown-rendered message bubble |
|
|
300
|
+
| `ChatInput` | Textarea + send button |
|
|
301
|
+
| `ToolCallCard` | Lifecycle-aware tool call display with inline approval |
|
|
302
|
+
| `PendingPermissions` | Renders pending tool approval and user question cards |
|
|
303
|
+
| `ToolApprovalCard` | Tool approval card with Allow/Deny buttons |
|
|
304
|
+
| `UserQuestionCard` | Structured question card with option selection |
|
|
305
|
+
| `ThinkingBlock` | Collapsible card displaying extended thinking text |
|
|
306
|
+
| `ThinkingIndicator` | Animated dots shown while agent is generating |
|
|
307
|
+
| `CollapsibleCard` | Expandable card wrapper |
|
|
308
|
+
| `StatusDot` | Phase-colored status indicator |
|
|
309
|
+
| `cn(...inputs)` | `clsx` + `tailwind-merge` utility for class merging |
|
|
310
|
+
| `registerWidget(registration)` | Register a component for a tool name |
|
|
311
|
+
| `getWidget(toolName)` | Look up a registered widget |
|
|
312
|
+
| `stripMcpPrefix(name)` | `"mcp__server__tool"` → `"tool"` |
|
|
313
|
+
|
|
314
|
+
### Types (re-exported from both entry points)
|
|
315
|
+
|
|
316
|
+
| Type | Description |
|
|
317
|
+
|------|-------------|
|
|
318
|
+
| `SSEEvent` | `{ event: string, data: string }` |
|
|
319
|
+
| `ChatMessage` | `{ id, role, content, thinking?, toolCalls? }` |
|
|
320
|
+
| `ToolCallInfo` | `{ id, name, input, partialInput?, result?, error?, status }` |
|
|
321
|
+
| `ToolCallPhase` | `"pending" \| "streaming_input" \| "running" \| "complete" \| "error"` |
|
|
322
|
+
| `WidgetProps<TResult>` | Props passed to widget components |
|
|
323
|
+
| `WidgetRegistration<TResult>` | Widget registration — `toolName`, `label`, `richLabel?`, `inputRenderer?`, `component` |
|
|
324
|
+
| `ChatStore` | `StoreApi<ChatStoreShape>` — vanilla Zustand store |
|
|
325
|
+
| `ChatStoreShape` | Full state + actions interface |
|
|
326
|
+
| `CustomEvent<T>` | `{ name: string, value: T }` — structured app-level event |
|
|
327
|
+
| `PermissionRequest` | `ToolApprovalRequest \| UserQuestionRequest` — pending permission |
|
|
328
|
+
| `PermissionResponse` | `ToolApprovalResponse \| UserQuestionResponse` — user's answer |
|
|
329
|
+
| `ToolApprovalRequest` | `{ kind, requestId, toolName, toolUseId?, input, description? }` |
|
|
330
|
+
| `ToolApprovalResponse` | `{ kind, requestId, behavior: "allow" \| "deny", message? }` |
|
|
331
|
+
| `UserQuestion` | `{ question, header?, options?, multiSelect? }` |
|
|
332
|
+
| `UserQuestionRequest` | `{ kind, requestId, questions: UserQuestion[] }` |
|
|
333
|
+
| `UserQuestionResponse` | `{ kind, requestId, answers: Record<string, string> }` |
|
|
334
|
+
|
|
335
|
+
## SSE Events
|
|
336
|
+
|
|
337
|
+
Events emitted by the server, handled automatically by `useAgent`:
|
|
338
|
+
|
|
339
|
+
| Event | Payload | Description |
|
|
340
|
+
|-------|---------|-------------|
|
|
341
|
+
| `message_start` | `{}` | Agent began generating a response |
|
|
342
|
+
| `thinking_start` | `{}` | Extended thinking block began |
|
|
343
|
+
| `thinking_delta` | `{ text }` | Streaming thinking text chunk |
|
|
344
|
+
| `text_delta` | `{ text }` | Streaming text chunk |
|
|
345
|
+
| `tool_start` | `{ id, name }` | Agent began calling a tool |
|
|
346
|
+
| `tool_input_delta` | `{ id, partialJson }` | Streaming tool input JSON |
|
|
347
|
+
| `tool_call` | `{ id, name, input }` | Tool input finalized |
|
|
348
|
+
| `tool_result` | `{ toolUseId, result }` | Tool execution result |
|
|
349
|
+
| `tool_progress` | `{ toolName, elapsed }` | Long-running tool heartbeat |
|
|
350
|
+
| `permission_request` | `PermissionRequest` | Tool approval or user question awaiting response |
|
|
351
|
+
| `turn_complete` | `{ numTurns, cost }` | Agent turn finished |
|
|
352
|
+
| `custom` | `{ name, value }` | App-specific event from `onToolResult` |
|
|
353
|
+
| `session_error` | `{ subtype }` | Session ended with error |
|
|
354
|
+
|
|
355
|
+
## Development
|
|
356
|
+
|
|
357
|
+
See [docs/development.md](docs/development.md) for local setup, pre-commit hooks, and CI.
|
|
358
|
+
|
|
359
|
+
## License
|
|
360
|
+
|
|
361
|
+
MIT
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import type { CustomEvent } from "../types.js";
|
|
3
|
+
import { type ChatStore, type ChatStoreShape } from "./store.js";
|
|
4
|
+
import { type UseAgentReturn } from "./use-agent.js";
|
|
5
|
+
interface AgentContextValue extends UseAgentReturn {
|
|
6
|
+
store: ChatStore;
|
|
7
|
+
}
|
|
8
|
+
export declare function AgentProvider(props: {
|
|
9
|
+
endpoint?: string;
|
|
10
|
+
onCustomEvent?: (event: CustomEvent) => void;
|
|
11
|
+
children: ReactNode;
|
|
12
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
13
|
+
export declare function useAgentContext(): AgentContextValue;
|
|
14
|
+
export declare function useChatStore<T>(selector: (state: ChatStoreShape) => T): T;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useContext, useMemo, useRef } from "react";
|
|
3
|
+
import { useStore } from "zustand";
|
|
4
|
+
import { createChatStore } from "./store.js";
|
|
5
|
+
import { useAgent } from "./use-agent.js";
|
|
6
|
+
const AgentContext = createContext(null);
|
|
7
|
+
export function AgentProvider(props) {
|
|
8
|
+
const storeRef = useRef(null);
|
|
9
|
+
if (!storeRef.current) {
|
|
10
|
+
storeRef.current = createChatStore();
|
|
11
|
+
}
|
|
12
|
+
const store = storeRef.current;
|
|
13
|
+
const agentConfig = useMemo(() => ({
|
|
14
|
+
endpoint: props.endpoint,
|
|
15
|
+
onCustomEvent: props.onCustomEvent,
|
|
16
|
+
}), [props.endpoint, props.onCustomEvent]);
|
|
17
|
+
const agent = useAgent(store, agentConfig);
|
|
18
|
+
const value = useMemo(() => ({ ...agent, store }), [agent, store]);
|
|
19
|
+
return _jsx(AgentContext, { value: value, children: props.children });
|
|
20
|
+
}
|
|
21
|
+
export function useAgentContext() {
|
|
22
|
+
const ctx = useContext(AgentContext);
|
|
23
|
+
if (!ctx)
|
|
24
|
+
throw new Error("useAgentContext must be used within <AgentProvider>");
|
|
25
|
+
return ctx;
|
|
26
|
+
}
|
|
27
|
+
export function useChatStore(selector) {
|
|
28
|
+
const { store } = useAgentContext();
|
|
29
|
+
return useStore(store, selector);
|
|
30
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef } from "react";
|
|
3
|
+
import { cn } from "./cn.js";
|
|
4
|
+
export function ApprovalButtons({ onApprove, onDeny, className, }) {
|
|
5
|
+
const allowRef = useRef(null);
|
|
6
|
+
// Auto-focus the Allow button when mounted
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
allowRef.current?.focus();
|
|
9
|
+
}, []);
|
|
10
|
+
// Keyboard shortcuts: 1 = Allow, 2 = Deny (skip when typing in inputs)
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
function handleKeyDown(e) {
|
|
13
|
+
const tag = e.target?.tagName;
|
|
14
|
+
if (tag === "INPUT" || tag === "TEXTAREA" || e.target?.isContentEditable) {
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (e.key === "1") {
|
|
18
|
+
e.preventDefault();
|
|
19
|
+
onApprove();
|
|
20
|
+
}
|
|
21
|
+
else if (e.key === "2") {
|
|
22
|
+
e.preventDefault();
|
|
23
|
+
onDeny();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
27
|
+
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
28
|
+
}, [onApprove, onDeny]);
|
|
29
|
+
return (_jsxs("div", { className: cn("mt-2 flex items-center gap-2", className), children: [_jsxs("button", { ref: allowRef, type: "button", onClick: onApprove, className: "rounded bg-primary px-3 py-1 text-primary-foreground hover:bg-primary/90 transition-colors focus:outline-none focus:ring-2 focus:ring-primary/50", children: ["Allow", _jsx("kbd", { className: "ml-1.5 text-[9px] opacity-60", children: "1" })] }), _jsxs("button", { type: "button", onClick: onDeny, className: "rounded border border-border px-3 py-1 text-muted-foreground hover:bg-accent transition-colors focus:outline-none focus:ring-2 focus:ring-border", children: ["Deny", _jsx("kbd", { className: "ml-1.5 text-[9px] opacity-60", children: "2" })] })] }));
|
|
30
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useRef, useState } from "react";
|
|
3
|
+
import { cn } from "./cn.js";
|
|
4
|
+
import { SendIcon } from "./icons.js";
|
|
5
|
+
export function ChatInput({ onSend, placeholder = "Type a message...", disabled, className, }) {
|
|
6
|
+
const [text, setText] = useState("");
|
|
7
|
+
const textareaRef = useRef(null);
|
|
8
|
+
const isDisabled = disabled ?? false;
|
|
9
|
+
const MAX_H = 160; // matches max-h-40 (10rem)
|
|
10
|
+
const autoResize = useCallback(() => {
|
|
11
|
+
const el = textareaRef.current;
|
|
12
|
+
if (!el)
|
|
13
|
+
return;
|
|
14
|
+
el.style.height = "auto";
|
|
15
|
+
el.style.height = `${el.scrollHeight}px`;
|
|
16
|
+
el.style.overflowY = el.scrollHeight > MAX_H ? "auto" : "hidden";
|
|
17
|
+
}, []);
|
|
18
|
+
function handleSend() {
|
|
19
|
+
const trimmed = text.trim();
|
|
20
|
+
if (!trimmed || isDisabled)
|
|
21
|
+
return;
|
|
22
|
+
onSend(trimmed);
|
|
23
|
+
setText("");
|
|
24
|
+
requestAnimationFrame(() => {
|
|
25
|
+
const el = textareaRef.current;
|
|
26
|
+
if (el) {
|
|
27
|
+
el.style.height = "auto";
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
function handleKeyDown(e) {
|
|
32
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
33
|
+
e.preventDefault();
|
|
34
|
+
handleSend();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return (_jsxs("div", { className: cn("flex items-end gap-2 p-3", className), children: [_jsx("textarea", { ref: textareaRef, value: text, onChange: (e) => {
|
|
38
|
+
setText(e.target.value);
|
|
39
|
+
autoResize();
|
|
40
|
+
}, onKeyDown: handleKeyDown, placeholder: placeholder, rows: 1, className: "flex-1 resize-none rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring max-h-40 overflow-y-hidden" }), _jsx("button", { type: "button", onClick: handleSend, disabled: !text.trim() || isDisabled, className: "inline-flex h-9 w-9 items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:pointer-events-none", children: _jsx(SendIcon, {}) })] }));
|
|
41
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import type { ToolCallPhase } from "../types.js";
|
|
3
|
+
export declare function CollapsibleCard({ label, status, defaultOpen, children, className, }: {
|
|
4
|
+
label: string;
|
|
5
|
+
status?: ToolCallPhase;
|
|
6
|
+
defaultOpen?: boolean;
|
|
7
|
+
children: ReactNode;
|
|
8
|
+
className?: string;
|
|
9
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { cn } from "./cn.js";
|
|
4
|
+
import { ChevronIcon } from "./icons.js";
|
|
5
|
+
import { StatusDot } from "./StatusDot.js";
|
|
6
|
+
export function CollapsibleCard({ label, status, defaultOpen = true, children, className, }) {
|
|
7
|
+
const [open, setOpen] = useState(defaultOpen);
|
|
8
|
+
return (_jsxs("div", { className: cn("rounded-md border border-border bg-accent/50 overflow-hidden", className), children: [_jsxs("button", { type: "button", onClick: () => setOpen(!open), className: "flex w-full items-center gap-2 px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent transition-colors", children: [_jsx(ChevronIcon, { open: open }), status && _jsx(StatusDot, { status: status }), _jsx("span", { className: "truncate min-w-0", children: label })] }), open && _jsx("div", { className: "px-2.5 pb-2", children: children })] }));
|
|
9
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef } from "react";
|
|
3
|
+
import { useChatStore } from "./AgentProvider.js";
|
|
4
|
+
import { cn } from "./cn.js";
|
|
5
|
+
import { PendingPermissions } from "./PendingPermissions.js";
|
|
6
|
+
import { TextMessage } from "./TextMessage.js";
|
|
7
|
+
import { ThinkingBlock } from "./ThinkingBlock.js";
|
|
8
|
+
import { ThinkingIndicator } from "./ThinkingIndicator.js";
|
|
9
|
+
import { ToolCallCard } from "./ToolCallCard.js";
|
|
10
|
+
export function MessageList({ className }) {
|
|
11
|
+
const messages = useChatStore((s) => s.messages);
|
|
12
|
+
const streamingText = useChatStore((s) => s.streamingText);
|
|
13
|
+
const streamingThinking = useChatStore((s) => s.streamingThinking);
|
|
14
|
+
const isThinking = useChatStore((s) => s.isThinking);
|
|
15
|
+
const bottomRef = useRef(null);
|
|
16
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll on content changes
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
19
|
+
}, [messages, streamingText, streamingThinking, isThinking]);
|
|
20
|
+
return (_jsx("div", { className: cn("flex-1 overflow-y-auto", className), children: _jsxs("div", { className: "flex flex-col gap-3 p-4 text-sm", children: [messages.map((msg) => {
|
|
21
|
+
if (msg.toolCalls?.length) {
|
|
22
|
+
return (_jsx("div", { className: "flex flex-col gap-1.5", children: msg.toolCalls.map((tc) => (_jsx(ToolCallCard, { toolCall: tc }, tc.id))) }, msg.id));
|
|
23
|
+
}
|
|
24
|
+
if (msg.role === "system") {
|
|
25
|
+
return (_jsx("div", { className: "text-center text-xs text-destructive", children: msg.content }, msg.id));
|
|
26
|
+
}
|
|
27
|
+
if (msg.content || msg.thinking) {
|
|
28
|
+
return (_jsxs("div", { className: "flex flex-col gap-1.5", children: [msg.thinking && _jsx(ThinkingBlock, { thinking: msg.thinking }), msg.content && (_jsx(TextMessage, { role: msg.role, content: msg.content }))] }, msg.id));
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}), streamingThinking && _jsx(ThinkingBlock, { thinking: streamingThinking, streaming: true }), streamingText && _jsx(TextMessage, { role: "assistant", content: streamingText }), _jsx(PendingPermissions, {}), isThinking && _jsx(ThinkingIndicator, {}), _jsx("div", { ref: bottomRef })] }) }));
|
|
32
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useAgentContext, useChatStore } from "./AgentProvider.js";
|
|
3
|
+
import { isApprovalClaimedByToolCall } from "./approval-matching.js";
|
|
4
|
+
import { cn } from "./cn.js";
|
|
5
|
+
import { ToolApprovalCard } from "./ToolApprovalCard.js";
|
|
6
|
+
import { UserQuestionCard } from "./UserQuestionCard.js";
|
|
7
|
+
export function PendingPermissions({ className }) {
|
|
8
|
+
const pending = useChatStore((s) => s.pendingPermissions);
|
|
9
|
+
const messages = useChatStore((s) => s.messages);
|
|
10
|
+
const { respondToPermission } = useAgentContext();
|
|
11
|
+
// Tool approvals that match a non-terminal tool call are rendered inline
|
|
12
|
+
// by ToolCallCard — skip them here to avoid duplicate UI.
|
|
13
|
+
const unclaimed = pending.filter((request) => request.kind !== "tool_approval" || !isApprovalClaimedByToolCall(request, messages));
|
|
14
|
+
if (!unclaimed.length)
|
|
15
|
+
return null;
|
|
16
|
+
return (_jsx("div", { className: cn("flex flex-col gap-2", className), children: unclaimed.map((request) => {
|
|
17
|
+
if (request.kind === "tool_approval") {
|
|
18
|
+
return (_jsx(ToolApprovalCard, { request: request, onApprove: () => respondToPermission({
|
|
19
|
+
kind: "tool_approval",
|
|
20
|
+
requestId: request.requestId,
|
|
21
|
+
behavior: "allow",
|
|
22
|
+
}), onDeny: (message) => respondToPermission({
|
|
23
|
+
kind: "tool_approval",
|
|
24
|
+
requestId: request.requestId,
|
|
25
|
+
behavior: "deny",
|
|
26
|
+
message: message ?? "Denied by user",
|
|
27
|
+
}) }, request.requestId));
|
|
28
|
+
}
|
|
29
|
+
return (_jsx(UserQuestionCard, { request: request, onSubmit: (answers) => respondToPermission({
|
|
30
|
+
kind: "user_question",
|
|
31
|
+
requestId: request.requestId,
|
|
32
|
+
answers,
|
|
33
|
+
}) }, request.requestId));
|
|
34
|
+
}) }));
|
|
35
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ToolCallPhase } from "../types.js";
|
|
2
|
+
export declare function StatusDot({ status, className }: {
|
|
3
|
+
status: ToolCallPhase;
|
|
4
|
+
className?: string;
|
|
5
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
6
|
+
export declare function PulsingDot({ className }: {
|
|
7
|
+
className?: string;
|
|
8
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { cn } from "./cn.js";
|
|
3
|
+
const phaseClasses = {
|
|
4
|
+
pending: "bg-muted-foreground/40",
|
|
5
|
+
streaming_input: "bg-amber-500 animate-pulse",
|
|
6
|
+
running: "bg-amber-500 animate-pulse",
|
|
7
|
+
complete: "bg-emerald-500",
|
|
8
|
+
error: "bg-destructive",
|
|
9
|
+
};
|
|
10
|
+
export function StatusDot({ status, className }) {
|
|
11
|
+
return _jsx("span", { className: cn("h-2 w-2 shrink-0 rounded-full", phaseClasses[status], className) });
|
|
12
|
+
}
|
|
13
|
+
export function PulsingDot({ className }) {
|
|
14
|
+
return (_jsx("span", { className: cn("inline-block h-2 w-2 shrink-0 rounded-full bg-amber-500 animate-pulse", className) }));
|
|
15
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import Markdown from "react-markdown";
|
|
3
|
+
import { cn } from "./cn.js";
|
|
4
|
+
import { markdownComponents } from "./markdown-overrides.js";
|
|
5
|
+
export function TextMessage({ role, content, className, }) {
|
|
6
|
+
const isUser = role === "user";
|
|
7
|
+
return (_jsx("div", { className: cn("flex", isUser ? "justify-end" : "justify-start", className), children: _jsx("div", { className: cn("max-w-[85%] rounded-lg px-3 py-2 text-sm overflow-hidden break-words", isUser ? "bg-primary text-primary-foreground" : "bg-muted text-foreground"), children: isUser ? (_jsx("span", { className: "whitespace-pre-wrap", children: content })) : (_jsx("div", { className: "[&>*:first-child]:mt-0 [&>*:last-child]:mb-0", children: _jsx(Markdown, { components: markdownComponents, children: content }) })) }) }));
|
|
8
|
+
}
|