@nastechai/agent 0.16.0 → 0.17.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 (98) hide show
  1. package/eslint.config.js +23 -0
  2. package/index.html +24 -0
  3. package/package.json +54 -26
  4. package/package.json.bak +89 -0
  5. package/package.json.pub +88 -0
  6. package/src/App.tsx +1173 -0
  7. package/src/components/AuthWidget.tsx +150 -0
  8. package/src/components/AutoField.tsx +206 -0
  9. package/src/components/Backdrop.tsx +93 -0
  10. package/src/components/ChatSidebar.tsx +394 -0
  11. package/src/components/DeleteConfirmDialog.tsx +40 -0
  12. package/src/components/LanguageSwitcher.tsx +186 -0
  13. package/src/components/Markdown.tsx +383 -0
  14. package/src/components/ModelInfoCard.tsx +112 -0
  15. package/src/components/ModelPickerDialog.tsx +470 -0
  16. package/src/components/OAuthLoginModal.tsx +374 -0
  17. package/src/components/OAuthProvidersCard.tsx +287 -0
  18. package/src/components/PlatformsCard.tsx +97 -0
  19. package/src/components/ScheduleBuilder.tsx +273 -0
  20. package/src/components/SidebarFooter.tsx +42 -0
  21. package/src/components/SidebarStatusStrip.tsx +72 -0
  22. package/src/components/SlashPopover.tsx +171 -0
  23. package/src/components/ThemeSwitcher.tsx +243 -0
  24. package/src/components/ToolCall.tsx +228 -0
  25. package/src/components/ToolsetConfigDrawer.tsx +448 -0
  26. package/src/contexts/PageHeaderProvider.tsx +139 -0
  27. package/src/contexts/SystemActions.tsx +120 -0
  28. package/src/contexts/page-header-context.ts +12 -0
  29. package/src/contexts/system-actions-context.ts +18 -0
  30. package/src/contexts/usePageHeader.ts +10 -0
  31. package/src/contexts/useSystemActions.ts +15 -0
  32. package/src/hooks/useModalBehavior.ts +44 -0
  33. package/src/hooks/useSidebarStatus.ts +27 -0
  34. package/src/i18n/af.ts +702 -0
  35. package/src/i18n/context.tsx +123 -0
  36. package/src/i18n/de.ts +701 -0
  37. package/src/i18n/en.ts +708 -0
  38. package/src/i18n/es.ts +701 -0
  39. package/src/i18n/fr.ts +701 -0
  40. package/src/i18n/ga.ts +702 -0
  41. package/src/i18n/hu.ts +702 -0
  42. package/src/i18n/index.ts +2 -0
  43. package/src/i18n/it.ts +701 -0
  44. package/src/i18n/ja.ts +702 -0
  45. package/src/i18n/ko.ts +702 -0
  46. package/src/i18n/pt.ts +702 -0
  47. package/src/i18n/ru.ts +702 -0
  48. package/src/i18n/tr.ts +702 -0
  49. package/src/i18n/types.ts +710 -0
  50. package/src/i18n/uk.ts +702 -0
  51. package/src/i18n/zh-hant.ts +702 -0
  52. package/src/i18n/zh.ts +698 -0
  53. package/src/index.css +274 -0
  54. package/src/lib/api.ts +1585 -0
  55. package/src/lib/dashboard-flags.ts +15 -0
  56. package/src/lib/format.ts +9 -0
  57. package/src/lib/fuzzy.ts +192 -0
  58. package/src/lib/gatewayClient.ts +253 -0
  59. package/src/lib/nested.ts +23 -0
  60. package/src/lib/resolve-page-title.ts +41 -0
  61. package/src/lib/schedule.ts +382 -0
  62. package/src/lib/slashExec.ts +163 -0
  63. package/src/lib/utils.ts +35 -0
  64. package/src/main.tsx +25 -0
  65. package/src/pages/AnalyticsPage.tsx +601 -0
  66. package/src/pages/ChannelsPage.tsx +772 -0
  67. package/src/pages/ChatPage.tsx +889 -0
  68. package/src/pages/ConfigPage.tsx +660 -0
  69. package/src/pages/CronPage.tsx +524 -0
  70. package/src/pages/DocsPage.tsx +69 -0
  71. package/src/pages/EnvPage.tsx +918 -0
  72. package/src/pages/LogsPage.tsx +246 -0
  73. package/src/pages/McpPage.tsx +757 -0
  74. package/src/pages/ModelsPage.tsx +994 -0
  75. package/src/pages/PairingPage.tsx +276 -0
  76. package/src/pages/PluginsPage.tsx +580 -0
  77. package/src/pages/ProfilesPage.tsx +559 -0
  78. package/src/pages/SessionsPage.tsx +936 -0
  79. package/src/pages/SkillsPage.tsx +557 -0
  80. package/src/pages/SystemPage.tsx +1259 -0
  81. package/src/pages/WebhooksPage.tsx +483 -0
  82. package/src/plugins/PluginPage.tsx +64 -0
  83. package/src/plugins/index.ts +6 -0
  84. package/src/plugins/registry.ts +151 -0
  85. package/src/plugins/sdk.d.ts +160 -0
  86. package/src/plugins/slots.ts +199 -0
  87. package/src/plugins/types.ts +37 -0
  88. package/src/plugins/usePlugins.ts +133 -0
  89. package/src/themes/context.tsx +443 -0
  90. package/src/themes/fonts.ts +160 -0
  91. package/src/themes/index.ts +3 -0
  92. package/src/themes/presets.ts +477 -0
  93. package/src/themes/types.ts +187 -0
  94. package/tsconfig.app.json +34 -0
  95. package/tsconfig.json +7 -0
  96. package/tsconfig.node.json +26 -0
  97. package/vite.config.ts +124 -0
  98. package/vite.config.ts.timestamp-1780999102396-af6b77b30ebd8.mjs +105 -0
@@ -0,0 +1,470 @@
1
+ import { Button } from "@nastechai/ui/ui/components/button";
2
+ import { Checkbox } from "@nastechai/ui/ui/components/checkbox";
3
+ import { ListItem } from "@nastechai/ui/ui/components/list-item";
4
+ import { Spinner } from "@nastechai/ui/ui/components/spinner";
5
+ import { Input } from "@nastechai/ui/ui/components/input";
6
+ import { Label } from "@nastechai/ui/ui/components/label";
7
+ import type { GatewayClient } from "@/lib/gatewayClient";
8
+ import { Check, Search, X } from "lucide-react";
9
+ import { useEffect, useMemo, useRef, useState } from "react";
10
+ import { createPortal } from "react-dom";
11
+ import { cn, themedBody } from "@/lib/utils";
12
+
13
+ /**
14
+ * Two-stage model picker modal.
15
+ *
16
+ * Mirrors ui-tui/src/components/modelPicker.tsx:
17
+ * Stage 1: pick provider (authenticated providers only)
18
+ * Stage 2: pick model within that provider
19
+ *
20
+ * Two invocation modes:
21
+ *
22
+ * 1. Chat-session mode (ChatSidebar) — pass `gw` + `sessionId`. The picker
23
+ * loads options via `model.options` JSON-RPC and emits the result as a
24
+ * slash command string (`/model <model> --provider <slug> [--global]`)
25
+ * through `onSubmit`, which the ChatPage pipes to `slashExec`.
26
+ *
27
+ * 2. Standalone mode (ModelsPage, Config settings) — pass a `loader` and
28
+ * `onApply`. The picker fetches options via the REST endpoint and calls
29
+ * `onApply(provider, model, persistGlobal)` instead of emitting a slash
30
+ * command. This lets the Models page reuse the same UI without
31
+ * requiring an open chat PTY.
32
+ */
33
+
34
+ interface ModelOptionProvider {
35
+ name: string;
36
+ slug: string;
37
+ models?: string[];
38
+ total_models?: number;
39
+ is_current?: boolean;
40
+ warning?: string;
41
+ }
42
+
43
+ interface ModelOptionsResponse {
44
+ model?: string;
45
+ provider?: string;
46
+ providers?: ModelOptionProvider[];
47
+ }
48
+
49
+ interface Props {
50
+ /** Chat-mode: when present, picker emits a slash command via onSubmit. */
51
+ gw?: GatewayClient;
52
+ sessionId?: string;
53
+ onSubmit?(slashCommand: string): void;
54
+
55
+ /** Standalone-mode: when present (and onSubmit absent), picker calls onApply. */
56
+ loader?(): Promise<ModelOptionsResponse>;
57
+ onApply?(args: {
58
+ provider: string;
59
+ model: string;
60
+ persistGlobal: boolean;
61
+ }): Promise<void> | void;
62
+
63
+ onClose(): void;
64
+ title?: string;
65
+ /** If true, hides "Persist globally" checkbox — always saves to config.yaml. */
66
+ alwaysGlobal?: boolean;
67
+ }
68
+
69
+ export function ModelPickerDialog(props: Props) {
70
+ const {
71
+ gw,
72
+ sessionId,
73
+ onSubmit,
74
+ loader,
75
+ onApply,
76
+ onClose,
77
+ title = "Switch Model",
78
+ alwaysGlobal = false,
79
+ } = props;
80
+ const standalone = !!loader && !!onApply;
81
+
82
+ const [providers, setProviders] = useState<ModelOptionProvider[]>([]);
83
+ const [currentModel, setCurrentModel] = useState("");
84
+ const [currentProviderSlug, setCurrentProviderSlug] = useState("");
85
+ const [loading, setLoading] = useState(true);
86
+ const [error, setError] = useState<string | null>(null);
87
+ const [selectedSlug, setSelectedSlug] = useState("");
88
+ const [selectedModel, setSelectedModel] = useState("");
89
+ const [query, setQuery] = useState("");
90
+ const [persistGlobal, setPersistGlobal] = useState(alwaysGlobal);
91
+ const [applying, setApplying] = useState(false);
92
+ const closedRef = useRef(false);
93
+
94
+ // Load providers + models on open.
95
+ useEffect(() => {
96
+ closedRef.current = false;
97
+
98
+ const promise = standalone
99
+ ? (loader as () => Promise<ModelOptionsResponse>)()
100
+ : (gw as GatewayClient).request<ModelOptionsResponse>(
101
+ "model.options",
102
+ sessionId ? { session_id: sessionId } : {},
103
+ );
104
+
105
+ promise
106
+ .then((r) => {
107
+ if (closedRef.current) return;
108
+ const next = r?.providers ?? [];
109
+ setProviders(next);
110
+ setCurrentModel(String(r?.model ?? ""));
111
+ setCurrentProviderSlug(String(r?.provider ?? ""));
112
+ setSelectedSlug(
113
+ (next.find((p) => p.is_current) ?? next[0])?.slug ?? "",
114
+ );
115
+ setSelectedModel("");
116
+ setLoading(false);
117
+ })
118
+ .catch((e) => {
119
+ if (closedRef.current) return;
120
+ setError(e instanceof Error ? e.message : String(e));
121
+ setLoading(false);
122
+ });
123
+
124
+ return () => {
125
+ closedRef.current = true;
126
+ };
127
+ // Deliberately omit props from deps — stable for the dialog's lifetime.
128
+ // eslint-disable-next-line react-hooks/exhaustive-deps
129
+ }, []);
130
+
131
+ // Esc closes.
132
+ useEffect(() => {
133
+ const onKey = (e: KeyboardEvent) => {
134
+ if (e.key === "Escape") {
135
+ e.preventDefault();
136
+ onClose();
137
+ }
138
+ };
139
+ window.addEventListener("keydown", onKey);
140
+ return () => window.removeEventListener("keydown", onKey);
141
+ }, [onClose]);
142
+
143
+ const selectedProvider = useMemo(
144
+ () => providers.find((p) => p.slug === selectedSlug) ?? null,
145
+ [providers, selectedSlug],
146
+ );
147
+
148
+ const models = useMemo(
149
+ () => selectedProvider?.models ?? [],
150
+ [selectedProvider],
151
+ );
152
+
153
+ const needle = query.trim().toLowerCase();
154
+
155
+ const filteredProviders = useMemo(
156
+ () =>
157
+ !needle
158
+ ? providers
159
+ : providers.filter(
160
+ (p) =>
161
+ p.name.toLowerCase().includes(needle) ||
162
+ p.slug.toLowerCase().includes(needle) ||
163
+ (p.models ?? []).some((m) => m.toLowerCase().includes(needle)),
164
+ ),
165
+ [providers, needle],
166
+ );
167
+
168
+ const filteredModels = useMemo(
169
+ () =>
170
+ !needle ? models : models.filter((m) => m.toLowerCase().includes(needle)),
171
+ [models, needle],
172
+ );
173
+
174
+ const canConfirm = !!selectedProvider && !!selectedModel && !applying;
175
+
176
+ const confirm = async () => {
177
+ if (!canConfirm || !selectedProvider) return;
178
+ if (standalone && onApply) {
179
+ setApplying(true);
180
+ try {
181
+ await onApply({
182
+ provider: selectedProvider.slug,
183
+ model: selectedModel,
184
+ persistGlobal,
185
+ });
186
+ onClose();
187
+ } catch (e) {
188
+ setError(e instanceof Error ? e.message : String(e));
189
+ } finally {
190
+ setApplying(false);
191
+ }
192
+ } else if (onSubmit) {
193
+ const global = persistGlobal ? " --global" : "";
194
+ onSubmit(
195
+ `/model ${selectedModel} --provider ${selectedProvider.slug}${global}`,
196
+ );
197
+ onClose();
198
+ }
199
+ };
200
+
201
+ // Portal to document.body: the main dashboard column in App.tsx is
202
+ // `relative z-2`, which creates a stacking context that traps fixed
203
+ // descendants below the app sidebar (z-50). Without the portal this
204
+ // modal's z-[100] is scoped to z-2 and the sidebar covers its left
205
+ // edge — visible especially in the Large theme variants where the
206
+ // larger root font widens the dialog into the sidebar's column. See
207
+ // Toast.tsx for the same pattern.
208
+ return createPortal(
209
+ <div
210
+ className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
211
+ onClick={(e) => e.target === e.currentTarget && onClose()}
212
+ role="dialog"
213
+ aria-modal="true"
214
+ aria-labelledby="model-picker-title"
215
+ >
216
+ <div className={cn(themedBody, "relative w-full max-w-3xl max-h-[80vh] border border-border bg-card shadow-2xl flex flex-col")}>
217
+ <Button
218
+ ghost
219
+ size="icon"
220
+ onClick={onClose}
221
+ className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
222
+ aria-label="Close"
223
+ >
224
+ <X />
225
+ </Button>
226
+
227
+ <header className="p-5 pb-3 border-b border-border">
228
+ <h2
229
+ id="model-picker-title"
230
+ className="font-mondwest text-display text-base tracking-wider"
231
+ >
232
+ {title}
233
+ </h2>
234
+ <p className="text-xs text-muted-foreground mt-1 font-mono">
235
+ current: {currentModel || "(unknown)"}
236
+ {currentProviderSlug && ` · ${currentProviderSlug}`}
237
+ </p>
238
+ </header>
239
+
240
+ <div className="px-5 pt-3 pb-2 border-b border-border">
241
+ <div className="relative">
242
+ <Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
243
+ <Input
244
+ autoFocus
245
+ placeholder="Filter providers and models…"
246
+ value={query}
247
+ onChange={(e) => setQuery(e.target.value)}
248
+ className="pl-7 h-8 text-sm"
249
+ />
250
+ </div>
251
+ </div>
252
+
253
+ <div className="flex-1 min-h-0 grid grid-cols-[200px_1fr] overflow-hidden">
254
+ <ProviderColumn
255
+ loading={loading}
256
+ error={error}
257
+ providers={filteredProviders}
258
+ total={providers.length}
259
+ selectedSlug={selectedSlug}
260
+ query={needle}
261
+ onSelect={(slug) => {
262
+ setSelectedSlug(slug);
263
+ setSelectedModel("");
264
+ }}
265
+ />
266
+
267
+ <ModelColumn
268
+ provider={selectedProvider}
269
+ models={filteredModels}
270
+ allModels={models}
271
+ selectedModel={selectedModel}
272
+ currentModel={currentModel}
273
+ currentProviderSlug={currentProviderSlug}
274
+ onSelect={setSelectedModel}
275
+ onConfirm={(m) => {
276
+ setSelectedModel(m);
277
+ // Confirm on next tick so state settles.
278
+ window.setTimeout(confirm, 0);
279
+ }}
280
+ />
281
+ </div>
282
+
283
+ <footer className="border-t border-border p-3 flex items-center justify-between gap-3 flex-wrap">
284
+ {alwaysGlobal ? (
285
+ <span className="text-xs text-muted-foreground">
286
+ Saves to config.yaml — applies to new sessions.
287
+ </span>
288
+ ) : (
289
+ <div className="flex items-center gap-2">
290
+ <Checkbox
291
+ checked={persistGlobal}
292
+ id="model-picker-persist-global"
293
+ onCheckedChange={(checked) =>
294
+ setPersistGlobal(checked === true)
295
+ }
296
+ />
297
+
298
+ <Label
299
+ className="font-mondwest normal-case tracking-normal text-xs text-muted-foreground cursor-pointer"
300
+ htmlFor="model-picker-persist-global"
301
+ >
302
+ Persist globally (otherwise this session only)
303
+ </Label>
304
+ </div>
305
+ )}
306
+
307
+ <div className="flex items-center gap-2 ml-auto">
308
+ <Button outlined onClick={onClose} disabled={applying}>
309
+ Cancel
310
+ </Button>
311
+ <Button onClick={confirm} disabled={!canConfirm}>
312
+ {applying ? <Spinner /> : "Switch"}
313
+ </Button>
314
+ </div>
315
+ </footer>
316
+ </div>
317
+ </div>,
318
+ document.body,
319
+ );
320
+ }
321
+
322
+ /* ------------------------------------------------------------------ */
323
+ /* Provider column */
324
+ /* ------------------------------------------------------------------ */
325
+
326
+ function ProviderColumn({
327
+ loading,
328
+ error,
329
+ providers,
330
+ total,
331
+ selectedSlug,
332
+ query,
333
+ onSelect,
334
+ }: {
335
+ loading: boolean;
336
+ error: string | null;
337
+ providers: ModelOptionProvider[];
338
+ total: number;
339
+ selectedSlug: string;
340
+ query: string;
341
+ onSelect(slug: string): void;
342
+ }) {
343
+ return (
344
+ <div className="border-r border-border overflow-y-auto">
345
+ {loading && (
346
+ <div className="flex items-center gap-2 p-4 text-xs text-muted-foreground">
347
+ <Spinner className="text-xs" /> loading…
348
+ </div>
349
+ )}
350
+
351
+ {error && <div className="p-4 text-xs text-destructive">{error}</div>}
352
+
353
+ {!loading && !error && providers.length === 0 && (
354
+ <div className="p-4 text-xs text-muted-foreground italic">
355
+ {query
356
+ ? "no matches"
357
+ : total === 0
358
+ ? "no authenticated providers"
359
+ : "no matches"}
360
+ </div>
361
+ )}
362
+
363
+ {providers.map((p) => {
364
+ const active = p.slug === selectedSlug;
365
+ return (
366
+ <ListItem
367
+ key={p.slug}
368
+ active={active}
369
+ onClick={() => onSelect(p.slug)}
370
+ className={`items-start text-xs border-l-2 ${
371
+ active ? "border-l-primary" : "border-l-transparent"
372
+ }`}
373
+ >
374
+ <div className="flex-1 min-w-0">
375
+ <div className="flex items-center gap-1.5">
376
+ <span className="font-medium truncate">{p.name}</span>
377
+ {p.is_current && <CurrentTag />}
378
+ </div>
379
+ <div className="text-xs text-text-secondary font-mono truncate">
380
+ {p.slug} · {p.total_models ?? p.models?.length ?? 0} models
381
+ </div>
382
+ </div>
383
+ </ListItem>
384
+ );
385
+ })}
386
+ </div>
387
+ );
388
+ }
389
+
390
+ /* ------------------------------------------------------------------ */
391
+ /* Model column */
392
+ /* ------------------------------------------------------------------ */
393
+
394
+ function ModelColumn({
395
+ provider,
396
+ models,
397
+ allModels,
398
+ selectedModel,
399
+ currentModel,
400
+ currentProviderSlug,
401
+ onSelect,
402
+ onConfirm,
403
+ }: {
404
+ provider: ModelOptionProvider | null;
405
+ models: string[];
406
+ allModels: string[];
407
+ selectedModel: string;
408
+ currentModel: string;
409
+ currentProviderSlug: string;
410
+ onSelect(model: string): void;
411
+ onConfirm(model: string): void;
412
+ }) {
413
+ if (!provider) {
414
+ return (
415
+ <div className="overflow-y-auto">
416
+ <div className="p-4 text-xs text-muted-foreground italic">
417
+ pick a provider →
418
+ </div>
419
+ </div>
420
+ );
421
+ }
422
+
423
+ return (
424
+ <div className="overflow-y-auto">
425
+ {provider.warning && (
426
+ <div className="p-3 text-xs text-destructive border-b border-border">
427
+ {provider.warning}
428
+ </div>
429
+ )}
430
+
431
+ {models.length === 0 ? (
432
+ <div className="p-4 text-xs text-muted-foreground italic">
433
+ {allModels.length
434
+ ? "no models match your filter"
435
+ : "no models listed for this provider"}
436
+ </div>
437
+ ) : (
438
+ models.map((m) => {
439
+ const active = m === selectedModel;
440
+ const isCurrent =
441
+ m === currentModel && provider.slug === currentProviderSlug;
442
+
443
+ return (
444
+ <ListItem
445
+ key={m}
446
+ active={active}
447
+ onClick={() => onSelect(m)}
448
+ onDoubleClick={() => onConfirm(m)}
449
+ className="px-3 py-1.5 text-xs font-mono"
450
+ >
451
+ <Check
452
+ className={`h-3 w-3 shrink-0 ${active ? "text-primary" : "text-transparent"}`}
453
+ />
454
+ <span className="flex-1 truncate">{m}</span>
455
+ {isCurrent && <CurrentTag />}
456
+ </ListItem>
457
+ );
458
+ })
459
+ )}
460
+ </div>
461
+ );
462
+ }
463
+
464
+ function CurrentTag() {
465
+ return (
466
+ <span className="text-display text-xs tracking-wider text-primary shrink-0">
467
+ current
468
+ </span>
469
+ );
470
+ }