@neeter/react 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.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/dist/AgentProvider.d.ts +15 -0
  3. package/dist/AgentProvider.js +30 -0
  4. package/dist/ApprovalButtons.d.ts +5 -0
  5. package/dist/ApprovalButtons.js +30 -0
  6. package/dist/ChatInput.d.ts +6 -0
  7. package/dist/ChatInput.js +41 -0
  8. package/dist/CollapsibleCard.d.ts +9 -0
  9. package/dist/CollapsibleCard.js +9 -0
  10. package/dist/MessageList.d.ts +3 -0
  11. package/dist/MessageList.js +32 -0
  12. package/dist/PendingPermissions.d.ts +3 -0
  13. package/dist/PendingPermissions.js +35 -0
  14. package/dist/StatusDot.d.ts +8 -0
  15. package/dist/StatusDot.js +15 -0
  16. package/dist/TextMessage.d.ts +5 -0
  17. package/dist/TextMessage.js +8 -0
  18. package/dist/ThinkingBlock.d.ts +5 -0
  19. package/dist/ThinkingBlock.js +38 -0
  20. package/dist/ThinkingIndicator.d.ts +3 -0
  21. package/dist/ThinkingIndicator.js +5 -0
  22. package/dist/ToolApprovalCard.d.ts +7 -0
  23. package/dist/ToolApprovalCard.js +11 -0
  24. package/dist/ToolCallCard.d.ts +5 -0
  25. package/dist/ToolCallCard.js +59 -0
  26. package/dist/UserQuestionCard.d.ts +6 -0
  27. package/dist/UserQuestionCard.js +120 -0
  28. package/dist/approval-matching.d.ts +13 -0
  29. package/dist/approval-matching.js +30 -0
  30. package/dist/cn.d.ts +2 -0
  31. package/dist/cn.js +5 -0
  32. package/dist/icons.d.ts +7 -0
  33. package/dist/icons.js +8 -0
  34. package/dist/index.d.ts +29 -0
  35. package/dist/index.js +28 -0
  36. package/dist/markdown-overrides.d.ts +2 -0
  37. package/dist/markdown-overrides.js +8 -0
  38. package/dist/registry.d.ts +4 -0
  39. package/dist/registry.js +10 -0
  40. package/dist/store.d.ts +34 -0
  41. package/dist/store.js +141 -0
  42. package/dist/types.d.ts +19 -0
  43. package/dist/types.js +1 -0
  44. package/dist/use-agent.d.ts +12 -0
  45. package/dist/use-agent.js +119 -0
  46. package/dist/widgets/AskUserQuestionWidget.d.ts +1 -0
  47. package/dist/widgets/AskUserQuestionWidget.js +42 -0
  48. package/dist/widgets/BashWidget.d.ts +1 -0
  49. package/dist/widgets/BashWidget.js +33 -0
  50. package/dist/widgets/EditWidget.d.ts +1 -0
  51. package/dist/widgets/EditWidget.js +36 -0
  52. package/dist/widgets/GlobWidget.d.ts +1 -0
  53. package/dist/widgets/GlobWidget.js +31 -0
  54. package/dist/widgets/GrepWidget.d.ts +1 -0
  55. package/dist/widgets/GrepWidget.js +36 -0
  56. package/dist/widgets/NotebookEditWidget.d.ts +1 -0
  57. package/dist/widgets/NotebookEditWidget.js +47 -0
  58. package/dist/widgets/ReadWidget.d.ts +1 -0
  59. package/dist/widgets/ReadWidget.js +46 -0
  60. package/dist/widgets/TodoWriteWidget.d.ts +1 -0
  61. package/dist/widgets/TodoWriteWidget.js +40 -0
  62. package/dist/widgets/WebFetchWidget.d.ts +1 -0
  63. package/dist/widgets/WebFetchWidget.js +48 -0
  64. package/dist/widgets/WebSearchWidget.d.ts +1 -0
  65. package/dist/widgets/WebSearchWidget.js +85 -0
  66. package/dist/widgets/WriteWidget.d.ts +1 -0
  67. package/dist/widgets/WriteWidget.js +30 -0
  68. package/package.json +45 -0
  69. 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.
@@ -0,0 +1,15 @@
1
+ import type { CustomEvent } from "@neeter/types";
2
+ import { type ReactNode } from "react";
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,5 @@
1
+ export declare function ApprovalButtons({ onApprove, onDeny, className, }: {
2
+ onApprove: () => void;
3
+ onDeny: () => void;
4
+ className?: string;
5
+ }): import("react/jsx-runtime").JSX.Element;
@@ -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,6 @@
1
+ export declare function ChatInput({ onSend, placeholder, disabled, className, }: {
2
+ onSend: (text: string) => void;
3
+ placeholder?: string;
4
+ disabled?: boolean;
5
+ className?: string;
6
+ }): import("react/jsx-runtime").JSX.Element;
@@ -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 { ToolCallPhase } from "@neeter/types";
2
+ import { type ReactNode } from "react";
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,3 @@
1
+ export declare function MessageList({ className }: {
2
+ className?: string;
3
+ }): import("react/jsx-runtime").JSX.Element;
@@ -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,3 @@
1
+ export declare function PendingPermissions({ className }: {
2
+ className?: string;
3
+ }): import("react/jsx-runtime").JSX.Element | null;
@@ -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 "@neeter/types";
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,5 @@
1
+ export declare function TextMessage({ role, content, className, }: {
2
+ role: "user" | "assistant";
3
+ content: string;
4
+ className?: string;
5
+ }): import("react/jsx-runtime").JSX.Element;
@@ -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
+ }
@@ -0,0 +1,5 @@
1
+ export declare function ThinkingBlock({ thinking, streaming, className, }: {
2
+ thinking: string;
3
+ streaming?: boolean;
4
+ className?: string;
5
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,38 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useState } from "react";
3
+ import Markdown from "react-markdown";
4
+ import { cn } from "./cn.js";
5
+ import { ChevronIcon } from "./icons.js";
6
+ import { markdownComponents } from "./markdown-overrides.js";
7
+ import { PulsingDot } from "./StatusDot.js";
8
+ const STORAGE_KEY = "neeter-thinking-open";
9
+ function readPref() {
10
+ try {
11
+ return localStorage.getItem(STORAGE_KEY) === "true";
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }
17
+ function writePref(open) {
18
+ try {
19
+ localStorage.setItem(STORAGE_KEY, String(open));
20
+ }
21
+ catch {
22
+ /* SSR / restricted env */
23
+ }
24
+ }
25
+ export function ThinkingBlock({ thinking, streaming = false, className, }) {
26
+ const [open, setOpen] = useState(readPref);
27
+ const toggle = useCallback(() => {
28
+ setOpen((prev) => {
29
+ const next = !prev;
30
+ writePref(next);
31
+ return next;
32
+ });
33
+ }, []);
34
+ return (_jsx("div", { className: cn("flex justify-start", className), children: _jsxs("div", { className: "rounded-md border border-border/40 bg-accent/20 overflow-hidden", children: [_jsxs("button", { type: "button", onClick: toggle, className: "flex w-full items-center gap-2 px-2.5 py-1.5 text-xs text-muted-foreground/60 hover:bg-accent/30 transition-colors", children: [_jsx(ChevronIcon, { open: open }), streaming && !open && _jsx(PulsingDot, {}), _jsx("span", { className: "truncate min-w-0", children: "Thinking" })] }), open && (_jsx("div", { className: "px-2.5 pt-1 pb-2", children: _jsx(ThinkingContent, { children: thinking }) }))] }) }));
35
+ }
36
+ function ThinkingContent({ children }) {
37
+ return (_jsx("div", { className: "text-xs text-muted-foreground/70 italic [&>*:first-child]:mt-0 [&>*:last-child]:mb-0", children: _jsx(Markdown, { components: markdownComponents, children: children }) }));
38
+ }
@@ -0,0 +1,3 @@
1
+ export declare function ThinkingIndicator({ className }: {
2
+ className?: string;
3
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,5 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cn } from "./cn.js";
3
+ export function ThinkingIndicator({ className }) {
4
+ return (_jsx("div", { className: cn("flex justify-start", className), children: _jsxs("div", { className: "flex items-center gap-1 rounded-lg bg-muted px-3 py-2", children: [_jsx("span", { className: "h-1.5 w-1.5 rounded-full bg-muted-foreground animate-bounce [animation-delay:0ms]" }), _jsx("span", { className: "h-1.5 w-1.5 rounded-full bg-muted-foreground animate-bounce [animation-delay:150ms]" }), _jsx("span", { className: "h-1.5 w-1.5 rounded-full bg-muted-foreground animate-bounce [animation-delay:300ms]" })] }) }));
5
+ }
@@ -0,0 +1,7 @@
1
+ import type { ToolApprovalRequest } from "@neeter/types";
2
+ export declare function ToolApprovalCard({ request, onApprove, onDeny, className, }: {
3
+ request: ToolApprovalRequest;
4
+ onApprove: () => void;
5
+ onDeny: (message?: string) => void;
6
+ className?: string;
7
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,11 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ApprovalButtons } from "./ApprovalButtons.js";
3
+ import { cn } from "./cn.js";
4
+ import { getWidget, stripMcpPrefix } from "./registry.js";
5
+ import { PulsingDot } from "./StatusDot.js";
6
+ export function ToolApprovalCard({ request, onApprove, onDeny, className, }) {
7
+ const reg = getWidget(stripMcpPrefix(request.toolName));
8
+ const label = reg?.label ?? request.toolName;
9
+ const InputRenderer = reg?.inputRenderer;
10
+ return (_jsxs("div", { className: cn("rounded-md border border-amber-500/40 bg-amber-500/5 px-3 py-2.5 text-xs", className), children: [_jsxs("div", { className: "flex items-center gap-2 text-muted-foreground", children: [_jsx(PulsingDot, {}), _jsx("span", { className: "font-medium", children: label }), request.description && (_jsxs("span", { className: "ml-1 opacity-70", children: ["\u2014 ", request.description] }))] }), InputRenderer ? (_jsx(InputRenderer, { input: request.input })) : (Object.keys(request.input).length > 0 && (_jsx("pre", { className: "mt-1.5 max-h-32 overflow-auto rounded bg-background/60 px-2 py-1 font-mono text-[10px] text-muted-foreground", children: JSON.stringify(request.input, null, 2) }))), _jsx(ApprovalButtons, { onApprove: onApprove, onDeny: () => onDeny() })] }));
11
+ }
@@ -0,0 +1,5 @@
1
+ import type { ToolCallInfo } from "@neeter/types";
2
+ export declare function ToolCallCard({ toolCall, className, }: {
3
+ toolCall: ToolCallInfo;
4
+ className?: string;
5
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,59 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useAgentContext, useChatStore } from "./AgentProvider.js";
3
+ import { ApprovalButtons } from "./ApprovalButtons.js";
4
+ import { findMatchingApproval } from "./approval-matching.js";
5
+ import { CollapsibleCard } from "./CollapsibleCard.js";
6
+ import { cn } from "./cn.js";
7
+ import { getWidget, stripMcpPrefix } from "./registry.js";
8
+ import { PulsingDot, StatusDot } from "./StatusDot.js";
9
+ export function ToolCallCard({ toolCall, className, }) {
10
+ const short = stripMcpPrefix(toolCall.name);
11
+ const reg = getWidget(short);
12
+ const label = reg?.label ?? short;
13
+ const pendingPermissions = useChatStore((s) => s.pendingPermissions);
14
+ const { respondToPermission, store } = useAgentContext();
15
+ const matchingApproval = findMatchingApproval(pendingPermissions, toolCall);
16
+ if (toolCall.status === "complete" && toolCall.result && reg) {
17
+ let parsed;
18
+ try {
19
+ parsed = JSON.parse(toolCall.result);
20
+ }
21
+ catch {
22
+ parsed = toolCall.result;
23
+ }
24
+ const displayLabel = reg.richLabel
25
+ ? (reg.richLabel(parsed, toolCall.input) ?? label)
26
+ : label;
27
+ const widgetProps = {
28
+ phase: toolCall.status,
29
+ toolUseId: toolCall.id,
30
+ input: toolCall.input,
31
+ partialInput: toolCall.partialInput,
32
+ result: parsed,
33
+ error: toolCall.error,
34
+ };
35
+ return (_jsx(CollapsibleCard, { label: displayLabel, status: toolCall.status, className: className, children: _jsx(reg.component, { ...widgetProps }) }));
36
+ }
37
+ if (toolCall.status === "error") {
38
+ return (_jsxs("div", { className: cn("flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 px-2.5 py-1.5 text-xs text-muted-foreground", className), children: [_jsx(StatusDot, { status: toolCall.status }), _jsx("span", { children: label }), toolCall.error && _jsx("span", { className: "ml-auto text-destructive", children: toolCall.error })] }));
39
+ }
40
+ if (matchingApproval) {
41
+ const InputRenderer = reg?.inputRenderer;
42
+ return (_jsxs("div", { className: cn("rounded-md border border-amber-500/40 bg-amber-500/5 px-3 py-2.5 text-xs", className), children: [_jsxs("div", { className: "flex items-center gap-2 text-muted-foreground", children: [_jsx(PulsingDot, {}), _jsx("span", { className: "font-medium", children: label }), matchingApproval.description && (_jsxs("span", { className: "ml-1 opacity-70", children: ["\u2014 ", matchingApproval.description] }))] }), InputRenderer ? (_jsx(InputRenderer, { input: matchingApproval.input })) : (Object.keys(matchingApproval.input).length > 0 && (_jsx("pre", { className: "mt-1.5 max-h-32 overflow-auto rounded bg-background/60 px-2 py-1 font-mono text-[10px] text-muted-foreground", children: JSON.stringify(matchingApproval.input, null, 2) }))), _jsx(ApprovalButtons, { onApprove: () => respondToPermission({
43
+ kind: "tool_approval",
44
+ requestId: matchingApproval.requestId,
45
+ behavior: "allow",
46
+ }), onDeny: () => {
47
+ store.getState().errorToolCall(toolCall.id, "Not approved");
48
+ respondToPermission({
49
+ kind: "tool_approval",
50
+ requestId: matchingApproval.requestId,
51
+ behavior: "deny",
52
+ message: "Denied by user",
53
+ });
54
+ } })] }));
55
+ }
56
+ const FallbackInputRenderer = reg?.inputRenderer;
57
+ const hasInput = Object.keys(toolCall.input).length > 0;
58
+ return (_jsxs("div", { className: cn("rounded-md border border-border bg-accent/50 px-2.5 py-1.5 text-xs text-muted-foreground", className), children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(StatusDot, { status: toolCall.status }), _jsx("span", { children: label }), toolCall.status === "streaming_input" && toolCall.partialInput && (_jsx("span", { className: "ml-auto truncate max-w-[200px] opacity-50 font-mono text-[10px]", children: toolCall.partialInput.slice(0, 80) }))] }), hasInput && FallbackInputRenderer && _jsx(FallbackInputRenderer, { input: toolCall.input })] }));
59
+ }
@@ -0,0 +1,6 @@
1
+ import type { UserQuestionRequest } from "@neeter/types";
2
+ export declare function UserQuestionCard({ request, onSubmit, className, }: {
3
+ request: UserQuestionRequest;
4
+ onSubmit: (answers: Record<string, string>) => void;
5
+ className?: string;
6
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,120 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { cn } from "./cn.js";
4
+ const OTHER_LABEL = "__other__";
5
+ export function UserQuestionCard({ request, onSubmit, className, }) {
6
+ const wrapperRef = useRef(null);
7
+ const [answers, setAnswers] = useState({});
8
+ const [otherText, setOtherText] = useState({});
9
+ const otherInputRefs = useRef({});
10
+ // Auto-focus wrapper so number-key shortcuts work immediately
11
+ useEffect(() => {
12
+ wrapperRef.current?.focus();
13
+ }, []);
14
+ const allAnswered = request.questions.every((q) => {
15
+ const vals = answers[q.question];
16
+ if (!vals || vals.length === 0)
17
+ return false;
18
+ if (vals.includes(OTHER_LABEL))
19
+ return !!otherText[q.question]?.trim();
20
+ return true;
21
+ });
22
+ function resolvedAnswers() {
23
+ const resolved = {};
24
+ for (const q of request.questions) {
25
+ const vals = answers[q.question] ?? [];
26
+ const mapped = vals.map((v) => (v === OTHER_LABEL ? (otherText[q.question] ?? "") : v));
27
+ resolved[q.question] = mapped.join(", ");
28
+ }
29
+ return resolved;
30
+ }
31
+ function selectOption(question, label, multiSelect) {
32
+ setAnswers((prev) => {
33
+ if (!multiSelect)
34
+ return { ...prev, [question]: [label] };
35
+ const current = prev[question] ?? [];
36
+ return {
37
+ ...prev,
38
+ [question]: current.includes(label)
39
+ ? current.filter((l) => l !== label)
40
+ : [...current, label],
41
+ };
42
+ });
43
+ if (label === OTHER_LABEL) {
44
+ setTimeout(() => otherInputRefs.current[question]?.focus(), 0);
45
+ }
46
+ }
47
+ // Keyboard shortcuts: number keys select options, Enter submits
48
+ useEffect(() => {
49
+ function handleKeyDown(e) {
50
+ const tag = e.target?.tagName;
51
+ if (tag === "INPUT" || tag === "TEXTAREA" || e.target?.isContentEditable) {
52
+ return;
53
+ }
54
+ if (request.questions.length !== 1)
55
+ return;
56
+ const q = request.questions[0];
57
+ if (!q.options?.length)
58
+ return;
59
+ if (e.key === "Enter" && allAnswered) {
60
+ e.preventDefault();
61
+ onSubmit(resolvedAnswers());
62
+ return;
63
+ }
64
+ const totalOptions = q.options.length + 1; // +1 for "Other"
65
+ const num = parseInt(e.key, 10);
66
+ if (num >= 1 && num <= totalOptions) {
67
+ e.preventDefault();
68
+ if (num <= q.options.length) {
69
+ if (!q.multiSelect) {
70
+ // Single-select: submit immediately (matches ApprovalButtons)
71
+ onSubmit({ [q.question]: q.options[num - 1].label });
72
+ }
73
+ else {
74
+ selectOption(q.question, q.options[num - 1].label, true);
75
+ }
76
+ }
77
+ else {
78
+ selectOption(q.question, OTHER_LABEL, q.multiSelect);
79
+ }
80
+ }
81
+ }
82
+ document.addEventListener("keydown", handleKeyDown);
83
+ return () => document.removeEventListener("keydown", handleKeyDown);
84
+ });
85
+ return (_jsxs("div", { ref: wrapperRef, tabIndex: -1, className: cn("rounded-md border border-blue-500/40 bg-blue-500/5 px-3 py-2.5 text-xs focus:outline-none", className), children: [request.questions.map((q) => {
86
+ const hasOptions = q.options && q.options.length > 0;
87
+ const isSingleQuestion = request.questions.length === 1;
88
+ return (_jsxs("div", { className: "mb-3 last:mb-0", children: [q.header && (_jsx("span", { className: "text-[10px] uppercase tracking-wider text-muted-foreground/70", children: q.header })), _jsx("p", { className: "mt-0.5 text-foreground", children: q.question }), hasOptions && (_jsxs("div", { className: "mt-1.5 flex flex-col gap-1", children: [q.options?.map((opt, idx) => {
89
+ const selected = (answers[q.question] ?? []).includes(opt.label);
90
+ return (_jsxs("button", { type: "button", onClick: () => {
91
+ if (!q.multiSelect && isSingleQuestion) {
92
+ onSubmit({ [q.question]: opt.label });
93
+ }
94
+ else {
95
+ selectOption(q.question, opt.label, q.multiSelect);
96
+ }
97
+ }, className: cn("flex items-start gap-2 rounded border px-2 py-1.5 text-left transition-colors", selected
98
+ ? "border-blue-500 bg-blue-500/15 text-foreground ring-1 ring-blue-500/30"
99
+ : "border-border text-muted-foreground hover:bg-accent"), children: [_jsx("span", { className: "mt-px shrink-0", children: q.multiSelect ? (selected ? "☑" : "☐") : selected ? "●" : "○" }), _jsxs("span", { className: "flex-1", children: [_jsx("span", { className: "font-medium", children: opt.label }), opt.description && (_jsxs("span", { className: "ml-1 opacity-70", children: ["\u2014 ", opt.description] }))] }), isSingleQuestion && (_jsx("kbd", { className: "ml-auto shrink-0 text-[9px] opacity-40", children: idx + 1 }))] }, opt.label));
100
+ }), (() => {
101
+ const isOther = (answers[q.question] ?? []).includes(OTHER_LABEL);
102
+ const otherIdx = (q.options?.length ?? 0) + 1;
103
+ return (_jsxs("div", { children: [_jsxs("button", { type: "button", onClick: () => selectOption(q.question, OTHER_LABEL, q.multiSelect), className: cn("flex w-full items-start gap-2 rounded border px-2 py-1.5 text-left transition-colors", isOther
104
+ ? "border-blue-500 bg-blue-500/15 text-foreground ring-1 ring-blue-500/30"
105
+ : "border-border text-muted-foreground hover:bg-accent"), children: [_jsx("span", { className: "mt-px shrink-0", children: q.multiSelect ? (isOther ? "☑" : "☐") : isOther ? "●" : "○" }), _jsx("span", { className: "font-medium", children: "Other" }), isSingleQuestion && (_jsx("kbd", { className: "ml-auto shrink-0 text-[9px] opacity-40", children: otherIdx }))] }), isOther && (_jsx("input", { ref: (el) => {
106
+ otherInputRefs.current[q.question] = el;
107
+ }, type: "text", className: "mt-1 w-full rounded border border-border bg-background px-2 py-1 text-foreground focus:outline-none focus:ring-1 focus:ring-blue-500/50", placeholder: "Type your answer\u2026", value: otherText[q.question] ?? "", onChange: (e) => setOtherText((prev) => ({ ...prev, [q.question]: e.target.value })), onKeyDown: (e) => {
108
+ if (e.key === "Enter" && otherText[q.question]?.trim()) {
109
+ e.preventDefault();
110
+ onSubmit(resolvedAnswers());
111
+ }
112
+ } }))] }));
113
+ })()] })), !hasOptions && (_jsx("input", { type: "text", className: "mt-1.5 w-full rounded border border-border bg-background px-2 py-1 text-foreground focus:outline-none focus:ring-1 focus:ring-blue-500/50", placeholder: "Type your answer\u2026", value: (answers[q.question] ?? [])[0] ?? "", onChange: (e) => setAnswers((prev) => ({ ...prev, [q.question]: [e.target.value] })), onKeyDown: (e) => {
114
+ if (e.key === "Enter" && (answers[q.question]?.[0] ?? "").trim()) {
115
+ e.preventDefault();
116
+ onSubmit(resolvedAnswers());
117
+ }
118
+ } }))] }, q.question));
119
+ }), _jsx("button", { type: "button", disabled: !allAnswered, onClick: () => onSubmit(resolvedAnswers()), className: cn("mt-2 rounded bg-primary px-3 py-1 text-primary-foreground transition-colors focus:outline-none focus:ring-2 focus:ring-primary/50", allAnswered ? "hover:bg-primary/90" : "opacity-50 cursor-not-allowed"), children: "Submit" })] }));
120
+ }
@@ -0,0 +1,13 @@
1
+ import type { ChatMessage, PermissionRequest, ToolApprovalRequest, ToolCallInfo } from "@neeter/types";
2
+ /**
3
+ * Find the pending approval that matches a specific tool call.
4
+ * Matches by toolUseId when available, falls back to toolName.
5
+ * Only considers non-terminal tool calls (not complete/error).
6
+ */
7
+ export declare function findMatchingApproval(pendingPermissions: PermissionRequest[], toolCall: ToolCallInfo): ToolApprovalRequest | undefined;
8
+ /**
9
+ * Check if an approval request is "claimed" by any non-terminal tool call
10
+ * in the message list. Claimed approvals are rendered inline by ToolCallCard
11
+ * and should be skipped by PendingPermissions to avoid duplicate UI.
12
+ */
13
+ export declare function isApprovalClaimedByToolCall(request: ToolApprovalRequest, messages: ChatMessage[]): boolean;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Find the pending approval that matches a specific tool call.
3
+ * Matches by toolUseId when available, falls back to toolName.
4
+ * Only considers non-terminal tool calls (not complete/error).
5
+ */
6
+ export function findMatchingApproval(pendingPermissions, toolCall) {
7
+ if (toolCall.status === "complete" || toolCall.status === "error")
8
+ return undefined;
9
+ return pendingPermissions.find((p) => p.kind === "tool_approval" &&
10
+ (p.toolUseId ? p.toolUseId === toolCall.id : p.toolName === toolCall.name));
11
+ }
12
+ /**
13
+ * Check if an approval request is "claimed" by any non-terminal tool call
14
+ * in the message list. Claimed approvals are rendered inline by ToolCallCard
15
+ * and should be skipped by PendingPermissions to avoid duplicate UI.
16
+ */
17
+ export function isApprovalClaimedByToolCall(request, messages) {
18
+ for (const msg of messages) {
19
+ if (msg.toolCalls) {
20
+ for (const tc of msg.toolCalls) {
21
+ if (tc.status === "complete" || tc.status === "error")
22
+ continue;
23
+ if (request.toolUseId ? tc.id === request.toolUseId : tc.name === request.toolName) {
24
+ return true;
25
+ }
26
+ }
27
+ }
28
+ }
29
+ return false;
30
+ }