@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,97 @@
1
+ import { AlertTriangle, Radio, Wifi, WifiOff } from "lucide-react";
2
+ import type { PlatformStatus } from "@/lib/api";
3
+ import { isoTimeAgo } from "@/lib/utils";
4
+ import { Badge } from "@nastechai/ui/ui/components/badge";
5
+ import { Card, CardContent, CardHeader, CardTitle } from "@nastechai/ui/ui/components/card";
6
+ import { useI18n } from "@/i18n";
7
+
8
+ export function PlatformsCard({ platforms }: PlatformsCardProps) {
9
+ const { t } = useI18n();
10
+ const platformStateBadge: Record<
11
+ string,
12
+ { tone: "success" | "warning" | "destructive"; label: string }
13
+ > = {
14
+ connected: { tone: "success", label: t.status.connected },
15
+ disconnected: { tone: "warning", label: t.status.disconnected },
16
+ fatal: { tone: "destructive", label: t.status.error },
17
+ };
18
+
19
+ return (
20
+ <Card>
21
+ <CardHeader>
22
+ <div className="flex items-center gap-2">
23
+ <Radio className="h-5 w-5 text-muted-foreground" />
24
+ <CardTitle className="text-base">
25
+ {t.status.connectedPlatforms}
26
+ </CardTitle>
27
+ </div>
28
+ </CardHeader>
29
+
30
+ <CardContent className="grid gap-3">
31
+ {platforms.map(([name, info]) => {
32
+ const display = platformStateBadge[info.state] ?? {
33
+ tone: "outline" as const,
34
+ label: info.state,
35
+ };
36
+ const IconComponent =
37
+ info.state === "connected"
38
+ ? Wifi
39
+ : info.state === "fatal"
40
+ ? AlertTriangle
41
+ : WifiOff;
42
+
43
+ return (
44
+ <div
45
+ key={name}
46
+ className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 border border-border p-3 w-full"
47
+ >
48
+ <div className="flex items-center gap-3 min-w-0 w-full">
49
+ <IconComponent
50
+ className={`h-4 w-4 shrink-0 ${
51
+ info.state === "connected"
52
+ ? "text-success"
53
+ : info.state === "fatal"
54
+ ? "text-destructive"
55
+ : "text-warning"
56
+ }`}
57
+ />
58
+
59
+ <div className="flex flex-col gap-0.5 min-w-0">
60
+ <span className="font-mondwest normal-case text-sm font-medium capitalize truncate">
61
+ {name}
62
+ </span>
63
+
64
+ {info.error_message && (
65
+ <span className="font-mondwest normal-case text-xs text-destructive">
66
+ {info.error_message}
67
+ </span>
68
+ )}
69
+
70
+ {info.updated_at && (
71
+ <span className="font-mondwest normal-case text-xs text-muted-foreground">
72
+ {t.status.lastUpdate}: {isoTimeAgo(info.updated_at)}
73
+ </span>
74
+ )}
75
+ </div>
76
+ </div>
77
+
78
+ <Badge
79
+ tone={display.tone}
80
+ className="shrink-0 self-start sm:self-center"
81
+ >
82
+ {display.tone === "success" && (
83
+ <span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
84
+ )}
85
+ {display.label}
86
+ </Badge>
87
+ </div>
88
+ );
89
+ })}
90
+ </CardContent>
91
+ </Card>
92
+ );
93
+ }
94
+
95
+ interface PlatformsCardProps {
96
+ platforms: [string, PlatformStatus][];
97
+ }
@@ -0,0 +1,273 @@
1
+ import { useCallback } from "react";
2
+ import { Input } from "@nastechai/ui/ui/components/input";
3
+ import { Label } from "@nastechai/ui/ui/components/label";
4
+ import { Select, SelectOption } from "@nastechai/ui/ui/components/select";
5
+ import { Button } from "@nastechai/ui/ui/components/button";
6
+ import { useI18n } from "@/i18n";
7
+ import {
8
+ buildScheduleString,
9
+ DEFAULT_SCHEDULE_STATE,
10
+ type IntervalUnit,
11
+ type ScheduleBuilderState,
12
+ type ScheduleMode,
13
+ type Weekday,
14
+ WEEKDAY_INDEXES,
15
+ } from "@/lib/schedule";
16
+
17
+ /**
18
+ * Human-readable schedule picker for cron job create/edit flows.
19
+ *
20
+ * Replaces the raw "type a cron expression" input that lived inline in
21
+ * ``CronPage``. The picker still emits a single backend-compatible
22
+ * schedule string (see ``cron/jobs.py::parse_schedule``), but the user
23
+ * fills out shape-appropriate inputs (time picker, weekday toggles,
24
+ * datetime-local field) per mode.
25
+ *
26
+ * Architecture:
27
+ *
28
+ * - The component is fully controlled. Parent owns the
29
+ * ``ScheduleBuilderState`` and the derived schedule string (built
30
+ * via ``buildScheduleString`` in render).
31
+ * - Mode-specific state slots (``timeOfDay``, ``weekdays``, ...) are
32
+ * preserved across mode switches so flipping back to a previous mode
33
+ * doesn't erase the user's work.
34
+ * - The "Custom" mode is an escape hatch — surfacing it as a normal
35
+ * option (instead of hiding it behind an "advanced" toggle) keeps
36
+ * power-user workflows discoverable without making everyone scroll
37
+ * past it.
38
+ */
39
+ export function ScheduleBuilder({ onChange, value }: ScheduleBuilderProps) {
40
+ const { t } = useI18n();
41
+ const cronStrings = t.cron;
42
+ const modeStrings = (cronStrings.scheduleModes ?? {}) as Record<string, string>;
43
+
44
+ const update = useCallback(
45
+ (patch: Partial<ScheduleBuilderState>) => {
46
+ onChange({ ...value, ...patch });
47
+ },
48
+ [onChange, value],
49
+ );
50
+
51
+ const toggleWeekday = useCallback(
52
+ (day: Weekday) => {
53
+ const present = value.weekdays.includes(day);
54
+ update({
55
+ weekdays: present
56
+ ? value.weekdays.filter((d) => d !== day)
57
+ : [...value.weekdays, day],
58
+ });
59
+ },
60
+ [update, value.weekdays],
61
+ );
62
+
63
+ return (
64
+ <div className="grid gap-3">
65
+ <div className="grid gap-2">
66
+ <Label htmlFor="cron-schedule-mode">
67
+ {cronStrings.scheduleMode ?? "Schedule"}
68
+ </Label>
69
+ <Select
70
+ id="cron-schedule-mode"
71
+ value={value.mode}
72
+ onValueChange={(v) => update({ mode: v as ScheduleMode })}
73
+ >
74
+ <SelectOption value="interval">{modeStrings.interval}</SelectOption>
75
+ <SelectOption value="daily">{modeStrings.daily}</SelectOption>
76
+ <SelectOption value="weekly">{modeStrings.weekly}</SelectOption>
77
+ <SelectOption value="monthly">{modeStrings.monthly}</SelectOption>
78
+ <SelectOption value="once">{modeStrings.once}</SelectOption>
79
+ <SelectOption value="custom">{modeStrings.custom}</SelectOption>
80
+ </Select>
81
+ </div>
82
+
83
+ {value.mode === "interval" && (
84
+ <div className="grid grid-cols-[1fr_1.4fr] gap-3">
85
+ <div className="grid gap-2">
86
+ <Label htmlFor="cron-interval-value">
87
+ {modeStrings.intervalEvery}
88
+ </Label>
89
+ <Input
90
+ id="cron-interval-value"
91
+ type="number"
92
+ min={1}
93
+ max={9999}
94
+ value={String(value.intervalValue)}
95
+ onChange={(e) => {
96
+ const n = parseInt(e.target.value, 10);
97
+ update({
98
+ intervalValue: Number.isFinite(n) && n > 0 ? n : 1,
99
+ });
100
+ }}
101
+ />
102
+ </div>
103
+ <div className="grid gap-2">
104
+ <Label htmlFor="cron-interval-unit">{modeStrings.intervalUnit}</Label>
105
+ <Select
106
+ id="cron-interval-unit"
107
+ value={value.intervalUnit}
108
+ onValueChange={(v) => update({ intervalUnit: v as IntervalUnit })}
109
+ >
110
+ <SelectOption value="minutes">
111
+ {modeStrings.unitMinutes}
112
+ </SelectOption>
113
+ <SelectOption value="hours">{modeStrings.unitHours}</SelectOption>
114
+ <SelectOption value="days">{modeStrings.unitDays}</SelectOption>
115
+ </Select>
116
+ </div>
117
+ </div>
118
+ )}
119
+
120
+ {value.mode === "daily" && (
121
+ <TimeOfDayField
122
+ id="cron-daily-time"
123
+ label={modeStrings.timeOfDay}
124
+ value={value.timeOfDay}
125
+ onChange={(timeOfDay) => update({ timeOfDay })}
126
+ />
127
+ )}
128
+
129
+ {value.mode === "weekly" && (
130
+ <>
131
+ <div className="grid gap-2">
132
+ <Label>{modeStrings.weekdays}</Label>
133
+ <div
134
+ className="flex flex-wrap gap-1.5"
135
+ role="group"
136
+ aria-label={modeStrings.weekdays}
137
+ >
138
+ {WEEKDAY_INDEXES.map((d) => {
139
+ const isOn = value.weekdays.includes(d);
140
+ return (
141
+ <Button
142
+ key={d}
143
+ type="button"
144
+ size="sm"
145
+ outlined={!isOn}
146
+ aria-pressed={isOn}
147
+ onClick={() => toggleWeekday(d)}
148
+ className="min-w-[2.5rem] font-mono-ui text-xs uppercase"
149
+ >
150
+ {modeStrings.weekdaysShort[d]}
151
+ </Button>
152
+ );
153
+ })}
154
+ </div>
155
+ </div>
156
+ <TimeOfDayField
157
+ id="cron-weekly-time"
158
+ label={modeStrings.timeOfDay}
159
+ value={value.timeOfDay}
160
+ onChange={(timeOfDay) => update({ timeOfDay })}
161
+ />
162
+ </>
163
+ )}
164
+
165
+ {value.mode === "monthly" && (
166
+ <div className="grid grid-cols-[1fr_1fr] gap-3">
167
+ <div className="grid gap-2">
168
+ <Label htmlFor="cron-month-day">{modeStrings.dayOfMonth}</Label>
169
+ <Input
170
+ id="cron-month-day"
171
+ type="number"
172
+ min={1}
173
+ max={31}
174
+ value={String(value.dayOfMonth)}
175
+ onChange={(e) => {
176
+ const n = parseInt(e.target.value, 10);
177
+ update({
178
+ dayOfMonth:
179
+ Number.isFinite(n) && n >= 1 && n <= 31 ? n : 1,
180
+ });
181
+ }}
182
+ />
183
+ </div>
184
+ <TimeOfDayField
185
+ id="cron-monthly-time"
186
+ label={modeStrings.timeOfDay}
187
+ value={value.timeOfDay}
188
+ onChange={(timeOfDay) => update({ timeOfDay })}
189
+ />
190
+ </div>
191
+ )}
192
+
193
+ {value.mode === "once" && (
194
+ <div className="grid gap-2">
195
+ <Label htmlFor="cron-once-at">{modeStrings.onceAt}</Label>
196
+ {/* Native datetime-local — emits the exact "YYYY-MM-DDTHH:MM"
197
+ shape ``parse_schedule`` accepts on the backend. */}
198
+ <input
199
+ id="cron-once-at"
200
+ type="datetime-local"
201
+ className="flex h-9 w-full border border-border bg-background/40 px-3 py-2 text-sm font-courier shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25"
202
+ value={value.onceAt}
203
+ onChange={(e) => update({ onceAt: e.target.value })}
204
+ />
205
+ </div>
206
+ )}
207
+
208
+ {value.mode === "custom" && (
209
+ <div className="grid gap-2">
210
+ <Label htmlFor="cron-custom-expr">{modeStrings.customLabel}</Label>
211
+ <Input
212
+ id="cron-custom-expr"
213
+ placeholder={modeStrings.customPlaceholder}
214
+ value={value.custom}
215
+ onChange={(e) => update({ custom: e.target.value })}
216
+ className="font-mono-ui"
217
+ />
218
+ <p className="text-xs text-muted-foreground">
219
+ {modeStrings.customHint}
220
+ </p>
221
+ </div>
222
+ )}
223
+
224
+ {/* Inline preview of what we'll send to the backend. Helps users
225
+ eyeball the result before hitting Create, and keeps the
226
+ schedule grammar discoverable for the custom mode. */}
227
+ <p className="text-xs text-muted-foreground">
228
+ <span className="opacity-70">{modeStrings.preview}: </span>
229
+ <span className="font-mono-ui text-foreground">
230
+ {buildScheduleString(value) || modeStrings.previewEmpty}
231
+ </span>
232
+ </p>
233
+ </div>
234
+ );
235
+ }
236
+
237
+ function TimeOfDayField({
238
+ id,
239
+ label,
240
+ onChange,
241
+ value,
242
+ }: TimeOfDayFieldProps) {
243
+ return (
244
+ <div className="grid gap-2">
245
+ <Label htmlFor={id}>{label}</Label>
246
+ {/* Native time picker is the right tool for "HH:MM" — saves us
247
+ two separate hour/minute selects, respects user locale's
248
+ AM/PM preference, and round-trips with ``buildScheduleString``
249
+ without parsing. */}
250
+ <input
251
+ id={id}
252
+ type="time"
253
+ className="flex h-9 w-full border border-border bg-background/40 px-3 py-2 text-sm font-courier shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25"
254
+ value={value}
255
+ onChange={(e) => onChange(e.target.value)}
256
+ />
257
+ </div>
258
+ );
259
+ }
260
+
261
+ export { DEFAULT_SCHEDULE_STATE };
262
+
263
+ interface ScheduleBuilderProps {
264
+ onChange: (state: ScheduleBuilderState) => void;
265
+ value: ScheduleBuilderState;
266
+ }
267
+
268
+ interface TimeOfDayFieldProps {
269
+ id: string;
270
+ label: string;
271
+ onChange: (value: string) => void;
272
+ value: string;
273
+ }
@@ -0,0 +1,42 @@
1
+ import { Typography } from "@nastechai/ui/ui/components/typography/index";
2
+ import type { StatusResponse } from "@/lib/api";
3
+ import { cn } from "@/lib/utils";
4
+ import { useI18n } from "@/i18n";
5
+
6
+ export function SidebarFooter({ status }: SidebarFooterProps) {
7
+ const { t } = useI18n();
8
+
9
+ return (
10
+ <div
11
+ className={cn(
12
+ "flex shrink-0 items-center justify-between gap-2",
13
+ "px-5 py-2.5",
14
+ "border-t border-current/10",
15
+ )}
16
+ >
17
+ <Typography
18
+ className="font-mono-ui text-xs tabular-nums tracking-[0.08em] text-text-tertiary lowercase"
19
+ >
20
+ {status?.version != null ? `v${status.version}` : "—"}
21
+ </Typography>
22
+
23
+ <a
24
+ href="https://nastech.com"
25
+ target="_blank"
26
+ rel="noopener noreferrer"
27
+ className={cn(
28
+ "font-mondwest text-display text-xs tracking-[0.12em] text-midground",
29
+ "transition-opacity hover:opacity-90",
30
+ "focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground/40",
31
+ )}
32
+ style={{ mixBlendMode: "plus-lighter" }}
33
+ >
34
+ {t.app.footer.org}
35
+ </a>
36
+ </div>
37
+ );
38
+ }
39
+
40
+ interface SidebarFooterProps {
41
+ status: StatusResponse | null;
42
+ }
@@ -0,0 +1,72 @@
1
+ import { Link } from "react-router-dom";
2
+ import type { StatusResponse } from "@/lib/api";
3
+ import { cn } from "@/lib/utils";
4
+ import { useI18n } from "@/i18n";
5
+
6
+ /** Gateway + session summary for the System sidebar block (no separate strip chrome). */
7
+ export function SidebarStatusStrip({ status }: SidebarStatusStripProps) {
8
+ const { t } = useI18n();
9
+
10
+ if (status === null) {
11
+ return (
12
+ <div className="px-5 py-1.5" aria-hidden>
13
+ <div className="h-2 w-[80%] max-w-full animate-pulse rounded-sm bg-midground/10" />
14
+ </div>
15
+ );
16
+ }
17
+
18
+ const gw = gatewayLine(status, t);
19
+ const { activeSessionsLabel, gatewayStatusLabel } = t.app;
20
+
21
+ return (
22
+ <Link
23
+ to="/sessions"
24
+ title={t.app.statusOverview}
25
+ className={cn(
26
+ "block text-left",
27
+ "px-5 pb-2 pt-0.5",
28
+ "text-text-secondary",
29
+ "transition-colors hover:text-midground",
30
+ "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground/40",
31
+ "focus-visible:ring-inset",
32
+ )}
33
+ >
34
+ <div className="flex flex-col gap-1 font-mondwest text-xs leading-snug tracking-[0.08em]">
35
+ <p className="break-words">
36
+ <span className="text-text-tertiary">{gatewayStatusLabel}</span>{" "}
37
+ <span className={cn("font-medium", gw.tone)}>{gw.label}</span>
38
+ </p>
39
+
40
+ <p className="break-words">
41
+ <span className="text-text-tertiary">{activeSessionsLabel}</span>{" "}
42
+ <span className="tabular-nums text-text-secondary">
43
+ {status.active_sessions}
44
+ </span>
45
+ </p>
46
+ </div>
47
+ </Link>
48
+ );
49
+ }
50
+
51
+ export function gatewayLine(
52
+ status: StatusResponse,
53
+ t: ReturnType<typeof useI18n>["t"],
54
+ ): { label: string; tone: string } {
55
+ const g = t.app.gatewayStrip;
56
+ const byState: Record<string, { label: string; tone: string }> = {
57
+ running: { label: g.running, tone: "text-success" },
58
+ starting: { label: g.starting, tone: "text-warning" },
59
+ startup_failed: { label: g.failed, tone: "text-destructive" },
60
+ stopped: { label: g.stopped, tone: "text-muted-foreground" },
61
+ };
62
+ if (status.gateway_state && byState[status.gateway_state]) {
63
+ return byState[status.gateway_state];
64
+ }
65
+ return status.gateway_running
66
+ ? { label: g.running, tone: "text-success" }
67
+ : { label: g.off, tone: "text-muted-foreground" };
68
+ }
69
+
70
+ interface SidebarStatusStripProps {
71
+ status: StatusResponse | null;
72
+ }
@@ -0,0 +1,171 @@
1
+ import type { GatewayClient } from "@/lib/gatewayClient";
2
+ import { ListItem } from "@nastechai/ui/ui/components/list-item";
3
+ import { ChevronRight } from "lucide-react";
4
+ import {
5
+ forwardRef,
6
+ useCallback,
7
+ useEffect,
8
+ useImperativeHandle,
9
+ useRef,
10
+ useState,
11
+ } from "react";
12
+
13
+ /**
14
+ * Slash-command autocomplete popover, rendered above the composer in ChatPage.
15
+ * Mirrors the completion UX of the Ink TUI — type `/`, see matching commands,
16
+ * arrow keys or click to select, Tab to apply, Enter to submit.
17
+ *
18
+ * The parent owns all keyboard handling via `ref.handleKey`, which returns
19
+ * true when the popover consumed the event, so the composer's Enter/arrow
20
+ * logic stays in one place.
21
+ */
22
+
23
+ export interface CompletionItem {
24
+ display: string;
25
+ text: string;
26
+ meta?: string;
27
+ }
28
+
29
+ export interface SlashPopoverHandle {
30
+ /** Returns true if the key was consumed by the popover. */
31
+ handleKey(e: React.KeyboardEvent<HTMLTextAreaElement>): boolean;
32
+ }
33
+
34
+ interface Props {
35
+ input: string;
36
+ gw: GatewayClient | null;
37
+ onApply(nextInput: string): void;
38
+ }
39
+
40
+ interface CompletionResponse {
41
+ items?: CompletionItem[];
42
+ replace_from?: number;
43
+ }
44
+
45
+ const DEBOUNCE_MS = 60;
46
+
47
+ export const SlashPopover = forwardRef<SlashPopoverHandle, Props>(
48
+ function SlashPopover({ input, gw, onApply }, ref) {
49
+ const [items, setItems] = useState<CompletionItem[]>([]);
50
+ const [selected, setSelected] = useState(0);
51
+ const [replaceFrom, setReplaceFrom] = useState(1);
52
+ const lastInputRef = useRef<string>("");
53
+
54
+ // Debounced completion fetch. We never clear `items` in the effect body
55
+ // (doing so would flag react-hooks/set-state-in-effect); instead the
56
+ // render guard below hides stale items once the input stops matching.
57
+ useEffect(() => {
58
+ const trimmed = input ?? "";
59
+
60
+ if (!gw || !trimmed.startsWith("/") || trimmed === lastInputRef.current) {
61
+ if (!trimmed.startsWith("/")) lastInputRef.current = "";
62
+ return;
63
+ }
64
+ lastInputRef.current = trimmed;
65
+
66
+ const timer = window.setTimeout(async () => {
67
+ if (lastInputRef.current !== trimmed) return;
68
+ try {
69
+ const r = await gw.request<CompletionResponse>("complete.slash", {
70
+ text: trimmed,
71
+ });
72
+ if (lastInputRef.current !== trimmed) return;
73
+ setItems(r?.items ?? []);
74
+ setReplaceFrom(r?.replace_from ?? 1);
75
+ setSelected(0);
76
+ } catch {
77
+ if (lastInputRef.current === trimmed) setItems([]);
78
+ }
79
+ }, DEBOUNCE_MS);
80
+
81
+ return () => window.clearTimeout(timer);
82
+ }, [input, gw]);
83
+
84
+ const apply = useCallback(
85
+ (item: CompletionItem) => {
86
+ onApply(input.slice(0, replaceFrom) + item.text);
87
+ },
88
+ [input, replaceFrom, onApply],
89
+ );
90
+
91
+ // Only consume keys when the popover is actually visible. Stale items from
92
+ // a previous slash prefix are ignored once the user deletes the "/".
93
+ const visible = items.length > 0 && input.startsWith("/");
94
+
95
+ useImperativeHandle(
96
+ ref,
97
+ () => ({
98
+ handleKey: (e) => {
99
+ if (!visible) return false;
100
+
101
+ switch (e.key) {
102
+ case "ArrowDown":
103
+ e.preventDefault();
104
+ setSelected((s) => (s + 1) % items.length);
105
+ return true;
106
+
107
+ case "ArrowUp":
108
+ e.preventDefault();
109
+ setSelected((s) => (s - 1 + items.length) % items.length);
110
+ return true;
111
+
112
+ case "Tab": {
113
+ e.preventDefault();
114
+ const item = items[selected];
115
+ if (item) apply(item);
116
+ return true;
117
+ }
118
+
119
+ case "Escape":
120
+ e.preventDefault();
121
+ setItems([]);
122
+ return true;
123
+
124
+ default:
125
+ return false;
126
+ }
127
+ },
128
+ }),
129
+ [visible, items, selected, apply],
130
+ );
131
+
132
+ if (!visible) return null;
133
+
134
+ return (
135
+ <div
136
+ className="absolute bottom-full left-0 right-0 mb-2 max-h-64 overflow-y-auto rounded-md border border-border bg-popover shadow-xl text-sm"
137
+ role="listbox"
138
+ >
139
+ {items.map((it, i) => {
140
+ const active = i === selected;
141
+
142
+ return (
143
+ <ListItem
144
+ key={`${it.text}-${i}`}
145
+ active={active}
146
+ role="option"
147
+ aria-selected={active}
148
+ onMouseEnter={() => setSelected(i)}
149
+ onClick={() => apply(it)}
150
+ className="px-3 py-1.5"
151
+ >
152
+ <ChevronRight
153
+ className={`h-3 w-3 shrink-0 ${active ? "text-primary" : "text-transparent"}`}
154
+ />
155
+
156
+ <span className="font-mono text-xs shrink-0 truncate">
157
+ {it.display}
158
+ </span>
159
+
160
+ {it.meta && (
161
+ <span className="text-xs text-text-tertiary truncate ml-auto">
162
+ {it.meta}
163
+ </span>
164
+ )}
165
+ </ListItem>
166
+ );
167
+ })}
168
+ </div>
169
+ );
170
+ },
171
+ );