@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,382 @@
1
+ /**
2
+ * Schedule builder helpers for the cron page.
3
+ *
4
+ * The nastech-agent backend (cron/jobs.py::parse_schedule) accepts a
5
+ * surprisingly broad set of string formats:
6
+ *
7
+ * - Duration (one-shot): "30m", "2h", "1d"
8
+ * - Interval (recurring): "every 30m", "every 2h", "every 1d"
9
+ * - Cron expression (5-field): "0 9 * * *", "30 14 * * 1,3,5"
10
+ * - ISO timestamp (one-shot): "2026-02-03T14:00:00"
11
+ *
12
+ * Power users can hand-type any of those, but for everyone else the
13
+ * dashboard now offers a human-readable picker. This module is the
14
+ * pure logic layer behind that picker:
15
+ *
16
+ * - {@link buildScheduleString} turns the picker's structured state
17
+ * into one of the strings above.
18
+ * - {@link describeSchedule} goes the other way: takes the structured
19
+ * schedule shape the API returns (``CronJob.schedule``) and produces
20
+ * a human-readable sentence for the job list. It recognises common
21
+ * cron-expression shapes (daily/weekly/monthly) so users don't have
22
+ * to parse "30 14 * * 1,3,5" by eye.
23
+ *
24
+ * Kept dependency-free and locale-string-driven so it tree-shakes
25
+ * cleanly and is testable in isolation if we ever wire up vitest here.
26
+ */
27
+
28
+ /** Picker modes — each renders a different set of inputs in the UI but
29
+ * all funnel through {@link buildScheduleString} to a backend-compatible
30
+ * string. ``custom`` is the escape hatch for power users who still want
31
+ * to type a raw cron expression. */
32
+ export type ScheduleMode =
33
+ | "interval"
34
+ | "daily"
35
+ | "weekly"
36
+ | "monthly"
37
+ | "once"
38
+ | "custom";
39
+
40
+ /** Unit used by interval mode. Backend parses ``m``/``h``/``d`` suffixes. */
41
+ export type IntervalUnit = "minutes" | "hours" | "days";
42
+
43
+ /** Cron weekday convention: Sunday = 0 .. Saturday = 6. Matches what
44
+ * croniter expects on the backend (no need to remap on submit). */
45
+ export const WEEKDAY_INDEXES = [0, 1, 2, 3, 4, 5, 6] as const;
46
+ export type Weekday = (typeof WEEKDAY_INDEXES)[number];
47
+
48
+ export interface ScheduleBuilderState {
49
+ /** Index of which "custom" radio is selected. */
50
+ mode: ScheduleMode;
51
+
52
+ /** Interval mode: positive integer, paired with ``intervalUnit``. */
53
+ intervalValue: number;
54
+ intervalUnit: IntervalUnit;
55
+
56
+ /** Daily/weekly/monthly mode: "HH:MM" 24h format from <input type=time>. */
57
+ timeOfDay: string;
58
+
59
+ /** Weekly mode: 0..6, Sunday-first. Empty means "every day", which is
60
+ * still valid — we send "*" for the day-of-week cron field. */
61
+ weekdays: Weekday[];
62
+
63
+ /** Monthly mode: 1..31 (no support for "last day of month" sugar — the
64
+ * croniter ``L`` extension isn't enabled in the parse_schedule regex). */
65
+ dayOfMonth: number;
66
+
67
+ /** Once mode: ``YYYY-MM-DDTHH:MM`` from <input type=datetime-local>. */
68
+ onceAt: string;
69
+
70
+ /** Custom mode: raw user-typed cron expression. Stored separately so
71
+ * flipping between modes doesn't erase the user's work. */
72
+ custom: string;
73
+ }
74
+
75
+ /** Default state — "every 30 minutes" is the most-common-cron-pattern
76
+ * starting point and avoids forcing the user to pick everything from
77
+ * scratch. */
78
+ export const DEFAULT_SCHEDULE_STATE: ScheduleBuilderState = {
79
+ mode: "interval",
80
+ intervalValue: 30,
81
+ intervalUnit: "minutes",
82
+ timeOfDay: "09:00",
83
+ weekdays: [1, 2, 3, 4, 5],
84
+ dayOfMonth: 1,
85
+ onceAt: "",
86
+ custom: "",
87
+ };
88
+
89
+ const UNIT_SUFFIX: Record<IntervalUnit, string> = {
90
+ minutes: "m",
91
+ hours: "h",
92
+ days: "d",
93
+ };
94
+
95
+ /** Build the schedule string from picker state. Returns ``""`` when the
96
+ * state is incomplete enough that the backend would 400 — the caller
97
+ * uses that to disable the Submit button.
98
+ *
99
+ * Why we lean on the broad parse_schedule grammar instead of always
100
+ * emitting cron expressions: interval syntax ("every 30m") survives a
101
+ * backend without ``croniter`` installed and renders more readably in
102
+ * the job list. We only emit raw cron when the picker truly needs the
103
+ * cron field expressiveness (specific weekdays, specific day-of-month). */
104
+ export function buildScheduleString(state: ScheduleBuilderState): string {
105
+ switch (state.mode) {
106
+ case "interval": {
107
+ const n = Math.floor(state.intervalValue);
108
+ if (!Number.isFinite(n) || n < 1) return "";
109
+ return `every ${n}${UNIT_SUFFIX[state.intervalUnit]}`;
110
+ }
111
+ case "daily": {
112
+ const parsed = parseTimeOfDay(state.timeOfDay);
113
+ if (!parsed) return "";
114
+ return `${parsed.minute} ${parsed.hour} * * *`;
115
+ }
116
+ case "weekly": {
117
+ const parsed = parseTimeOfDay(state.timeOfDay);
118
+ if (!parsed) return "";
119
+ // Empty weekday selection → "*" (every day) rather than a backend
120
+ // 400. The Daily mode is the cleaner choice for that, but if the
121
+ // user toggles all days off in Weekly mode we still emit a valid
122
+ // expression instead of breaking the submit.
123
+ const days =
124
+ state.weekdays.length === 0
125
+ ? "*"
126
+ : [...state.weekdays].sort((a, b) => a - b).join(",");
127
+ return `${parsed.minute} ${parsed.hour} * * ${days}`;
128
+ }
129
+ case "monthly": {
130
+ const parsed = parseTimeOfDay(state.timeOfDay);
131
+ if (!parsed) return "";
132
+ const dom = Math.floor(state.dayOfMonth);
133
+ if (!Number.isFinite(dom) || dom < 1 || dom > 31) return "";
134
+ return `${parsed.minute} ${parsed.hour} ${dom} * *`;
135
+ }
136
+ case "once": {
137
+ const v = state.onceAt.trim();
138
+ if (!v) return "";
139
+ // <input type=datetime-local> already emits the
140
+ // "YYYY-MM-DDTHH:MM" shape that fromisoformat() accepts directly.
141
+ // Append ":00" so the backend's regex hits the "T" branch and
142
+ // the seconds component lines up with isoformat() output.
143
+ return v.length === 16 ? `${v}:00` : v;
144
+ }
145
+ case "custom":
146
+ return state.custom.trim();
147
+ }
148
+ }
149
+
150
+ function parseTimeOfDay(value: string): { hour: number; minute: number } | null {
151
+ if (!value || !/^\d{1,2}:\d{2}$/.test(value)) return null;
152
+ const [hh, mm] = value.split(":");
153
+ const hour = parseInt(hh, 10);
154
+ const minute = parseInt(mm, 10);
155
+ if (
156
+ !Number.isFinite(hour) ||
157
+ !Number.isFinite(minute) ||
158
+ hour < 0 ||
159
+ hour > 23 ||
160
+ minute < 0 ||
161
+ minute > 59
162
+ ) {
163
+ return null;
164
+ }
165
+ return { hour, minute };
166
+ }
167
+
168
+ /** Translation surface the human-readable describer needs. Passing it
169
+ * in (instead of importing ``useI18n``) keeps the helper pure and
170
+ * testable; the CronPage threads ``t.cron.scheduleDescribe`` through. */
171
+ export interface ScheduleDescribeStrings {
172
+ /** Display when no schedule can be resolved (e.g. legacy/blank job). */
173
+ none: string;
174
+ /** "Every {n} minute(s)" — caller pluralises via {n}. */
175
+ everyMinutes: string;
176
+ everyHours: string;
177
+ everyDays: string;
178
+ /** "Daily at {time}" */
179
+ dailyAt: string;
180
+ /** "Weekly on {days} at {time}" */
181
+ weeklyAt: string;
182
+ /** "Monthly on the {day} at {time}" */
183
+ monthlyAt: string;
184
+ /** "Once at {time}" */
185
+ onceAt: string;
186
+ /** Weekday short names indexed 0..6 (Sunday-first). */
187
+ weekdaysShort: [string, string, string, string, string, string, string];
188
+ /** Ordinal suffix builder, e.g. "1st", "22nd". For locales that
189
+ * don't use English ordinals, just return ``String(day)``. */
190
+ ordinal: (day: number) => string;
191
+ }
192
+
193
+ /** Schedule shape stored on a ``CronJob`` row (see api.ts). */
194
+ export interface ScheduleLike {
195
+ kind?: string;
196
+ expr?: string;
197
+ minutes?: number;
198
+ run_at?: string;
199
+ display?: string;
200
+ }
201
+
202
+ /** Human-readable description of a stored schedule.
203
+ *
204
+ * Prefers a structured render over the raw ``display`` string so cron
205
+ * expressions like ``30 14 * * 1,3,5`` show up as "Weekly on Mon, Wed,
206
+ * Fri at 14:30" instead of the raw five-field gibberish. Falls back to
207
+ * ``display`` / ``expr`` / ``none`` in that order if we can't make sense
208
+ * of the schedule (e.g. exotic cron with ranges, step values, or @reboot
209
+ * macros that we'd misrepresent if we tried to "humanize"). */
210
+ export function describeSchedule(
211
+ schedule: ScheduleLike | undefined,
212
+ fallbackDisplay: string | undefined,
213
+ strings: ScheduleDescribeStrings,
214
+ ): string {
215
+ if (!schedule) return fallbackDisplay || strings.none;
216
+
217
+ if (schedule.kind === "interval" && typeof schedule.minutes === "number") {
218
+ return describeInterval(schedule.minutes, strings);
219
+ }
220
+
221
+ if (schedule.kind === "once" && schedule.run_at) {
222
+ return strings.onceAt.replace(
223
+ "{time}",
224
+ formatIsoLocal(schedule.run_at, false),
225
+ );
226
+ }
227
+
228
+ if (schedule.kind === "cron" && schedule.expr) {
229
+ const cronDesc = describeCronExpression(schedule.expr, strings);
230
+ if (cronDesc) return cronDesc;
231
+ }
232
+
233
+ // Try the raw expression as a last attempt — for legacy jobs stored
234
+ // without ``kind``, the ``schedule_display`` field often *is* the cron
235
+ // expression.
236
+ if (fallbackDisplay) {
237
+ const cronDesc = describeCronExpression(fallbackDisplay, strings);
238
+ if (cronDesc) return cronDesc;
239
+ return fallbackDisplay;
240
+ }
241
+ if (schedule.display) return schedule.display;
242
+ if (schedule.expr) return schedule.expr;
243
+ return strings.none;
244
+ }
245
+
246
+ function describeInterval(
247
+ minutes: number,
248
+ strings: ScheduleDescribeStrings,
249
+ ): string {
250
+ if (minutes <= 0) return strings.none;
251
+ if (minutes % 1440 === 0) {
252
+ return strings.everyDays.replace("{n}", String(minutes / 1440));
253
+ }
254
+ if (minutes % 60 === 0) {
255
+ return strings.everyHours.replace("{n}", String(minutes / 60));
256
+ }
257
+ return strings.everyMinutes.replace("{n}", String(minutes));
258
+ }
259
+
260
+ /** Recognise the common, well-shaped cron patterns and return a
261
+ * human sentence for them. Returns ``null`` when the expression has any
262
+ * ranges, steps, or other complexity that would be misleading to
263
+ * "humanize" — caller falls back to displaying the raw expression so
264
+ * the user sees what's actually scheduled.
265
+ *
266
+ * Strictly 5-field only: the backend ``parse_schedule`` also accepts the
267
+ * 6-field ``minute hour dom month dow year`` form, but humanising those
268
+ * by destructuring only the first five fields would silently drop the
269
+ * year and mislead the user (e.g. ``0 9 * * * 2099`` would read as
270
+ * "Daily at 09:00"). 6+ field expressions intentionally fall through to
271
+ * the raw-string fallback in {@link describeSchedule}. */
272
+ function describeCronExpression(
273
+ expr: string,
274
+ strings: ScheduleDescribeStrings,
275
+ ): string | null {
276
+ const parts = expr.trim().split(/\s+/);
277
+ if (parts.length !== 5) return null;
278
+ const [minField, hourField, domField, monField, dowField] = parts;
279
+
280
+ const month = monField === "*";
281
+ if (!month) return null; // we don't try to humanize per-month rules
282
+
283
+ const isLiteralOrList = (f: string) =>
284
+ /^\d+(,\d+)*$/.test(f) || /^\*$/.test(f);
285
+ if (!isLiteralOrList(minField) || !isLiteralOrList(hourField)) return null;
286
+ if (!isLiteralOrList(domField) || !isLiteralOrList(dowField)) return null;
287
+
288
+ // Star minutes/hours would mean "every minute" / "every hour" — we'd
289
+ // need a step-value handler ("*/15") to describe that cleanly, and
290
+ // that path is power-user territory. Bail to raw display.
291
+ if (minField === "*" || hourField === "*") return null;
292
+
293
+ const minutes = minField.split(",").map((n) => parseInt(n, 10));
294
+ const hours = hourField.split(",").map((n) => parseInt(n, 10));
295
+ if (minutes.length !== 1 || hours.length !== 1) return null;
296
+ if (
297
+ !Number.isFinite(minutes[0]) ||
298
+ !Number.isFinite(hours[0]) ||
299
+ hours[0] < 0 ||
300
+ hours[0] > 23 ||
301
+ minutes[0] < 0 ||
302
+ minutes[0] > 59
303
+ ) {
304
+ return null;
305
+ }
306
+ const time = `${pad2(hours[0])}:${pad2(minutes[0])}`;
307
+
308
+ const domAll = domField === "*";
309
+ const dowAll = dowField === "*";
310
+
311
+ if (domAll && dowAll) {
312
+ return strings.dailyAt.replace("{time}", time);
313
+ }
314
+
315
+ if (domAll && !dowAll) {
316
+ const days = dowField
317
+ .split(",")
318
+ .map((n) => parseInt(n, 10))
319
+ .filter((n) => Number.isFinite(n) && n >= 0 && n <= 6) as Weekday[];
320
+ if (days.length === 0) return null;
321
+ const labels = days
322
+ .map((d) => strings.weekdaysShort[d])
323
+ .filter(Boolean)
324
+ .join(", ");
325
+ return strings.weeklyAt
326
+ .replace("{days}", labels)
327
+ .replace("{time}", time);
328
+ }
329
+
330
+ if (!domAll && dowAll) {
331
+ const dom = parseInt(domField, 10);
332
+ if (!Number.isFinite(dom) || dom < 1 || dom > 31) return null;
333
+ return strings.monthlyAt
334
+ .replace("{day}", strings.ordinal(dom))
335
+ .replace("{time}", time);
336
+ }
337
+
338
+ // Both day-of-month AND day-of-week set is unusual and cron's
339
+ // OR-semantics for that combo are confusing — fall back to raw.
340
+ return null;
341
+ }
342
+
343
+ function pad2(n: number): string {
344
+ return n < 10 ? `0${n}` : String(n);
345
+ }
346
+
347
+ /** Format an ISO date for inline display. Drops the seconds + TZ
348
+ * suffix so the cron list stays compact. Falls back to the raw string
349
+ * if Date parsing fails. */
350
+ function formatIsoLocal(iso: string, includeSeconds: boolean): string {
351
+ const d = new Date(iso);
352
+ if (Number.isNaN(d.getTime())) return iso;
353
+ const yyyy = d.getFullYear();
354
+ const mm = pad2(d.getMonth() + 1);
355
+ const dd = pad2(d.getDate());
356
+ const hh = pad2(d.getHours());
357
+ const mi = pad2(d.getMinutes());
358
+ if (includeSeconds) {
359
+ return `${yyyy}-${mm}-${dd} ${hh}:${mi}:${pad2(d.getSeconds())}`;
360
+ }
361
+ return `${yyyy}-${mm}-${dd} ${hh}:${mi}`;
362
+ }
363
+
364
+ /** Convenience: build an English ordinal suffix ("1st", "2nd", "23rd").
365
+ * Most non-English locales should just return ``String(day)`` from
366
+ * their ``ordinal`` override. */
367
+ export function englishOrdinal(day: number): string {
368
+ const d = Math.floor(day);
369
+ if (!Number.isFinite(d) || d < 1) return String(day);
370
+ const lastTwo = d % 100;
371
+ if (lastTwo >= 11 && lastTwo <= 13) return `${d}th`;
372
+ switch (d % 10) {
373
+ case 1:
374
+ return `${d}st`;
375
+ case 2:
376
+ return `${d}nd`;
377
+ case 3:
378
+ return `${d}rd`;
379
+ default:
380
+ return `${d}th`;
381
+ }
382
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Slash command execution pipeline for the web chat.
3
+ *
4
+ * Mirrors the Ink TUI's createSlashHandler.ts:
5
+ *
6
+ * 1. Parse the command into `name` + `arg`.
7
+ * 2. Try `slash.exec` — covers every registry-backed command the terminal
8
+ * UI knows about (/help, /resume, /compact, /model, …). Output is
9
+ * rendered into the transcript.
10
+ * 3. If `slash.exec` errors (command rejected, unknown, or needs client
11
+ * behaviour), fall back to `command.dispatch` which returns a typed
12
+ * directive: `exec` | `plugin` | `alias` | `skill` | `send`.
13
+ * 4. Each directive is dispatched to the appropriate callback.
14
+ *
15
+ * Keeping the pipeline here (instead of inline in ChatPage) lets future
16
+ * clients (SwiftUI, Android) implement the same logic by reading the same
17
+ * contract.
18
+ */
19
+
20
+ import type { GatewayClient } from "@/lib/gatewayClient";
21
+
22
+ export interface SlashExecResponse {
23
+ output?: string;
24
+ warning?: string;
25
+ }
26
+
27
+ export type CommandDispatchResponse =
28
+ | { type: "exec" | "plugin"; output?: string }
29
+ | { type: "alias"; target: string }
30
+ | { type: "skill"; name: string; message?: string }
31
+ | { type: "send"; message: string };
32
+
33
+ export interface SlashExecCallbacks {
34
+ /** Render a transcript system message. */
35
+ sys(text: string): void;
36
+ /** Submit a user message to the agent (prompt.submit). */
37
+ send(message: string): Promise<void> | void;
38
+ }
39
+
40
+ export interface SlashExecOptions {
41
+ /** Raw command including the leading slash (e.g. "/model opus-4.6"). */
42
+ command: string;
43
+ /** Session id. If empty the call is still issued — some commands are session-less. */
44
+ sessionId: string;
45
+ gw: GatewayClient;
46
+ callbacks: SlashExecCallbacks;
47
+ }
48
+
49
+ export type SlashExecResult = "done" | "sent" | "error";
50
+
51
+ /**
52
+ * Run a slash command. Returns the terminal state so callers can decide
53
+ * whether to clear the composer, queue retries, etc.
54
+ */
55
+ export async function executeSlash({
56
+ command,
57
+ sessionId,
58
+ gw,
59
+ callbacks: { sys, send },
60
+ }: SlashExecOptions): Promise<SlashExecResult> {
61
+ const { name, arg } = parseSlash(command);
62
+
63
+ if (!name) {
64
+ sys("empty slash command");
65
+ return "error";
66
+ }
67
+
68
+ // Primary dispatcher.
69
+ try {
70
+ const r = await gw.request<SlashExecResponse>("slash.exec", {
71
+ command: command.replace(/^\/+/, ""),
72
+ session_id: sessionId,
73
+ });
74
+ const body = r?.output || `/${name}: no output`;
75
+ sys(r?.warning ? `warning: ${r.warning}\n${body}` : body);
76
+ return "done";
77
+ } catch {
78
+ /* fall through to command.dispatch */
79
+ }
80
+
81
+ try {
82
+ const d = parseCommandDispatch(
83
+ await gw.request<unknown>("command.dispatch", {
84
+ name,
85
+ arg,
86
+ session_id: sessionId,
87
+ }),
88
+ );
89
+
90
+ if (!d) {
91
+ sys("error: invalid response: command.dispatch");
92
+ return "error";
93
+ }
94
+
95
+ switch (d.type) {
96
+ case "exec":
97
+ case "plugin":
98
+ sys(d.output ?? "(no output)");
99
+ return "done";
100
+
101
+ case "alias":
102
+ return executeSlash({
103
+ command: `/${d.target}${arg ? ` ${arg}` : ""}`,
104
+ sessionId,
105
+ gw,
106
+ callbacks: { sys, send },
107
+ });
108
+
109
+ case "skill":
110
+ case "send": {
111
+ const msg = d.message?.trim() ?? "";
112
+ if (!msg) {
113
+ sys(
114
+ `/${name}: ${d.type === "skill" ? "skill payload missing message" : "empty message"}`,
115
+ );
116
+ return "error";
117
+ }
118
+ if (d.type === "skill") sys(`⚡ loading skill: ${d.name}`);
119
+ await send(msg);
120
+ return "sent";
121
+ }
122
+ }
123
+ } catch (err) {
124
+ sys(`error: ${err instanceof Error ? err.message : String(err)}`);
125
+ return "error";
126
+ }
127
+ }
128
+
129
+ export function parseSlash(command: string): { name: string; arg: string } {
130
+ const m = command.replace(/^\/+/, "").match(/^(\S+)\s*(.*)$/);
131
+ return m ? { name: m[1], arg: m[2].trim() } : { name: "", arg: "" };
132
+ }
133
+
134
+ function parseCommandDispatch(raw: unknown): CommandDispatchResponse | null {
135
+ if (!raw || typeof raw !== "object") return null;
136
+
137
+ const r = raw as Record<string, unknown>;
138
+ const str = (v: unknown) => (typeof v === "string" ? v : undefined);
139
+
140
+ switch (r.type) {
141
+ case "exec":
142
+ case "plugin":
143
+ return { type: r.type, output: str(r.output) };
144
+
145
+ case "alias":
146
+ return typeof r.target === "string"
147
+ ? { type: "alias", target: r.target }
148
+ : null;
149
+
150
+ case "skill":
151
+ return typeof r.name === "string"
152
+ ? { type: "skill", name: r.name, message: str(r.message) }
153
+ : null;
154
+
155
+ case "send":
156
+ return typeof r.message === "string"
157
+ ? { type: "send", message: r.message }
158
+ : null;
159
+
160
+ default:
161
+ return null;
162
+ }
163
+ }
@@ -0,0 +1,35 @@
1
+ import { type ClassValue, clsx } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
7
+
8
+ /** Mondwest font only — use on layout shells; do not force normal-case here or `text-display` chrome (Segmented, badges) stops uppercasing. */
9
+ export const themedFont = "font-mondwest";
10
+
11
+ /** Mondwest body copy — sentence-case themed text (not uppercase chrome). */
12
+ export const themedBody = "font-mondwest normal-case";
13
+
14
+ /** Mondwest brand chrome — uppercase section headers and nav labels. */
15
+ export const themedChrome = "font-mondwest text-display";
16
+
17
+ /** Relative time from a Unix epoch timestamp (seconds). */
18
+ export function timeAgo(ts: number): string {
19
+ const delta = Date.now() / 1000 - ts;
20
+ if (delta < 60) return "just now";
21
+ if (delta < 3600) return `${Math.floor(delta / 60)}m ago`;
22
+ if (delta < 86400) return `${Math.floor(delta / 3600)}h ago`;
23
+ if (delta < 172800) return "yesterday";
24
+ return `${Math.floor(delta / 86400)}d ago`;
25
+ }
26
+
27
+ /** Relative time from an ISO-8601 timestamp string. */
28
+ export function isoTimeAgo(iso: string): string {
29
+ const delta = (Date.now() - new Date(iso).getTime()) / 1000;
30
+ if (delta < 0 || Number.isNaN(delta)) return "unknown";
31
+ if (delta < 60) return "just now";
32
+ if (delta < 3600) return `${Math.floor(delta / 60)}m ago`;
33
+ if (delta < 86400) return `${Math.floor(delta / 3600)}h ago`;
34
+ return `${Math.floor(delta / 86400)}d ago`;
35
+ }
package/src/main.tsx ADDED
@@ -0,0 +1,25 @@
1
+ import { createRoot } from "react-dom/client";
2
+ import { BrowserRouter } from "react-router-dom";
3
+ import "./index.css";
4
+ import App from "./App";
5
+ import { SystemActionsProvider } from "./contexts/SystemActions";
6
+ import { I18nProvider } from "./i18n";
7
+ import { exposePluginSDK } from "./plugins";
8
+ import { ThemeProvider } from "./themes";
9
+ import { NASTECH_BASE_PATH } from "./lib/api";
10
+
11
+ // Expose the plugin SDK before rendering so plugins loaded via <script>
12
+ // can access React, components, etc. immediately.
13
+ exposePluginSDK();
14
+
15
+ createRoot(document.getElementById("root")!).render(
16
+ <BrowserRouter basename={NASTECH_BASE_PATH || undefined}>
17
+ <I18nProvider>
18
+ <ThemeProvider>
19
+ <SystemActionsProvider>
20
+ <App />
21
+ </SystemActionsProvider>
22
+ </ThemeProvider>
23
+ </I18nProvider>
24
+ </BrowserRouter>,
25
+ );