devnonla-ui 0.1.0 → 0.2.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 (67) hide show
  1. package/README.md +22 -10
  2. package/package.json +2 -2
  3. package/src/alert/Alert.tsx +1 -1
  4. package/src/app/App.tsx +38 -18
  5. package/src/app/context.ts +32 -0
  6. package/src/button/Button.tsx +2 -2
  7. package/src/calendar/Calendar.tsx +4 -4
  8. package/src/chat/AgentPanel.tsx +15 -6
  9. package/src/chat/common/types.ts +27 -2
  10. package/src/chat/common/useAgentStream.ts +78 -12
  11. package/src/chat/common/utils.ts +13 -0
  12. package/src/chat/index.ts +5 -3
  13. package/src/chat/message-ui/ChatAgentMessage.tsx +1 -1
  14. package/src/chat/message-ui/ChatInput.tsx +1 -1
  15. package/src/chat/message-ui/ChatMarkdown.tsx +6 -6
  16. package/src/chat/message-ui/ChatMarkdownTable.tsx +1 -1
  17. package/src/chat/message-ui/ChatThinking.tsx +4 -3
  18. package/src/chat/message-ui/ChatUserMessage.tsx +0 -8
  19. package/src/chat/message-ui/MermaidBlock.tsx +11 -1
  20. package/src/chat/tool-ui/BackgroundTaskToolUI.tsx +2 -1
  21. package/src/chat/tool-ui/CallAgentToolUI.tsx +3 -2
  22. package/src/chat/tool-ui/ChatToolCall.tsx +3 -2
  23. package/src/chat/tool-ui/GetCurrentTimeToolUI.tsx +2 -1
  24. package/src/chat/tool-ui/ReadSkillToolUI.tsx +3 -2
  25. package/src/chat/tool-ui/RunJsToolUI.tsx +3 -2
  26. package/src/chat/tool-ui/WebFetchToolUI.tsx +3 -2
  27. package/src/chat/tool-ui/registry.ts +24 -12
  28. package/src/checkbox/Checkbox.tsx +5 -4
  29. package/src/codeblock/CodeBlock.tsx +1 -0
  30. package/src/datepicker/DatePicker.tsx +15 -9
  31. package/src/desktop/DesktopHeader.tsx +1 -1
  32. package/src/desktop/DesktopIcon.tsx +2 -4
  33. package/src/desktop/DesktopWindow.tsx +19 -5
  34. package/src/desktop/MeadowDesktop.tsx +2 -2
  35. package/src/drawer/Drawer.tsx +8 -6
  36. package/src/dropdown/ContextMenu.tsx +3 -1
  37. package/src/dropdown/Dropdown.tsx +8 -6
  38. package/src/form/Form.tsx +2 -2
  39. package/src/form/FormItem.tsx +2 -2
  40. package/src/form/partials/FormItemHelps.tsx +2 -2
  41. package/src/form/variants/FieldColor.tsx +1 -1
  42. package/src/form/variants/FieldObject.tsx +2 -2
  43. package/src/form/variants/FieldRepeaterItem.tsx +4 -4
  44. package/src/form-layout/FormLayout.tsx +6 -6
  45. package/src/index.ts +20 -6
  46. package/src/input/Input.tsx +11 -10
  47. package/src/lib/sizes.ts +13 -3
  48. package/src/lib/surface.ts +2 -1
  49. package/src/menu/Menu.tsx +2 -2
  50. package/src/message/message.tsx +35 -2
  51. package/src/modal/Modal.tsx +49 -3
  52. package/src/pagination/Pagination.tsx +6 -5
  53. package/src/popconfirm/Popconfirm.tsx +1 -1
  54. package/src/popover/Popover.tsx +6 -8
  55. package/src/scroll/OverlayScroll.tsx +5 -3
  56. package/src/segmented/Segmented.tsx +6 -5
  57. package/src/select/Select.tsx +9 -6
  58. package/src/shimmer/Shimmer.tsx +17 -0
  59. package/src/skeleton/Skeleton.tsx +3 -3
  60. package/src/spin/Spin.tsx +8 -5
  61. package/src/styles.css +81 -27
  62. package/src/switch/Switch.tsx +6 -5
  63. package/src/table/Table.tsx +6 -7
  64. package/src/tag/Tag.tsx +2 -2
  65. package/src/theme.ts +23 -3
  66. package/src/timepicker/TimePicker.tsx +13 -11
  67. package/src/tooltip/Tooltip.tsx +6 -7
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # devnonla-ui (NonlaUI)
2
2
 
3
- Ant Design–like React controls for Nonla Agents.
3
+ React controls for Nonla Agents.
4
4
 
5
5
  ```bash
6
6
  bun add devnonla-ui
@@ -10,7 +10,7 @@ Peer deps: `react` / `react-dom` >= 19, `react-hook-form`. Consumer should use T
10
10
 
11
11
  ```tsx
12
12
  import "devnonla-ui/styles.css";
13
- import { App, Button, EFormItemType, Form, Modal, message, type TFormItemProps } from "devnonla-ui";
13
+ import { App, Button, EFormItemType, SchemaForm, Modal, message, type TFormItemProps } from "devnonla-ui";
14
14
  import { useForm } from "react-hook-form";
15
15
 
16
16
  message.success("Saved");
@@ -31,7 +31,7 @@ function Example() {
31
31
  return (
32
32
  <App>
33
33
  <form onSubmit={form.handleSubmit(console.log)}>
34
- <Form form={form} items={items} />
34
+ <SchemaForm form={form} items={items} />
35
35
  <Button type="primary" htmlType="submit">
36
36
  Save
37
37
  </Button>
@@ -41,7 +41,7 @@ function Example() {
41
41
  }
42
42
  ```
43
43
 
44
- `Form` is JSON-schema driven (ZoForm-style) on `react-hook-form`. The BE can return `TFormItemProps[]`; FE hydrates `rules.pattern` strings to `RegExp`. Pass `fetcher` for `select_remote` fields.
44
+ `SchemaForm` is JSON-schema driven (ZoForm-style) on `react-hook-form`. The BE can return `TFormItemProps[]`; FE hydrates `rules.pattern` strings to `RegExp`. Pass `fetcher` for `select_remote` fields. Layout-only labeled fields use `Form` / `Form.Item`.
45
45
 
46
46
  # Sizes
47
47
 
@@ -67,7 +67,7 @@ All color lives in **`--nonla-*` knobs** (`src/styles.css`). Components never ha
67
67
  }
68
68
  ```
69
69
 
70
- `--brand-50` … `--brand-800` follow `--nonla-brand` (500 = the knob). Text on cream uses `brand-700`.
70
+ `--brand-50` … `--brand-800` follow `--nonla-brand` (500 = the knob). Text on cream uses `brand-700`. Text **on** the brand fill (Button primary, Tag solid, Checkbox) is `--nonla-solid-fg` / `colors.solidFg` — default white.
71
71
 
72
72
  Or at runtime:
73
73
 
@@ -83,13 +83,21 @@ Or at runtime:
83
83
 
84
84
  Flat knobs still work (`brand`, `colorBorder`, `colorBorderInput`, …). `applyNonlaTheme({ brand: "#3b82f6" })` does the same on `:root`.
85
85
 
86
- Core knobs: `--nonla-bg`, `--nonla-fg`, `--nonla-brand`, `--nonla-danger`, `--nonla-success`, `--nonla-warn`, `--nonla-link`, `--nonla-radius` (small = −2px, large = +2px), `--nonla-height` / `--nonla-height-sm` / `--nonla-height-lg`, plus surfaces (`--nonla-surface`, `--nonla-chip`, …) and preset accents (`--nonla-blue`, `--nonla-purple`, …).
86
+ Core knobs: `--nonla-bg`, `--nonla-fg`, `--nonla-brand`, `--nonla-solid-fg`, `--nonla-danger`, `--nonla-success`, `--nonla-warn`, `--nonla-link`, `--nonla-radius` (small = −2px, large = +2px), `--nonla-height` / `--nonla-height-sm` / `--nonla-height-lg`, plus surfaces (`--nonla-surface`, `--nonla-chip`, …) and preset accents (`--nonla-blue`, `--nonla-purple`, …).
87
87
 
88
88
  Those map into shadcn-standard tokens (`--background`, `--destructive`, …) plus Nonla aliases (`--brand`, `--success`, …).
89
89
 
90
90
  - You: only touch `--nonla-*` (CSS or `theme` / `applyNonlaTheme`).
91
91
  - Shadcn consumers: can still override `--background` / `--primary` / etc.
92
- - Nonla CTA = `--brand` (from `--nonla-brand`). `--primary` = ink (emphasis), not the amber button.
92
+ - Nonla CTA = `--brand` (from `--nonla-brand`). Label on that fill = `--nonla-solid-fg`. `--primary` = ink (emphasis), not the brand button.
93
+
94
+ `<App>` is the ConfigProvider: `theme`, `componentSize`, `getPopupContainer`. Overlay portals (Select, Dropdown, DatePicker, Modal, Drawer, …) read `getPopupContainer`. `message.*` and `Modal.confirm` render into holders under `App`. `useToken()` / `getDesignToken()` read the live seed knobs.
95
+
96
+ ```tsx
97
+ <App componentSize="large" getPopupContainer={() => document.getElementById("app")!}>
98
+
99
+ </App>
100
+ ```
93
101
 
94
102
  # Desktop
95
103
 
@@ -119,13 +127,17 @@ import { AgentPanel } from "devnonla-ui";
119
127
  <AgentPanel
120
128
  endpoint="/api/agents/abc/assistant/stream"
121
129
  title="Nova"
122
- extraBody={{ providerId, modelId: model }}
130
+ toolbar={<ModelPicker />}
131
+ toolUis={[{ name: "get_calendar_events", component: CalendarToolUI }]}
132
+ toolHooks={[
133
+ { name: "web_fetch", onCall: (e) => console.log("fetch", e.input), onResult: (e) => console.log("done", e.output) },
134
+ ]}
123
135
  />
124
136
  ```
125
137
 
126
- `endpoint` can also be a function that returns a `Response` (tests, mocks).
138
+ `endpoint` can also be a function that returns a `Response` (tests, mocks). Pass any node to `toolbar` for the composer (model picker, tools, …). Extra tool cards go in `toolUis` and win over builtins on the same `toolName`. `toolHooks` fire `onCall` / `onResult` for matching tools (omit `name` to hear every tool). `onToolAction` still catches all events.
127
139
 
128
- POST body: `{ messages, ...extraBody }`. Stream events:
140
+ POST body: `{ messages }` plus optional `extraBody`. Stream events:
129
141
 
130
142
  `text-delta` | `thinking-delta` | `tool-call` | `tool-result` | `done` | `error`
131
143
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "devnonla-ui",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
- "description": "NonlaUI — antd-like React controls for Nonla Agents",
5
+ "description": "NonlaUI — React controls for Nonla Agents",
6
6
  "author": "devlangla <devlangla0x01@gmail.com>",
7
7
  "license": "MIT",
8
8
  "repository": {
@@ -5,7 +5,7 @@ export type AlertType = "success" | "info" | "warning" | "error";
5
5
 
6
6
  export type AlertProps = {
7
7
  type?: AlertType;
8
- /** Primary text — antd `message` / `title`. */
8
+ /** Primary text — `message` / `title`. */
9
9
  message?: ReactNode;
10
10
  title?: ReactNode;
11
11
  description?: ReactNode;
package/src/app/App.tsx CHANGED
@@ -1,36 +1,56 @@
1
- import { type ReactNode, createContext, useContext, useLayoutEffect, useMemo } from "react";
1
+ import { type ReactNode, useLayoutEffect, useMemo, useState } from "react";
2
2
  import * as TooltipPrimitive from "@radix-ui/react-tooltip";
3
+ import { message, MessageHolder } from "../message/message";
4
+ import { ConfirmHolder, Modal } from "../modal/Modal";
5
+ import { ControlSizeContext, normalizeSize, type ControlSize } from "../lib/sizes";
3
6
  import { applyNonlaTheme, type NonlaThemeConfig } from "../theme";
7
+ import { AppContext, useAppConfig, usePopupContainer, useToken, type NonlaAppConfig } from "./context";
4
8
 
5
- export type NonlaAppConfig = {
6
- getPopupContainer?: () => HTMLElement;
7
- };
8
-
9
- const AppContext = createContext<NonlaAppConfig>({});
10
-
11
- export function useAppConfig() {
12
- return useContext(AppContext);
13
- }
9
+ export type { NonlaAppConfig };
10
+ export { useAppConfig, usePopupContainer, useToken };
14
11
 
15
12
  export type AppProps = {
16
13
  children: ReactNode;
17
14
  getPopupContainer?: () => HTMLElement;
18
- /** Override `--nonla-*` knobs on `:root`. Prefer CSS in the host app when possible. */
15
+ /** Default control size (`componentSize`). Per-control `size` still wins. */
16
+ componentSize?: ControlSize;
17
+ /** Override `--nonla-*` knobs on `:root` (portals inherit). Prefer CSS in the host when possible. */
19
18
  theme?: NonlaThemeConfig;
20
- message?: unknown;
21
- modal?: unknown;
22
19
  };
23
20
 
24
- /** Root host for NonlaUI (tooltip provider + theme). Mount once near app root. */
25
- export function App({ children, getPopupContainer, theme }: AppProps) {
26
- const value = useMemo<NonlaAppConfig>(() => ({ getPopupContainer }), [getPopupContainer]);
21
+ /** Root host for NonlaUI ConfigProvider + message/modal holders. Mount once near app root. */
22
+ export function App({ children, getPopupContainer, componentSize, theme }: AppProps) {
23
+ const [themeRev, setThemeRev] = useState(0);
24
+ const size = componentSize ? normalizeSize(componentSize) : undefined;
25
+ const value = useMemo<NonlaAppConfig>(
26
+ () => ({ getPopupContainer, componentSize: size, themeRev }),
27
+ [getPopupContainer, size, themeRev],
28
+ );
29
+
27
30
  useLayoutEffect(() => {
28
31
  if (!theme) return;
29
- return applyNonlaTheme(theme);
32
+ const restore = applyNonlaTheme(theme);
33
+ setThemeRev((n) => n + 1);
34
+ return () => {
35
+ restore();
36
+ setThemeRev((n) => n + 1);
37
+ };
30
38
  }, [theme]);
39
+
31
40
  return (
32
41
  <AppContext.Provider value={value}>
33
- <TooltipPrimitive.Provider delayDuration={100}>{children}</TooltipPrimitive.Provider>
42
+ <ControlSizeContext.Provider value={size}>
43
+ <TooltipPrimitive.Provider delayDuration={100}>
44
+ {children}
45
+ <MessageHolder />
46
+ <ConfirmHolder />
47
+ </TooltipPrimitive.Provider>
48
+ </ControlSizeContext.Provider>
34
49
  </AppContext.Provider>
35
50
  );
36
51
  }
52
+
53
+ /** Static APIs that follow this App tree (`useApp`). */
54
+ export function useApp() {
55
+ return { message, modal: Modal };
56
+ }
@@ -0,0 +1,32 @@
1
+ import { createContext, useContext, useMemo } from "react";
2
+ import type { CanonicalSize } from "../lib/sizes";
3
+ import { getDesignToken, type NonlaTokenSnapshot } from "../theme";
4
+
5
+ export type NonlaAppConfig = {
6
+ getPopupContainer?: () => HTMLElement;
7
+ componentSize?: CanonicalSize;
8
+ /** Bumps when `<App theme>` applies knobs — `useToken` re-reads CSS. */
9
+ themeRev: number;
10
+ };
11
+
12
+ export const AppContext = createContext<NonlaAppConfig>({ themeRev: 0 });
13
+
14
+ export function useAppConfig() {
15
+ return useContext(AppContext);
16
+ }
17
+
18
+ /** Portal container for overlays (`getPopupContainer`). Always leaves the desktop stacking context. */
19
+ export function usePopupContainer(override?: () => HTMLElement) {
20
+ const { getPopupContainer } = useAppConfig();
21
+ return () => (override ?? getPopupContainer)?.() ?? document.body;
22
+ }
23
+
24
+ /** Computed seed knobs under the current App theme (`useToken`). */
25
+ export function useToken(): { token: NonlaTokenSnapshot } {
26
+ const { themeRev } = useAppConfig();
27
+ const token = useMemo(() => {
28
+ void themeRev;
29
+ return getDesignToken();
30
+ }, [themeRev]);
31
+ return { token };
32
+ }
@@ -1,7 +1,7 @@
1
1
  import { type AnchorHTMLAttributes, type ButtonHTMLAttributes, type CSSProperties, type ForwardRefExoticComponent, type MouseEvent, type ReactNode, type RefAttributes, createContext, forwardRef, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
2
2
  import { cn } from "../lib/cn";
3
3
  import type { ControlSize } from "../lib/sizes";
4
- import { controlHeightVar, controlRadiusVar, getSizeTokens, normalizeSize } from "../lib/sizes";
4
+ import { controlHeightVar, controlRadiusVar, getSizeTokens, useControlSize } from "../lib/sizes";
5
5
 
6
6
  export type ButtonType = "default" | "primary" | "dashed" | "link" | "text";
7
7
  export type ButtonVariant = "outlined" | "dashed" | "solid" | "filled" | "text" | "link";
@@ -154,7 +154,7 @@ const ButtonInner = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProp
154
154
  const resolvedHtmlType = (typeIsHtml ? typeProp : htmlType) as ButtonHtmlType;
155
155
 
156
156
  const groupSize = useContext(GroupSizeContext);
157
- const size = normalizeSize(sizeProp ?? groupSize);
157
+ const size = useControlSize(sizeProp ?? groupSize);
158
158
  const tok = getSizeTokens(size);
159
159
  const [mergedColor, mergedVariant] = resolveTone(type, color, variant, danger);
160
160
  const preset = PRESET_COLORS.has(mergedColor);
@@ -92,18 +92,18 @@ export function Calendar({
92
92
  month: cn("flex w-full flex-col gap-3", defaultClassNames.month),
93
93
  nav: cn("absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", defaultClassNames.nav),
94
94
  button_previous: cn(
95
- "inline-flex size-[var(--cell-size)] items-center justify-center rounded-md p-0 text-foreground select-none",
95
+ "inline-flex size-(--cell-size) items-center justify-center rounded-md p-0 text-foreground select-none",
96
96
  "hover:bg-brand-50 disabled:opacity-40",
97
97
  defaultClassNames.button_previous,
98
98
  ),
99
99
  button_next: cn(
100
- "inline-flex size-[var(--cell-size)] items-center justify-center rounded-md p-0 text-foreground select-none",
100
+ "inline-flex size-(--cell-size) items-center justify-center rounded-md p-0 text-foreground select-none",
101
101
  "hover:bg-brand-50 disabled:opacity-40",
102
102
  defaultClassNames.button_next,
103
103
  ),
104
- month_caption: cn("flex h-[var(--cell-size)] w-full items-center justify-center px-[var(--cell-size)]", defaultClassNames.month_caption),
104
+ month_caption: cn("flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)", defaultClassNames.month_caption),
105
105
  caption_label: cn("select-none text-sm font-medium text-foreground", defaultClassNames.caption_label),
106
- dropdowns: cn("flex h-[var(--cell-size)] w-full items-center justify-center gap-1.5 text-sm font-medium", defaultClassNames.dropdowns),
106
+ dropdowns: cn("flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium", defaultClassNames.dropdowns),
107
107
  month_grid: cn("w-full border-collapse", defaultClassNames.month_grid),
108
108
  weekdays: cn("flex", defaultClassNames.weekdays),
109
109
  weekday: cn("flex-1 select-none rounded-md text-[0.8rem] font-normal text-muted-foreground", defaultClassNames.weekday),
@@ -1,7 +1,7 @@
1
1
  import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
2
2
  import { cn } from "../lib/cn";
3
3
  import { OverlayScroll, type OverlayScrollVisibility } from "../scroll/OverlayScroll";
4
- import type { AgentMessage, AgentPanelEndpoint, AgentToolAction } from "./common/types";
4
+ import type { AgentMessage, AgentPanelEndpoint, AgentToolAction, AgentToolHook } from "./common/types";
5
5
  import { parseBgTaskRef } from "./common/bgTasks";
6
6
  import { useAgentStream } from "./common/useAgentStream";
7
7
  import { formatToolName } from "./common/utils";
@@ -14,7 +14,8 @@ import { ChatUserMessage } from "./message-ui/ChatUserMessage";
14
14
  import { ChatWelcome } from "./message-ui/ChatWelcome";
15
15
  import { BackgroundTaskToolUI } from "./tool-ui/BackgroundTaskToolUI";
16
16
  import { ChatToolCall } from "./tool-ui/ChatToolCall";
17
- import { resolveToolUI } from "./tool-ui/registry";
17
+ import { resolveToolUI, type AgentToolUI } from "./tool-ui/registry";
18
+ import { Shimmer } from "../shimmer/Shimmer";
18
19
 
19
20
  export type AgentPanelProps = {
20
21
  /** POST URL, or a function that returns an SSE `Response`. */
@@ -31,7 +32,12 @@ export type AgentPanelProps = {
31
32
  headers?: Record<string, string>;
32
33
  fetcher?: typeof fetch;
33
34
  initialMessages?: AgentMessage[];
35
+ /** Custom node in the composer toolbar (model picker, tools, …). */
34
36
  toolbar?: ReactNode;
37
+ /** Extra tool cards keyed by stream `toolName`. Checked before builtins. */
38
+ toolUis?: AgentToolUI[];
39
+ /** Tool lifecycle hooks, matched by `name` like `toolUis`. Omit `name` to hear every tool. */
40
+ toolHooks?: AgentToolHook[];
35
41
  accessory?: ReactNode;
36
42
  emptyState?: ReactNode;
37
43
  /** Overlay scrollbar — does not reserve layout space. */
@@ -77,14 +83,14 @@ function useStickToBottom() {
77
83
  return { scrollRef, onScroll, scrollToBottom };
78
84
  }
79
85
 
80
- function MessageRow({ msg, generating }: { msg: AgentMessage; generating: boolean }) {
86
+ function MessageRow({ msg, generating, toolUis }: { msg: AgentMessage; generating: boolean; toolUis?: AgentToolUI[] }) {
81
87
  if (msg.role === "user") return <ChatUserMessage content={msg.content} />;
82
88
  if (msg.role === "error") return <ChatError>{msg.content}</ChatError>;
83
89
  if (msg.role === "thinking") {
84
90
  return <ChatThinking thinking={msg.content} duration={(msg.meta?.thinkingDuration as number) ?? 0} />;
85
91
  }
86
92
  if (msg.role === "tool-call") {
87
- const CustomUI = resolveToolUI(msg.toolName);
93
+ const CustomUI = resolveToolUI(msg.toolName, toolUis);
88
94
  if (CustomUI) {
89
95
  return <CustomUI msg={msg} generating={generating} showAvatar={false} />;
90
96
  }
@@ -126,6 +132,8 @@ export function AgentPanel({
126
132
  fetcher,
127
133
  initialMessages,
128
134
  toolbar,
135
+ toolUis,
136
+ toolHooks,
129
137
  accessory,
130
138
  emptyState,
131
139
  scrollbar = "hover",
@@ -140,6 +148,7 @@ export function AgentPanel({
140
148
  fetcher,
141
149
  initialMessages,
142
150
  onToolAction,
151
+ toolHooks,
143
152
  });
144
153
  const { scrollRef, onScroll, scrollToBottom } = useStickToBottom();
145
154
  const [epoch, setEpoch] = useState(0);
@@ -180,11 +189,11 @@ export function AgentPanel({
180
189
  ) : (
181
190
  <div className="flex flex-col">
182
191
  {messages.map((msg) => (
183
- <MessageRow key={msg.id} msg={msg} generating={generating} />
192
+ <MessageRow key={msg.id} msg={msg} generating={generating} toolUis={toolUis} />
184
193
  ))}
185
194
  {showFooter ? (
186
195
  <div className="mt-1 px-4 pb-0.5">
187
- <span className="nonla-chat-shimmer text-(length:--chat-body-size) leading-5.5 font-medium">{status}</span>
196
+ <Shimmer className="text-(length:--chat-body-size) leading-5.5 font-medium">{status}</Shimmer>
188
197
  </div>
189
198
  ) : null}
190
199
  </div>
@@ -38,6 +38,31 @@ export type AgentStreamRequest = {
38
38
  /** POST URL, or a function that returns an SSE `Response`. */
39
39
  export type AgentPanelEndpoint = string | ((req: AgentStreamRequest) => Promise<Response>);
40
40
 
41
+ /** Exact name, aliases, predicate, or omit/`*` for every tool. */
42
+ export type AgentToolNameMatch = string | readonly string[] | ((toolName: string) => boolean);
43
+
44
+ export type AgentToolCallEvent = {
45
+ toolCallId?: string;
46
+ toolName: string;
47
+ toolLabel: string;
48
+ input: unknown;
49
+ };
50
+
51
+ export type AgentToolResultEvent = {
52
+ toolCallId?: string;
53
+ toolName: string;
54
+ output: unknown;
55
+ /** Set when the tool ended without a real result (cancel, disconnect, timeout). */
56
+ error?: string;
57
+ };
58
+
59
+ /** Named tool lifecycle hooks. Omit `name` (or use `"*"`) to match every tool. */
60
+ export type AgentToolHook = {
61
+ name?: AgentToolNameMatch;
62
+ onCall?: (event: AgentToolCallEvent) => void;
63
+ onResult?: (event: AgentToolResultEvent) => void;
64
+ };
65
+
41
66
  export type AgentToolAction =
42
- | { type: "tool-call"; toolName: string; toolLabel: string; input: unknown }
43
- | { type: "tool-result"; toolName: string; output: unknown };
67
+ | ({ type: "tool-call" } & AgentToolCallEvent)
68
+ | ({ type: "tool-result" } & AgentToolResultEvent);
@@ -1,7 +1,7 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import { parseSseStream } from "./sse";
3
- import type { AgentHistoryMessage, AgentMessage, AgentPanelEndpoint, AgentToolAction } from "./types";
4
- import { formatToolName } from "./utils";
3
+ import type { AgentHistoryMessage, AgentMessage, AgentPanelEndpoint, AgentToolAction, AgentToolCallEvent, AgentToolHook, AgentToolResultEvent } from "./types";
4
+ import { formatToolName, matchesToolHook } from "./utils";
5
5
 
6
6
  let _id = 0;
7
7
  function nextId(prefix: string) {
@@ -69,9 +69,12 @@ export type UseAgentStreamOptions = {
69
69
  fetcher?: typeof fetch;
70
70
  initialMessages?: AgentMessage[];
71
71
  onToolAction?: (event: AgentToolAction) => void;
72
+ toolHooks?: AgentToolHook[];
72
73
  };
73
74
 
74
- export function useAgentStream({ endpoint, extraBody, headers, fetcher = fetch, initialMessages, onToolAction }: UseAgentStreamOptions) {
75
+ type OpenTool = AgentToolCallEvent;
76
+
77
+ export function useAgentStream({ endpoint, extraBody, headers, fetcher = fetch, initialMessages, onToolAction, toolHooks }: UseAgentStreamOptions) {
75
78
  const [messages, setMessages] = useState<AgentMessage[]>(() => initialMessages ?? []);
76
79
  const [generating, setGenerating] = useState(false);
77
80
  const thinkingRef = useRef("");
@@ -79,10 +82,44 @@ export function useAgentStream({ endpoint, extraBody, headers, fetcher = fetch,
79
82
  const abortRef = useRef<AbortController | null>(null);
80
83
  const onToolActionRef = useRef(onToolAction);
81
84
  onToolActionRef.current = onToolAction;
85
+ const toolHooksRef = useRef(toolHooks);
86
+ toolHooksRef.current = toolHooks;
82
87
  const extraBodyRef = useRef(extraBody);
83
88
  extraBodyRef.current = extraBody;
84
89
  const endpointRef = useRef(endpoint);
85
90
  endpointRef.current = endpoint;
91
+ const openToolsRef = useRef<Map<string, OpenTool>>(new Map());
92
+
93
+ const emitCall = useCallback((event: AgentToolCallEvent) => {
94
+ for (const hook of toolHooksRef.current ?? []) {
95
+ if (matchesToolHook(hook.name, event.toolName)) hook.onCall?.(event);
96
+ }
97
+ onToolActionRef.current?.({ type: "tool-call", ...event });
98
+ }, []);
99
+
100
+ const emitResult = useCallback((event: AgentToolResultEvent) => {
101
+ for (const hook of toolHooksRef.current ?? []) {
102
+ if (matchesToolHook(hook.name, event.toolName)) hook.onResult?.(event);
103
+ }
104
+ onToolActionRef.current?.({ type: "tool-result", ...event });
105
+ }, []);
106
+
107
+ const flushOpenTools = useCallback(
108
+ (error: string) => {
109
+ const open = [...openToolsRef.current.values()];
110
+ if (!open.length) return;
111
+ openToolsRef.current.clear();
112
+ for (const tool of open) {
113
+ emitResult({
114
+ toolCallId: tool.toolCallId,
115
+ toolName: tool.toolName,
116
+ output: { success: false, error },
117
+ error,
118
+ });
119
+ }
120
+ },
121
+ [emitResult],
122
+ );
86
123
 
87
124
  useEffect(() => {
88
125
  return () => {
@@ -94,20 +131,22 @@ export function useAgentStream({ endpoint, extraBody, headers, fetcher = fetch,
94
131
  const cancel = useCallback(() => {
95
132
  abortRef.current?.abort();
96
133
  abortRef.current = null;
134
+ flushOpenTools("Cancelled");
97
135
  setMessages((prev) => finalizeStreaming(failOpenTools(prev, "Cancelled")));
98
136
  setGenerating(false);
99
137
  thinkingRef.current = "";
100
138
  thinkingStartRef.current = 0;
101
- }, []);
139
+ }, [flushOpenTools]);
102
140
 
103
141
  const clear = useCallback(() => {
104
142
  abortRef.current?.abort();
105
143
  abortRef.current = null;
144
+ flushOpenTools("Cancelled");
106
145
  setMessages([]);
107
146
  setGenerating(false);
108
147
  thinkingRef.current = "";
109
148
  thinkingStartRef.current = 0;
110
- }, []);
149
+ }, [flushOpenTools]);
111
150
 
112
151
  const send = useCallback(
113
152
  async (text: string) => {
@@ -115,6 +154,7 @@ export function useAgentStream({ endpoint, extraBody, headers, fetcher = fetch,
115
154
  if (!trimmed || generating) return;
116
155
 
117
156
  abortRef.current?.abort();
157
+ flushOpenTools("Cancelled");
118
158
  const controller = new AbortController();
119
159
  abortRef.current = controller;
120
160
 
@@ -259,10 +299,18 @@ export function useAgentStream({ endpoint, extraBody, headers, fetcher = fetch,
259
299
  onToolCall: (event) => {
260
300
  const label = event.toolLabel ? (event.toolLabel.includes(" ") ? event.toolLabel : formatToolName(event.toolLabel)) : formatToolName(event.toolName);
261
301
  freezeOpen();
302
+ const id = nextId("tc");
303
+ const call: AgentToolCallEvent = {
304
+ toolCallId: event.toolCallId,
305
+ toolName: event.toolName,
306
+ toolLabel: label,
307
+ input: event.input,
308
+ };
309
+ openToolsRef.current.set(event.toolCallId || id, call);
262
310
  setMessages((prev) => [
263
311
  ...prev,
264
312
  {
265
- id: nextId("tc"),
313
+ id,
266
314
  role: "tool-call",
267
315
  content: event.toolName,
268
316
  toolCallId: event.toolCallId,
@@ -272,11 +320,21 @@ export function useAgentStream({ endpoint, extraBody, headers, fetcher = fetch,
272
320
  timestamp: new Date(),
273
321
  },
274
322
  ]);
275
- onToolActionRef.current?.({ type: "tool-call", toolName: event.toolName, toolLabel: label, input: event.input });
323
+ emitCall(call);
276
324
  },
277
325
  onToolResult: (event) => {
278
326
  const raw = event.result;
279
327
  const resultStr = typeof raw === "string" ? raw : JSON.stringify(raw);
328
+ if (event.toolCallId) {
329
+ openToolsRef.current.delete(event.toolCallId);
330
+ } else {
331
+ for (const [key, tool] of [...openToolsRef.current.entries()].reverse()) {
332
+ if (tool.toolName === event.toolName) {
333
+ openToolsRef.current.delete(key);
334
+ break;
335
+ }
336
+ }
337
+ }
280
338
  setMessages((prev) => {
281
339
  let matchIdx = -1;
282
340
  if (event.toolCallId) {
@@ -289,15 +347,18 @@ export function useAgentStream({ endpoint, extraBody, headers, fetcher = fetch,
289
347
  if (matchIdx === -1) return prev;
290
348
  return prev.map((m, i) => (i === matchIdx ? { ...m, toolOutput: resultStr } : m));
291
349
  });
292
- onToolActionRef.current?.({ type: "tool-result", toolName: event.toolName, output: raw });
350
+ emitResult({ toolCallId: event.toolCallId, toolName: event.toolName, output: raw });
293
351
  },
294
352
  onDone: () => {
353
+ flushOpenTools("Tool did not return a result");
295
354
  setMessages((prev) => finalizeStreaming(failOpenTools(prev, "Tool did not return a result")));
296
355
  setGenerating(false);
297
356
  thinkingRef.current = "";
298
357
  thinkingStartRef.current = 0;
299
358
  },
300
359
  onError: (error) => {
360
+ const reason = error === "cancelled" ? "Cancelled" : error;
361
+ flushOpenTools(reason);
301
362
  if (error === "Connection lost") {
302
363
  setMessages((prev) => finalizeStreaming(failOpenTools(prev, "Connection lost")));
303
364
  setGenerating(false);
@@ -306,7 +367,7 @@ export function useAgentStream({ endpoint, extraBody, headers, fetcher = fetch,
306
367
  return;
307
368
  }
308
369
  setMessages((prev) => [
309
- ...finalizeStreaming(failOpenTools(prev, error === "cancelled" ? "Cancelled" : error)),
370
+ ...finalizeStreaming(failOpenTools(prev, reason)),
310
371
  ...(error === "cancelled" ? [] : [{ id: nextId("err"), role: "error" as const, content: error, timestamp: new Date() }]),
311
372
  ]);
312
373
  setGenerating(false);
@@ -318,6 +379,7 @@ export function useAgentStream({ endpoint, extraBody, headers, fetcher = fetch,
318
379
  );
319
380
 
320
381
  if (result === "aborted") {
382
+ flushOpenTools("Cancelled");
321
383
  setMessages((prev) => finalizeStreaming(failOpenTools(prev, "Cancelled")));
322
384
  setGenerating(false);
323
385
  thinkingRef.current = "";
@@ -325,26 +387,30 @@ export function useAgentStream({ endpoint, extraBody, headers, fetcher = fetch,
325
387
  return;
326
388
  }
327
389
 
390
+ flushOpenTools("Tool did not return a result");
328
391
  setMessages((prev) => finalizeStreaming(failOpenTools(prev, "Tool did not return a result")));
329
392
  setGenerating(false);
330
393
  } catch (err: unknown) {
331
394
  if ((err as Error)?.name === "AbortError") {
395
+ flushOpenTools("Cancelled");
332
396
  setMessages((prev) => finalizeStreaming(failOpenTools(prev, "Cancelled")));
333
397
  setGenerating(false);
334
398
  thinkingRef.current = "";
335
399
  thinkingStartRef.current = 0;
336
400
  return;
337
401
  }
402
+ const message = err instanceof Error ? err.message : String(err);
403
+ flushOpenTools(message);
338
404
  setMessages((prev) => [
339
- ...finalizeStreaming(failOpenTools(prev, err instanceof Error ? err.message : String(err))),
340
- { id: nextId("err"), role: "error", content: err instanceof Error ? err.message : String(err), timestamp: new Date() },
405
+ ...finalizeStreaming(failOpenTools(prev, message)),
406
+ { id: nextId("err"), role: "error", content: message, timestamp: new Date() },
341
407
  ]);
342
408
  setGenerating(false);
343
409
  } finally {
344
410
  abortRef.current = null;
345
411
  }
346
412
  },
347
- [generating, messages, fetcher, headers],
413
+ [generating, messages, fetcher, headers, emitCall, emitResult, flushOpenTools],
348
414
  );
349
415
 
350
416
  return { messages, generating, send, cancel, clear };
@@ -1,3 +1,5 @@
1
+ import type { AgentToolNameMatch } from "./types";
2
+
1
3
  /** snake_case / camelCase → Title Case (e.g. edit_code → Edit Code). */
2
4
  export function formatToolName(name: string): string {
3
5
  return name
@@ -71,3 +73,14 @@ export function timestampMs(value: unknown): number {
71
73
  }
72
74
  return Date.now();
73
75
  }
76
+
77
+ export function matchesToolName(match: AgentToolNameMatch, toolName: string): boolean {
78
+ if (typeof match === "function") return match(toolName);
79
+ if (typeof match === "string") return match === toolName;
80
+ return match.includes(toolName);
81
+ }
82
+
83
+ export function matchesToolHook(match: AgentToolNameMatch | undefined, toolName: string): boolean {
84
+ if (match == null || match === "*") return true;
85
+ return matchesToolName(match, toolName);
86
+ }
package/src/chat/index.ts CHANGED
@@ -13,8 +13,10 @@ export type { ChatErrorProps } from "./message-ui/ChatError";
13
13
  export { ChatToolCall } from "./tool-ui/ChatToolCall";
14
14
  export type { ChatToolCallProps } from "./tool-ui/ChatToolCall";
15
15
 
16
- export { resolveToolUI } from "./tool-ui/registry";
16
+ export { resolveToolUI, builtinToolUis, matchesToolUIName } from "./tool-ui/registry";
17
+ export type { AgentToolUI, AgentToolUIName } from "./tool-ui/registry";
17
18
  export type { ToolUIProps, ChatToolMessage } from "./tool-ui/types";
19
+ export { isToolRunning } from "./tool-ui/types";
18
20
  export { CallAgentToolUI } from "./tool-ui/CallAgentToolUI";
19
21
  export { WebFetchToolUI } from "./tool-ui/WebFetchToolUI";
20
22
  export { GetCurrentTimeToolUI } from "./tool-ui/GetCurrentTimeToolUI";
@@ -48,9 +50,9 @@ export type { UseAgentStreamOptions } from "./common/useAgentStream";
48
50
  export { parseSseStream, normalizeSseEvent } from "./common/sse";
49
51
  export type { AgentSseEvent, AgentSseCallbacks, ParseSseResult } from "./common/sse";
50
52
 
51
- export type { AgentMessage, AgentMessageRole, AgentHistoryMessage, AgentStreamRequest, AgentPanelEndpoint, AgentToolAction } from "./common/types";
53
+ export type { AgentMessage, AgentMessageRole, AgentHistoryMessage, AgentStreamRequest, AgentPanelEndpoint, AgentToolAction, AgentToolHook, AgentToolCallEvent, AgentToolResultEvent, AgentToolNameMatch } from "./common/types";
52
54
 
53
- export { formatToolName, prettyJson, isCallAgentToolName, parseCallAgentToolTargetId } from "./common/utils";
55
+ export { formatToolName, prettyJson, isCallAgentToolName, parseCallAgentToolTargetId, matchesToolName, matchesToolHook } from "./common/utils";
54
56
 
55
57
  export { parseBgTaskRef, formatBgElapsed } from "./common/bgTasks";
56
58
  export type { ChatBgTask, ChatBgTaskStatus } from "./common/bgTasks";
@@ -21,7 +21,7 @@ export function ChatAgentMessage({ content, children, thinking, thinkingDuration
21
21
  {thinking ? <ChatThinking thinking={thinking} duration={thinkingDuration ?? 0} streaming={thinkingStreaming} /> : null}
22
22
  {body ? (
23
23
  <div className="min-w-0 px-4 pb-0.5">
24
- <div className="nonla-chat-body text-(length:--chat-body-size) leading-(--chat-body-leading) text-[var(--nonla-ink)]">{body}</div>
24
+ <div className="nonla-chat-body text-(length:--chat-body-size) leading-(--chat-body-leading) text-(--nonla-ink)">{body}</div>
25
25
  </div>
26
26
  ) : null}
27
27
  </div>
@@ -136,7 +136,7 @@ export function ChatInput({ generating = false, placeholder = "Message…", disa
136
136
  <div className="flex-1" />
137
137
 
138
138
  {generating ? (
139
- <button type="button" onClick={onCancel} title="Stop" className="w-6 h-6 rounded-full bg-brand text-white flex items-center justify-center shrink-0 cursor-pointer hover:bg-brand/90 active:scale-95 transition-all duration-100 border-0">
139
+ <button type="button" onClick={onCancel} title="Stop" className="w-6 h-6 rounded-full bg-brand text-(--nonla-solid-fg) flex items-center justify-center shrink-0 cursor-pointer hover:bg-brand/90 active:scale-95 transition-all duration-100 border-0">
140
140
  <svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden>
141
141
  <rect x="1.5" y="1.5" width="9" height="9" rx="2" fill="currentColor" />
142
142
  </svg>