@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,889 @@
1
+ /**
2
+ * ChatPage — embeds `nastech --tui` inside the dashboard.
3
+ *
4
+ * <div host> (dashboard chrome) .
5
+ * └─ <div wrapper> (rounded, dark bg, padded — the "terminal window" .
6
+ * look that gives the page a distinct visual identity) .
7
+ * └─ @xterm/xterm Terminal (WebGL renderer, Unicode 11 widths) .
8
+ * │ onData keystrokes → WebSocket → PTY master .
9
+ * │ onResize terminal resize → `\x1b[RESIZE:cols;rows]` .
10
+ * │ write(data) PTY output bytes → VT100 parser .
11
+ * ▼ .
12
+ * WebSocket /api/pty?token=<session> .
13
+ * ▼ .
14
+ * FastAPI pty_ws (nastech_cli/web_server.py) .
15
+ * ▼ .
16
+ * POSIX PTY → `node ui-tui/dist/entry.js` → tui_gateway + AIAgent .
17
+ */
18
+
19
+ import { FitAddon } from "@xterm/addon-fit";
20
+ import { Unicode11Addon } from "@xterm/addon-unicode11";
21
+ import { WebLinksAddon } from "@xterm/addon-web-links";
22
+ import { WebglAddon } from "@xterm/addon-webgl";
23
+ import { Terminal } from "@xterm/xterm";
24
+ import "@xterm/xterm/css/xterm.css";
25
+ import { Button } from "@nastechai/ui/ui/components/button";
26
+ import { Typography } from "@nastechai/ui/ui/components/typography/index";
27
+ import { NASTECH_BASE_PATH, buildWsAuthParam } from "@/lib/api";
28
+ import { cn } from "@/lib/utils";
29
+ import { Copy, PanelRight, X } from "lucide-react";
30
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
31
+ import { createPortal } from "react-dom";
32
+ import { useSearchParams } from "react-router-dom";
33
+
34
+ import { ChatSidebar } from "@/components/ChatSidebar";
35
+ import { usePageHeader } from "@/contexts/usePageHeader";
36
+ import { useI18n } from "@/i18n";
37
+ import { api } from "@/lib/api";
38
+ import { PluginSlot } from "@/plugins";
39
+
40
+ function buildWsUrl(
41
+ authParam: [string, string],
42
+ resume: string | null,
43
+ channel: string,
44
+ ): string {
45
+ const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
46
+ // ``authParam`` is ``["token", <session>]`` in loopback mode and
47
+ // ``["ticket", <minted>]`` in gated mode. The server-side helper
48
+ // ``_ws_auth_ok`` picks whichever shape matches the current gate state.
49
+ const qs = new URLSearchParams({ [authParam[0]]: authParam[1], channel });
50
+ if (resume) qs.set("resume", resume);
51
+ return `${proto}//${window.location.host}${NASTECH_BASE_PATH}/api/pty?${qs.toString()}`;
52
+ }
53
+
54
+ // Channel id ties this chat tab's PTY child (publisher) to its sidebar
55
+ // (subscriber). Generated once per mount so a tab refresh starts a fresh
56
+ // channel — the previous PTY child terminates with the old WS, and its
57
+ // channel auto-evicts when no subscribers remain.
58
+ function generateChannelId(): string {
59
+ if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
60
+ return crypto.randomUUID();
61
+ }
62
+ return `chat-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`;
63
+ }
64
+
65
+ // Colors for the terminal body. Matches the dashboard's dark teal canvas
66
+ // with cream foreground — we intentionally don't pick monokai or a loud
67
+ // theme, because the TUI's skin engine already paints the content; the
68
+ // terminal chrome just needs to sit quietly inside the dashboard.
69
+ const TERMINAL_THEME = {
70
+ background: "#0d2626",
71
+ foreground: "#f0e6d2",
72
+ cursor: "#f0e6d2",
73
+ cursorAccent: "#0d2626",
74
+ selectionBackground: "#f0e6d244",
75
+ };
76
+
77
+ /**
78
+ * CSS width for xterm font tiers.
79
+ *
80
+ * Prefer the terminal host's `clientWidth` — Chrome DevTools device mode often
81
+ * keeps `window.innerWidth` at the full desktop value while the *drawn* layout
82
+ * is phone-sized, which made us pick desktop font sizes (~14px) and look huge.
83
+ */
84
+ function terminalTierWidthPx(host: HTMLElement | null): number {
85
+ if (typeof window === "undefined") return 1280;
86
+ const fromHost = host?.clientWidth ?? 0;
87
+ if (fromHost > 2) return Math.round(fromHost);
88
+ const doc = document.documentElement?.clientWidth ?? 0;
89
+ const vv = window.visualViewport;
90
+ const inner = window.innerWidth;
91
+ const vvw = vv?.width ?? inner;
92
+ const layout = Math.min(inner, vvw, doc > 0 ? doc : inner);
93
+ return Math.max(1, Math.round(layout));
94
+ }
95
+
96
+ function terminalFontSizeForWidth(layoutWidthPx: number): number {
97
+ if (layoutWidthPx < 300) return 7;
98
+ if (layoutWidthPx < 360) return 8;
99
+ if (layoutWidthPx < 420) return 9;
100
+ if (layoutWidthPx < 520) return 10;
101
+ if (layoutWidthPx < 720) return 11;
102
+ if (layoutWidthPx < 1024) return 12;
103
+ return 14;
104
+ }
105
+
106
+ function terminalLineHeightForWidth(layoutWidthPx: number): number {
107
+ return layoutWidthPx < 1024 ? 1.02 : 1.15;
108
+ }
109
+
110
+ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
111
+ const hostRef = useRef<HTMLDivElement | null>(null);
112
+ const termRef = useRef<Terminal | null>(null);
113
+ const fitRef = useRef<FitAddon | null>(null);
114
+ const wsRef = useRef<WebSocket | null>(null);
115
+ // Exposed to the main metrics-sync effect so it can refit the terminal
116
+ // the moment `isActive` flips back to true (display:none → display:flex
117
+ // collapses the host's box, so ResizeObserver never fires on return).
118
+ const syncMetricsRef = useRef<(() => void) | null>(null);
119
+ const [searchParams, setSearchParams] = useSearchParams();
120
+ // Lazy-init: the missing-token check happens at construction so the effect
121
+ // body doesn't have to setState (React 19's set-state-in-effect rule).
122
+ // In gated (OAuth) mode the server intentionally omits the session token —
123
+ // the SPA authenticates the WS via a single-use ticket (buildWsAuthParam),
124
+ // so a missing token there is expected, not an error.
125
+ const [banner, setBanner] = useState<string | null>(() =>
126
+ typeof window !== "undefined" &&
127
+ !window.__NASTECH_SESSION_TOKEN__ &&
128
+ !window.__NASTECH_AUTH_REQUIRED__
129
+ ? "Session token unavailable. Open this page through `nastech dashboard`, not directly."
130
+ : null,
131
+ );
132
+ const [copyState, setCopyState] = useState<"idle" | "copied">("idle");
133
+ const copyResetRef = useRef<ReturnType<typeof setTimeout> | null>(null);
134
+ // Raw state for the mobile side-sheet + a derived value that force-
135
+ // closes whenever the chat tab isn't active. The *derived* value is
136
+ // what side-effects (body-scroll lock, keydown listener, portal render)
137
+ // key on — that way switching to another tab triggers the effect's
138
+ // cleanup, releasing the scroll-lock on /sessions etc. Returning to
139
+ // /chat re-runs the effect (derived flips back to true) and re-locks.
140
+ // Keying on the raw state would leak the body.overflow="hidden" across
141
+ // tabs because the dep wouldn't change on tab switch.
142
+ const [mobilePanelOpenRaw, setMobilePanelOpenRaw] = useState(false);
143
+ const mobilePanelOpen = isActive && mobilePanelOpenRaw;
144
+ const { setEnd } = usePageHeader();
145
+ const { t } = useI18n();
146
+ const closeMobilePanel = useCallback(() => setMobilePanelOpenRaw(false), []);
147
+ const modelToolsLabel = useMemo(
148
+ () => `${t.app.modelToolsSheetTitle} ${t.app.modelToolsSheetSubtitle}`,
149
+ [t.app.modelToolsSheetSubtitle, t.app.modelToolsSheetTitle],
150
+ );
151
+ const [portalRoot] = useState<HTMLElement | null>(() =>
152
+ typeof document !== "undefined" ? document.body : null,
153
+ );
154
+ const [narrow, setNarrow] = useState(() =>
155
+ typeof window !== "undefined"
156
+ ? window.matchMedia("(max-width: 1023px)").matches
157
+ : false,
158
+ );
159
+
160
+ // The dashboard keeps ChatPage mounted persistently so the PTY survives tab
161
+ // switches. That is great for ordinary /chat navigation, but it means query
162
+ // param changes do NOT remount the component. Resume-in-chat from the
163
+ // Sessions page relies on `/chat?resume=<id>` changing at runtime, so we must
164
+ // treat the current resume target as part of the PTY identity and rebuild the
165
+ // terminal session when it changes.
166
+ const resumeParam = searchParams.get("resume");
167
+ const channel = useMemo(() => generateChannelId(), [resumeParam]);
168
+
169
+ useEffect(() => {
170
+ if (!resumeParam) return;
171
+
172
+ let cancelled = false;
173
+
174
+ api
175
+ .getSessionLatestDescendant(resumeParam)
176
+ .then((res) => {
177
+ if (cancelled || !res.session_id || res.session_id === resumeParam) {
178
+ return;
179
+ }
180
+
181
+ const next = new URLSearchParams(searchParams);
182
+ next.set("resume", res.session_id);
183
+ setSearchParams(next, { replace: true });
184
+ })
185
+ .catch(() => {
186
+ // Best-effort: old servers or missing sessions should not block chat.
187
+ });
188
+
189
+ return () => {
190
+ cancelled = true;
191
+ };
192
+ }, [resumeParam, searchParams, setSearchParams]);
193
+
194
+ useEffect(() => {
195
+ const mql = window.matchMedia("(max-width: 1023px)");
196
+ const sync = () => setNarrow(mql.matches);
197
+ sync();
198
+ mql.addEventListener("change", sync);
199
+ return () => mql.removeEventListener("change", sync);
200
+ }, []);
201
+
202
+ useEffect(() => {
203
+ if (!mobilePanelOpen) return;
204
+ const onKey = (e: KeyboardEvent) => {
205
+ if (e.key === "Escape") closeMobilePanel();
206
+ };
207
+ document.addEventListener("keydown", onKey);
208
+ const prevOverflow = document.body.style.overflow;
209
+ document.body.style.overflow = "hidden";
210
+ return () => {
211
+ document.removeEventListener("keydown", onKey);
212
+ document.body.style.overflow = prevOverflow;
213
+ };
214
+ }, [mobilePanelOpen, closeMobilePanel]);
215
+
216
+ useEffect(() => {
217
+ const mql = window.matchMedia("(min-width: 1024px)");
218
+ const onChange = (e: MediaQueryListEvent) => {
219
+ if (e.matches) setMobilePanelOpenRaw(false);
220
+ };
221
+ mql.addEventListener("change", onChange);
222
+ return () => mql.removeEventListener("change", onChange);
223
+ }, []);
224
+
225
+ useEffect(() => {
226
+ // When hidden (non-chat tab) we must not register the header button —
227
+ // another page owns the header's end slot at that point.
228
+ if (!isActive) {
229
+ setEnd(null);
230
+ return;
231
+ }
232
+ if (!narrow) {
233
+ setEnd(null);
234
+ return;
235
+ }
236
+ setEnd(
237
+ <Button
238
+ ghost
239
+ onClick={() => setMobilePanelOpenRaw(true)}
240
+ aria-expanded={mobilePanelOpen}
241
+ aria-controls="chat-side-panel"
242
+ className={cn(
243
+ "shrink-0 rounded border border-current/20",
244
+ "px-2 py-1 text-xs font-medium tracking-wide",
245
+ "text-text-secondary hover:text-midground hover:bg-midground/5",
246
+ )}
247
+ >
248
+ <span className="inline-flex items-center gap-1.5">
249
+ <PanelRight className="h-3 w-3 shrink-0" />
250
+ {modelToolsLabel}
251
+ </span>
252
+ </Button>,
253
+ );
254
+ return () => setEnd(null);
255
+ }, [isActive, narrow, mobilePanelOpen, modelToolsLabel, setEnd]);
256
+
257
+ const handleCopyLast = () => {
258
+ const ws = wsRef.current;
259
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
260
+ // Send the slash as a burst, wait long enough for Ink's tokenizer to
261
+ // emit a keypress event for each character (not coalesce them into a
262
+ // paste), then send Return as its own event. The timing here is
263
+ // empirical — 100ms is safely past Node's default stdin coalescing
264
+ // window and well inside UI responsiveness.
265
+ ws.send("/copy");
266
+ setTimeout(() => {
267
+ const s = wsRef.current;
268
+ if (s && s.readyState === WebSocket.OPEN) s.send("\r");
269
+ }, 100);
270
+ setCopyState("copied");
271
+ if (copyResetRef.current) clearTimeout(copyResetRef.current);
272
+ copyResetRef.current = setTimeout(() => setCopyState("idle"), 1500);
273
+ termRef.current?.focus();
274
+ };
275
+
276
+ useEffect(() => {
277
+ const host = hostRef.current;
278
+ if (!host) return;
279
+
280
+ const token = window.__NASTECH_SESSION_TOKEN__;
281
+ const gated = !!window.__NASTECH_AUTH_REQUIRED__;
282
+ // Banner already initialised above; just bail before wiring xterm/WS.
283
+ // In gated mode the token is absent by design — buildWsAuthParam() mints
284
+ // a WS ticket instead, so don't bail; let the effect reach that path.
285
+ if (!token && !gated) {
286
+ return;
287
+ }
288
+
289
+ const tierW0 = terminalTierWidthPx(host);
290
+ const term = new Terminal({
291
+ allowProposedApi: true,
292
+ cursorBlink: true,
293
+ fontFamily:
294
+ "'JetBrains Mono', 'Cascadia Mono', 'Fira Code', 'MesloLGS NF', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace",
295
+ fontSize: terminalFontSizeForWidth(tierW0),
296
+ lineHeight: terminalLineHeightForWidth(tierW0),
297
+ letterSpacing: 0,
298
+ fontWeight: "400",
299
+ fontWeightBold: "700",
300
+ macOptionIsMeta: true,
301
+ // Hold Option (Alt on Linux/Windows) to force native text selection
302
+ // even when the inner NasTech TUI has enabled xterm mouse-events
303
+ // mode (CSI ?1000h family). Without this, click-and-drag in the
304
+ // chat canvas selects nothing and Cmd+C falls back to copying the
305
+ // entire visible buffer, which is rarely what the user wants.
306
+ // See #25720.
307
+ macOptionClickForcesSelection: true,
308
+ // Right-click selects the word under the pointer. xterm.js default
309
+ // is false; enabling it gives users a single-action selection
310
+ // path on top of the modifier-based bypass above.
311
+ rightClickSelectsWord: true,
312
+ // Browser-embedded chat runs the TUI in inline mode. Keep transcript
313
+ // history in xterm.js so the browser wheel can scroll it directly.
314
+ scrollback: 5000,
315
+ theme: TERMINAL_THEME,
316
+ });
317
+ termRef.current = term;
318
+
319
+ // --- Clipboard integration ---------------------------------------
320
+ //
321
+ // Three independent paths all route to the system clipboard:
322
+ //
323
+ // 1. **Selection → Ctrl+C (or Cmd+C on macOS).** Ink's own handler
324
+ // in useInputHandlers.ts turns Ctrl+C into a copy when the
325
+ // terminal has a selection, then emits an OSC 52 escape. Our
326
+ // OSC 52 handler below decodes that escape and writes to the
327
+ // browser clipboard — so the flow works just like it does in
328
+ // `nastech --tui`.
329
+ //
330
+ // 2. **Ctrl/Cmd+Shift+C.** Belt-and-suspenders shortcut that
331
+ // operates directly on xterm's selection, useful if the TUI
332
+ // ever stops listening (e.g. overlays / pickers) or if the user
333
+ // has selected with the mouse outside of Ink's selection model.
334
+ //
335
+ // 3. **Ctrl/Cmd+Shift+V.** Reads the system clipboard and feeds
336
+ // it to the terminal as keyboard input. xterm's paste() wraps
337
+ // it with bracketed-paste if the host has that mode enabled.
338
+ //
339
+ // OSC 52 reads (terminal asking to read the clipboard) are not
340
+ // supported — that would let any content the TUI renders exfiltrate
341
+ // the user's clipboard.
342
+ term.parser.registerOscHandler(52, (data) => {
343
+ // Format: "<targets>;<base64 | '?'>"
344
+ const semi = data.indexOf(";");
345
+ if (semi < 0) return false;
346
+ const payload = data.slice(semi + 1);
347
+ if (payload === "?" || payload === "") return false; // read/clear — ignore
348
+ try {
349
+ const binary = atob(payload);
350
+ const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
351
+ const text = new TextDecoder("utf-8").decode(bytes);
352
+ navigator.clipboard.writeText(text).catch((err) => {
353
+ // Most common reason: the Clipboard API requires a user gesture.
354
+ // This can fail when the OSC 52 response arrives outside the
355
+ // original keydown event's activation. Log to aid debugging.
356
+ console.warn("[dashboard clipboard] OSC 52 write failed:", err.message);
357
+ });
358
+ } catch {
359
+ console.warn("[dashboard clipboard] malformed OSC 52 payload");
360
+ }
361
+ return true;
362
+ });
363
+
364
+ const isMac =
365
+ typeof navigator !== "undefined" && /Mac/i.test(navigator.platform);
366
+
367
+ term.attachCustomKeyEventHandler((ev) => {
368
+ if (ev.type !== "keydown") return true;
369
+
370
+ // Copy: Cmd+C on macOS, Ctrl+Shift+C on other platforms. Bare Ctrl+C
371
+ // is reserved for SIGINT to the TUI child — matches xterm / gnome-terminal /
372
+ // konsole / Windows Terminal. Ctrl+Shift+C only copies if a selection exists;
373
+ // without a selection it passes through to the TUI so agents can still
374
+ // react to the keypress.
375
+ // Paste: Cmd+Shift+V on macOS, Ctrl+Shift+V on others.
376
+ const copyModifier = isMac ? ev.metaKey : ev.ctrlKey && ev.shiftKey;
377
+ const pasteModifier = isMac ? ev.metaKey : ev.ctrlKey && ev.shiftKey;
378
+
379
+ if (copyModifier && ev.key.toLowerCase() === "c") {
380
+ const sel = term.getSelection();
381
+ if (sel) {
382
+ // Direct writeText inside the keydown handler preserves the user
383
+ // gesture — async round-trips through OSC 52 can lose activation
384
+ // and fail with "Document is not focused".
385
+ navigator.clipboard.writeText(sel).catch((err) => {
386
+ console.warn("[dashboard clipboard] direct copy failed:", err.message);
387
+ });
388
+ // Clear xterm.js's highlight after copy (matches gnome-terminal).
389
+ term.clearSelection();
390
+ ev.preventDefault();
391
+ return false;
392
+ }
393
+ // No selection → fall through so the TUI receives Ctrl+Shift+C
394
+ // (or the bare ev if the user used a different modifier).
395
+ }
396
+
397
+ if (pasteModifier && ev.key.toLowerCase() === "v") {
398
+ navigator.clipboard
399
+ .readText()
400
+ .then((text) => {
401
+ if (text) term.paste(text);
402
+ })
403
+ .catch((err) => {
404
+ console.warn("[dashboard clipboard] paste failed:", err.message);
405
+ });
406
+ ev.preventDefault();
407
+ return false;
408
+ }
409
+
410
+ return true;
411
+ });
412
+
413
+ const fit = new FitAddon();
414
+ fitRef.current = fit;
415
+ term.loadAddon(fit);
416
+
417
+ // Dashboard chat should scroll the browser-side transcript, not send
418
+ // mouse-wheel protocol bytes through the PTY.
419
+ term.attachCustomWheelEventHandler((ev) => {
420
+ const delta = ev.deltaY;
421
+ if (!delta) {
422
+ return false;
423
+ }
424
+
425
+ const step = Math.max(1, Math.round(Math.abs(delta) / 50));
426
+ term.scrollLines(delta > 0 ? step : -step);
427
+
428
+ ev.preventDefault();
429
+ ev.stopPropagation();
430
+ return false;
431
+ });
432
+
433
+ const unicode11 = new Unicode11Addon();
434
+ term.loadAddon(unicode11);
435
+ term.unicode.activeVersion = "11";
436
+
437
+ term.loadAddon(new WebLinksAddon());
438
+
439
+ term.open(host);
440
+
441
+ // WebGL draws from a texture atlas sized with device pixels. On phones and
442
+ // in DevTools device mode that often produces *visually* much larger cells
443
+ // than `fontSize` suggests — users see "huge" text even at 7–9px settings.
444
+ // The canvas/DOM renderer tracks `fontSize` faithfully; use it for narrow
445
+ // hosts. Wide layouts still get WebGL for crisp box-drawing.
446
+ const useWebgl = terminalTierWidthPx(host) >= 768;
447
+ if (useWebgl) {
448
+ try {
449
+ const webgl = new WebglAddon();
450
+ webgl.onContextLoss(() => webgl.dispose());
451
+ term.loadAddon(webgl);
452
+ } catch (err) {
453
+ console.warn(
454
+ "[nastech-chat] WebGL renderer unavailable; falling back to default",
455
+ err,
456
+ );
457
+ }
458
+ }
459
+
460
+ // Initial fit + resize observer. fit.fit() reads the container's
461
+ // current bounding box and resizes the terminal grid to match.
462
+ //
463
+ // The subtle bit: the dashboard has CSS transitions on the container
464
+ // (backdrop fade-in, rounded corners settling as fonts load). If we
465
+ // call fit() at mount time, the bounding box we measure is often 1-2
466
+ // cell widths off from the final size. ResizeObserver *does* fire
467
+ // when the container settles, but if the pixel delta happens to be
468
+ // smaller than one cell's width, fit() computes the same integer
469
+ // (cols, rows) as before and doesn't emit onResize — so the PTY
470
+ // never learns the final size. Users see truncated long lines until
471
+ // they resize the browser window.
472
+ //
473
+ // We force one extra fit + explicit RESIZE send after two animation
474
+ // frames. rAF→rAF guarantees one layout commit between the two
475
+ // callbacks, giving CSS transitions and font metrics time to finalize
476
+ // before we take the authoritative measurement.
477
+ let hostSyncRaf = 0;
478
+ const scheduleHostSync = () => {
479
+ if (hostSyncRaf) return;
480
+ hostSyncRaf = requestAnimationFrame(() => {
481
+ hostSyncRaf = 0;
482
+ syncTerminalMetrics();
483
+ });
484
+ };
485
+
486
+ let metricsDebounce: ReturnType<typeof setTimeout> | null = null;
487
+ const syncTerminalMetrics = () => {
488
+ // display:none hosts have clientWidth/Height = 0, which fit() turns
489
+ // into a 1x1 terminal. Skip entirely while hidden; the visibility
490
+ // effect below runs another fit as soon as the tab is shown again.
491
+ if (!host.isConnected || host.clientWidth <= 0 || host.clientHeight <= 0) {
492
+ return;
493
+ }
494
+ const w = terminalTierWidthPx(host);
495
+ const nextSize = terminalFontSizeForWidth(w);
496
+ const nextLh = terminalLineHeightForWidth(w);
497
+ const fontChanged =
498
+ term.options.fontSize !== nextSize ||
499
+ term.options.lineHeight !== nextLh;
500
+ if (fontChanged) {
501
+ term.options.fontSize = nextSize;
502
+ term.options.lineHeight = nextLh;
503
+ }
504
+ try {
505
+ fit.fit();
506
+ } catch {
507
+ return;
508
+ }
509
+ if (fontChanged && term.rows > 0) {
510
+ try {
511
+ term.refresh(0, term.rows - 1);
512
+ } catch {
513
+ /* ignore */
514
+ }
515
+ }
516
+ if (
517
+ fontChanged &&
518
+ wsRef.current &&
519
+ wsRef.current.readyState === WebSocket.OPEN
520
+ ) {
521
+ wsRef.current.send(`\x1b[RESIZE:${term.cols};${term.rows}]`);
522
+ }
523
+ };
524
+ syncMetricsRef.current = syncTerminalMetrics;
525
+
526
+ const scheduleSyncTerminalMetrics = () => {
527
+ if (metricsDebounce) clearTimeout(metricsDebounce);
528
+ metricsDebounce = setTimeout(() => {
529
+ metricsDebounce = null;
530
+ syncTerminalMetrics();
531
+ }, 60);
532
+ };
533
+
534
+ const ro = new ResizeObserver(() => scheduleHostSync());
535
+ ro.observe(host);
536
+
537
+ window.addEventListener("resize", scheduleSyncTerminalMetrics);
538
+ window.visualViewport?.addEventListener("resize", scheduleSyncTerminalMetrics);
539
+ scheduleHostSync();
540
+ requestAnimationFrame(() => scheduleHostSync());
541
+
542
+ // Double-rAF authoritative fit. On the second frame the layout has
543
+ // committed at least once since mount; fit.fit() then reads the
544
+ // stable container size. We always send a RESIZE escape afterwards
545
+ // (even if fit's cols/rows didn't change, so the PTY has the same
546
+ // dims registered as our JS state — prevents a drift where Ink
547
+ // thinks the terminal is one col bigger than what's on screen).
548
+ let settleRaf1 = 0;
549
+ let settleRaf2 = 0;
550
+ settleRaf1 = requestAnimationFrame(() => {
551
+ settleRaf1 = 0;
552
+ settleRaf2 = requestAnimationFrame(() => {
553
+ settleRaf2 = 0;
554
+ syncTerminalMetrics();
555
+ });
556
+ });
557
+
558
+ // WebSocket. In gated mode (``window.__NASTECH_AUTH_REQUIRED__``) this
559
+ // awaits a single-use ticket via /api/auth/ws-ticket before opening;
560
+ // in loopback mode it resolves synchronously against the injected
561
+ // session token. The IIFE keeps the outer effect synchronous so its
562
+ // ``return cleanup`` stays at the top level; handlers + disposables
563
+ // are hoisted to ``let`` bindings the cleanup closes over.
564
+ let unmounting = false;
565
+ let onDataDisposable: { dispose(): void } | null = null;
566
+ let onResizeDisposable: { dispose(): void } | null = null;
567
+ void (async () => {
568
+ const authParam = await buildWsAuthParam();
569
+ if (unmounting) return;
570
+ const url = buildWsUrl(authParam, resumeParam, channel);
571
+ const ws = new WebSocket(url);
572
+ ws.binaryType = "arraybuffer";
573
+ wsRef.current = ws;
574
+
575
+ ws.onopen = () => {
576
+ setBanner(null);
577
+ // Send the initial RESIZE immediately so Ink has *a* size to lay
578
+ // out against on its first paint. The double-rAF block above will
579
+ // follow up with the authoritative measurement — at worst Ink
580
+ // reflows once after the PTY boots, which is imperceptible.
581
+ ws.send(`\x1b[RESIZE:${term.cols};${term.rows}]`);
582
+ };
583
+
584
+ ws.onmessage = (ev) => {
585
+ if (typeof ev.data === "string") {
586
+ term.write(ev.data);
587
+ } else {
588
+ term.write(new Uint8Array(ev.data as ArrayBuffer));
589
+ }
590
+ };
591
+
592
+ ws.onclose = (ev) => {
593
+ wsRef.current = null;
594
+ if (unmounting) {
595
+ return;
596
+ }
597
+ if (ev.code === 4401) {
598
+ setBanner("Auth failed. Reload the page to refresh the session token.");
599
+ return;
600
+ }
601
+ if (ev.code === 4403) {
602
+ setBanner("Chat is only reachable from localhost.");
603
+ return;
604
+ }
605
+ if (ev.code === 1011) {
606
+ // Server already wrote an ANSI error frame.
607
+ return;
608
+ }
609
+ term.write("\r\n\x1b[90m[session ended]\x1b[0m\r\n");
610
+ };
611
+
612
+ // Keystrokes → PTY.
613
+ //
614
+ // IMPORTANT:
615
+ // The embedded web chat has occasionally surfaced stray letters/digits
616
+ // in the input line after a turn completes. The most likely culprit is
617
+ // browser-side terminal control traffic being forwarded back into the
618
+ // PTY as if it were user text. SGR mouse tracking is the highest-risk
619
+ // path here: xterm.js emits raw CSI reports (`\x1b[<...`) that look like
620
+ // ordinary bytes to the backend.
621
+ //
622
+ // For the browser embed we prefer input stability over terminal-style
623
+ // mouse reporting, so we drop SGR mouse reports entirely instead of
624
+ // forwarding them into NasTech. Keyboard input, paste, and resize still
625
+ // behave normally.
626
+ // eslint-disable-next-line no-control-regex -- intentional ESC byte in xterm SGR mouse report parser
627
+ const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/;
628
+ onDataDisposable = term.onData((data) => {
629
+ if (ws.readyState !== WebSocket.OPEN) return;
630
+
631
+ if (SGR_MOUSE_RE.test(data)) {
632
+ return;
633
+ }
634
+
635
+ ws.send(data);
636
+ });
637
+
638
+ onResizeDisposable = term.onResize(({ cols, rows }) => {
639
+ if (ws.readyState === WebSocket.OPEN) {
640
+ ws.send(`\x1b[RESIZE:${cols};${rows}]`);
641
+ }
642
+ });
643
+ })();
644
+
645
+ term.focus();
646
+
647
+ return () => {
648
+ unmounting = true;
649
+ syncMetricsRef.current = null;
650
+ onDataDisposable?.dispose();
651
+ onResizeDisposable?.dispose();
652
+ if (metricsDebounce) clearTimeout(metricsDebounce);
653
+ window.removeEventListener("resize", scheduleSyncTerminalMetrics);
654
+ window.visualViewport?.removeEventListener(
655
+ "resize",
656
+ scheduleSyncTerminalMetrics,
657
+ );
658
+ ro.disconnect();
659
+ if (hostSyncRaf) cancelAnimationFrame(hostSyncRaf);
660
+ if (settleRaf1) cancelAnimationFrame(settleRaf1);
661
+ if (settleRaf2) cancelAnimationFrame(settleRaf2);
662
+ // Phase 5.3: ``ws`` is local to the IIFE that opens it (the gated-mode
663
+ // ticket fetch makes the open async). The cleanup runs at the outer
664
+ // effect's top level so it can't reach into that scope — close via
665
+ // the ref instead. ``?.`` covers the race where unmount fires before
666
+ // the ticket fetch resolves and ``wsRef.current`` was never assigned.
667
+ wsRef.current?.close();
668
+ wsRef.current = null;
669
+ term.dispose();
670
+ termRef.current = null;
671
+ fitRef.current = null;
672
+ if (copyResetRef.current) {
673
+ clearTimeout(copyResetRef.current);
674
+ copyResetRef.current = null;
675
+ }
676
+ };
677
+ }, [channel, resumeParam]);
678
+
679
+ // When the user returns to the chat tab (isActive: false → true), the
680
+ // terminal host just transitioned from display:none to display:flex.
681
+ // ResizeObserver won't fire on that kind of style-driven box change —
682
+ // xterm thinks its grid is still whatever it was when the tab was
683
+ // hidden (or 0×0, if it was hidden before first fit). Force a refit
684
+ // after two animation frames so layout has committed.
685
+ //
686
+ // Focus handling: we only steal focus back into the terminal when
687
+ // nothing else inside ChatPage was holding it (typically the first
688
+ // activation after mount, where document.activeElement is <body>; or
689
+ // a return after the user had been typing in the terminal, where
690
+ // focus was already on the xterm textarea before the tab got hidden
691
+ // and has since fallen back to <body>). If the user had clicked
692
+ // into the sidebar (model picker, tool-call entry) before switching
693
+ // tabs, we must not yank focus away from wherever they left it when
694
+ // they come back — that's a surprise and an a11y foot-gun.
695
+ useEffect(() => {
696
+ if (!isActive) return;
697
+ let raf1 = 0;
698
+ let raf2 = 0;
699
+ raf1 = requestAnimationFrame(() => {
700
+ raf1 = 0;
701
+ raf2 = requestAnimationFrame(() => {
702
+ raf2 = 0;
703
+ syncMetricsRef.current?.();
704
+ const host = hostRef.current;
705
+ const active = typeof document !== "undefined"
706
+ ? document.activeElement
707
+ : null;
708
+ const focusIsElsewhereInChatPage =
709
+ active !== null &&
710
+ active !== document.body &&
711
+ host !== null &&
712
+ !host.contains(active);
713
+ if (!focusIsElsewhereInChatPage) {
714
+ termRef.current?.focus();
715
+ }
716
+ });
717
+ });
718
+ return () => {
719
+ if (raf1) cancelAnimationFrame(raf1);
720
+ if (raf2) cancelAnimationFrame(raf2);
721
+ };
722
+ }, [isActive]);
723
+
724
+ // Layout:
725
+ // outer flex column — sits inside the dashboard's content area
726
+ // row split — terminal pane (flex-1) + sidebar (fixed width, lg+)
727
+ // terminal wrapper — rounded, dark, padded — the "terminal window"
728
+ // floating copy button — bottom-right corner, transparent with a
729
+ // subtle border; stays out of the way until hovered. Sends
730
+ // `/copy\n` to Ink, which emits OSC 52 → our clipboard handler.
731
+ // sidebar — ChatSidebar opens its own JSON-RPC sidecar; renders
732
+ // model badge, tool-call list, model picker. Best-effort: if the
733
+ // sidecar fails to connect the terminal pane keeps working.
734
+ //
735
+ // Mobile model/tools sheet is portaled to `document.body` so it stacks
736
+ // above the app sidebar (`z-50`) and mobile chrome (`z-40`). The main
737
+ // dashboard column uses `relative z-2`, which traps `position:fixed`
738
+ // descendants below those layers (see Toast.tsx).
739
+ const mobileModelToolsPortal =
740
+ isActive &&
741
+ narrow &&
742
+ portalRoot &&
743
+ createPortal(
744
+ <>
745
+ {mobilePanelOpen && (
746
+ <Button
747
+ ghost
748
+ aria-label={t.app.closeModelTools}
749
+ onClick={closeMobilePanel}
750
+ className={cn(
751
+ "fixed inset-0 z-[55] p-0 block",
752
+ "bg-black/60 backdrop-blur-sm",
753
+ )}
754
+ />
755
+ )}
756
+
757
+ <div
758
+ id="chat-side-panel"
759
+ role="complementary"
760
+ aria-label={modelToolsLabel}
761
+ className={cn(
762
+ "font-mondwest fixed top-0 right-0 z-[60] flex h-dvh max-h-dvh w-64 min-w-0 flex-col antialiased",
763
+ "border-l border-current/20 text-midground",
764
+ "bg-background-base/95 backdrop-blur-sm",
765
+ "transition-transform duration-200 ease-out",
766
+ "[background:var(--component-sidebar-background)]",
767
+ "[clip-path:var(--component-sidebar-clip-path)]",
768
+ "[border-image:var(--component-sidebar-border-image)]",
769
+ mobilePanelOpen
770
+ ? "translate-x-0"
771
+ : "pointer-events-none translate-x-full",
772
+ )}
773
+ >
774
+ <div
775
+ className={cn(
776
+ "flex h-14 shrink-0 items-center justify-between gap-2 border-b border-current/20 px-5",
777
+ )}
778
+ >
779
+ <Typography
780
+ mondwest
781
+ className="text-display font-bold text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground"
782
+ style={{ mixBlendMode: "plus-lighter" }}
783
+ >
784
+ {t.app.modelToolsSheetTitle}
785
+ <br />
786
+ {t.app.modelToolsSheetSubtitle}
787
+ </Typography>
788
+
789
+ <Button
790
+ ghost
791
+ size="icon"
792
+ onClick={closeMobilePanel}
793
+ aria-label={t.app.closeModelTools}
794
+ className="text-text-secondary hover:text-midground"
795
+ >
796
+ <X />
797
+ </Button>
798
+ </div>
799
+
800
+ <div
801
+ className={cn(
802
+ "min-h-0 flex-1 overflow-y-auto overflow-x-hidden",
803
+ "border-t border-current/10",
804
+ )}
805
+ >
806
+ <ChatSidebar channel={channel} />
807
+ </div>
808
+ </div>
809
+ </>,
810
+ portalRoot,
811
+ );
812
+
813
+ return (
814
+ <div className="flex min-h-0 flex-1 flex-col gap-2">
815
+ <PluginSlot name="chat:top" />
816
+ {mobileModelToolsPortal}
817
+
818
+ {banner && (
819
+ <div className="border border-warning/50 bg-warning/10 text-warning px-3 py-2 text-xs tracking-wide">
820
+ {banner}
821
+ </div>
822
+ )}
823
+
824
+ <div className="flex min-h-0 flex-1 flex-col gap-2 lg:flex-row lg:gap-3">
825
+ <div
826
+ className={cn(
827
+ "relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-lg",
828
+ "p-2 sm:p-3",
829
+ )}
830
+ style={{
831
+ backgroundColor: TERMINAL_THEME.background,
832
+ boxShadow: "0 8px 32px rgba(0, 0, 0, 0.4)",
833
+ }}
834
+ >
835
+ <div
836
+ ref={hostRef}
837
+ className="nastech-chat-xterm-host min-h-0 min-w-0 flex-1"
838
+ />
839
+
840
+ <Button
841
+ ghost
842
+ onClick={handleCopyLast}
843
+ title="Copy last assistant response as raw markdown"
844
+ aria-label="Copy last assistant response"
845
+ className={cn(
846
+ "absolute z-10",
847
+ "normal-case tracking-normal font-normal",
848
+ "rounded border border-current/30",
849
+ "bg-black/20 backdrop-blur-sm",
850
+ "opacity-70 hover:opacity-100 hover:border-current/60",
851
+ "transition-opacity duration-150",
852
+ "bottom-2 right-2 px-2 py-1 text-xs sm:bottom-3 sm:right-3 sm:px-2.5 sm:py-1.5",
853
+ "lg:bottom-4 lg:right-4",
854
+ )}
855
+ style={{ color: TERMINAL_THEME.foreground }}
856
+ >
857
+ <span className="inline-flex items-center gap-1.5">
858
+ <Copy className="h-3 w-3 shrink-0" />
859
+ <span className="hidden min-[400px]:inline tracking-wide">
860
+ {copyState === "copied" ? "copied" : "copy last response"}
861
+ </span>
862
+ </span>
863
+ </Button>
864
+ </div>
865
+
866
+ {!narrow && (
867
+ <div
868
+ id="chat-side-panel"
869
+ role="complementary"
870
+ aria-label={modelToolsLabel}
871
+ className="flex min-h-0 shrink-0 flex-col overflow-hidden lg:h-full lg:w-80"
872
+ >
873
+ <div className="min-h-0 flex-1 overflow-hidden">
874
+ <ChatSidebar channel={channel} />
875
+ </div>
876
+ </div>
877
+ )}
878
+ </div>
879
+ <PluginSlot name="chat:bottom" />
880
+ </div>
881
+ );
882
+ }
883
+
884
+ declare global {
885
+ interface Window {
886
+ __NASTECH_SESSION_TOKEN__?: string;
887
+ __NASTECH_AUTH_REQUIRED__?: boolean;
888
+ }
889
+ }