Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
yilsem committed 2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 files changed
+3442369

No files matched your search

+202
View File
@@ -0,0 +1,202 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { api, fetchJSON, setManagementProfile } from "./api";
const reloadMocks = vi.hoisted(() => ({
attemptDashboardTokenReloadOnce: vi.fn(() => false),
clearDashboardTokenReloadAttempt: vi.fn(),
}));
vi.mock("./dashboard-auth-reload", () => ({
attemptDashboardTokenReloadOnce: reloadMocks.attemptDashboardTokenReloadOnce,
clearDashboardTokenReloadAttempt: reloadMocks.clearDashboardTokenReloadAttempt,
}));
const SESSION_HEADER = "X-Hermes-Session-Token";
beforeEach(() => {
reloadMocks.attemptDashboardTokenReloadOnce.mockReset();
reloadMocks.attemptDashboardTokenReloadOnce.mockReturnValue(false);
reloadMocks.clearDashboardTokenReloadAttempt.mockReset();
Object.defineProperty(window, "__HERMES_SESSION_TOKEN__", {
configurable: true,
value: "stale-token",
writable: true,
});
Object.defineProperty(window, "__HERMES_AUTH_REQUIRED__", {
configurable: true,
value: false,
writable: true,
});
});
afterEach(() => {
setManagementProfile("");
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
function jsonFetchMock(body: unknown = { ok: true }) {
return vi.fn<typeof fetch>(
async () =>
new Response(JSON.stringify(body), {
headers: { "Content-Type": "application/json" },
status: 200,
}),
);
}
describe("fetchJSON", () => {
it("tries the one-shot reload path for loopback 401s", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
clone: () => ({
json: async () => ({}),
}),
ok: false,
status: 401,
statusText: "Unauthorized",
text: async () => "Unauthorized",
})),
);
reloadMocks.attemptDashboardTokenReloadOnce.mockReturnValue(true);
const pending = fetchJSON("/api/status");
await expect(Promise.race([pending, Promise.resolve("pending")])).resolves.toBe(
"pending",
);
expect(reloadMocks.attemptDashboardTokenReloadOnce).toHaveBeenCalledTimes(1);
expect(reloadMocks.clearDashboardTokenReloadAttempt).not.toHaveBeenCalled();
});
it("clears the reload latch after a successful response", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
json: async () => ({ ok: true }),
ok: true,
status: 200,
})),
);
await expect(fetchJSON("/api/status")).resolves.toEqual({ ok: true });
expect(reloadMocks.clearDashboardTokenReloadAttempt).toHaveBeenCalledTimes(1);
});
});
describe("api.getModelOptions", () => {
it("requests a live model refresh when asked", async () => {
vi.stubGlobal("window", {});
const fetchMock = jsonFetchMock({ providers: [] });
vi.stubGlobal("fetch", fetchMock);
await api.getModelOptions({ refresh: true });
expect(fetchMock).toHaveBeenCalledWith(
"/api/model/options?refresh=1&include_unconfigured=1",
expect.objectContaining({ credentials: "include" }),
);
});
it("keeps explicit profile scoping when refreshing", async () => {
vi.stubGlobal("window", {});
const fetchMock = jsonFetchMock({ providers: [] });
vi.stubGlobal("fetch", fetchMock);
await api.getModelOptions({ profile: "default", refresh: true });
expect(fetchMock).toHaveBeenCalledWith(
"/api/model/options?profile=default&refresh=1&include_unconfigured=1",
expect.objectContaining({ credentials: "include" }),
);
});
});
describe("api OAuth helpers", () => {
it("starts OAuth login in gated mode without requiring an injected session token", async () => {
vi.stubGlobal("window", { __HERMES_AUTH_REQUIRED__: true });
const fetchMock = jsonFetchMock({
flow: "device_code",
session_id: "oauth-session",
});
vi.stubGlobal("fetch", fetchMock);
await api.startOAuthLogin("openai-codex");
expect(fetchMock).toHaveBeenCalledWith(
"/api/providers/oauth/openai-codex/start",
expect.objectContaining({
body: "{}",
credentials: "include",
method: "POST",
}),
);
const headers = fetchMock.mock.calls[0][1]?.headers as Headers;
expect(headers.get("Content-Type")).toBe("application/json");
expect(headers.has(SESSION_HEADER)).toBe(false);
});
it("still sends the injected session token for OAuth login in loopback mode", async () => {
vi.stubGlobal("window", { __HERMES_SESSION_TOKEN__: "loopback-token" });
const fetchMock = jsonFetchMock({
flow: "device_code",
session_id: "oauth-session",
});
vi.stubGlobal("fetch", fetchMock);
await api.startOAuthLogin("openai-codex");
const headers = fetchMock.mock.calls[0][1]?.headers as Headers;
expect(headers.get(SESSION_HEADER)).toBe("loopback-token");
});
it("runs provider auth mutations in gated mode via cookie auth", async () => {
vi.stubGlobal("window", { __HERMES_AUTH_REQUIRED__: true });
const fetchMock = jsonFetchMock({ ok: true });
vi.stubGlobal("fetch", fetchMock);
await api.disconnectOAuthProvider("anthropic");
await api.submitOAuthCode("anthropic", "oauth-session", "code-123");
await api.cancelOAuthSession("oauth-session");
await api.revealEnvVar("OPENAI_API_KEY");
for (const call of fetchMock.mock.calls) {
const init = call[1] as RequestInit;
expect(init.credentials).toBe("include");
expect((init.headers as Headers).has(SESSION_HEADER)).toBe(false);
}
});
it("keeps every OAuth operation on the selected management profile", async () => {
vi.stubGlobal("window", {});
const fetchMock = jsonFetchMock({
flow: "device_code",
session_id: "oauth-session",
});
vi.stubGlobal("fetch", fetchMock);
setManagementProfile("worker");
await api.getOAuthProviders();
await api.disconnectOAuthProvider("anthropic");
await api.startOAuthLogin("openai-codex");
await api.submitOAuthCode("anthropic", "oauth-session", "code-123");
await api.pollOAuthSession("anthropic", "oauth-session");
await api.cancelOAuthSession("oauth-session");
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
"/api/providers/oauth?profile=worker",
"/api/providers/oauth/anthropic?profile=worker",
"/api/providers/oauth/openai-codex/start?profile=worker",
"/api/providers/oauth/anthropic/submit?profile=worker",
"/api/providers/oauth/anthropic/poll/oauth-session?profile=worker",
"/api/providers/oauth/sessions/oauth-session?profile=worker",
]);
});
});
+2670
View File
File diff suppressed because it is too large. Load diff
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { latchChatActivation } from "./chat-activation";
describe("latchChatActivation", () => {
it("stays inactive while the chat tab has never been active", () => {
// A dashboard sitting on /sessions, /system, … must not flip the latch,
// so the persistently-mounted ChatPage never opens /api/pty (which would
// trigger the TUI/agent bootstrap on every page).
expect(latchChatActivation(false, false)).toBe(false);
});
it("activates when the chat tab becomes active", () => {
expect(latchChatActivation(false, true)).toBe(true);
});
it("stays activated after the chat tab is left (sticky / persistence)", () => {
// Once the user has opened /chat, the PTY must survive navigating away so
// a running agent turn is not torn down on every tab switch.
expect(latchChatActivation(true, false)).toBe(true);
});
it("stays activated while the chat tab remains active", () => {
expect(latchChatActivation(true, true)).toBe(true);
});
});
+17
View File
@@ -0,0 +1,17 @@
/**
* Chat PTY activation latch.
*
* The dashboard keeps `ChatPage` mounted persistently (just hidden with CSS)
* on every route so the embedded chat PTY survives tab switches. The downside
* is that the PTY-connect effect would otherwise open `/api/pty` — which spawns
* the whole TUI + agent bootstrap (on a fresh checkout this prints
* `Installing TUI dependencies…` and runs `npm install`) — the moment the
* dashboard loads *any* page, even one the user never navigates the chat into.
*
* The fix is to only open the PTY once the chat tab has actually been active,
* while keeping activation **sticky** so the PTY still persists across later
* tab switches. This helper computes that latch: once `true`, it stays `true`.
*/
export function latchChatActivation(previous: boolean, isActive: boolean): boolean {
return previous || isActive;
}
@@ -0,0 +1,35 @@
/**
* Tests for the sidecar ``session.create`` params built by ChatSidebar.
*
* The sidecar must opt its session into close_on_disconnect so the gateway
* reaps the slash_worker on WS disconnect (the #21370/#21467 leak), and it
* must pass the dashboard's selected profile so model/credential info
* matches the PTY child under profile-scoped chat.
*
*/
import { describe, expect, it } from "vitest";
import { sidecarSessionCreateParams } from "@/components/ChatSidebar";
describe("sidecarSessionCreateParams", () => {
it("opts into close_on_disconnect", () => {
const params = sidecarSessionCreateParams();
expect(params.close_on_disconnect).toBe(true);
});
it("sets source to 'tool'", () => {
const params = sidecarSessionCreateParams();
expect(params.source).toBe("tool");
});
it("forwards the profile when present", () => {
const params = sidecarSessionCreateParams("work");
expect(params.profile).toBe("work");
});
it("omits profile when undefined", () => {
const params = sidecarSessionCreateParams();
expect(params).not.toHaveProperty("profile");
});
});
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { normalizeSessionTitle, titleFromSessionInfoPayload } from "./chat-title";
describe("normalizeSessionTitle", () => {
it("trims non-empty session titles", () => {
expect(normalizeSessionTitle(" Rename the dashboard ")).toBe(
"Rename the dashboard",
);
});
it("treats blank and non-string values as no title", () => {
expect(normalizeSessionTitle(" ")).toBeNull();
expect(normalizeSessionTitle(null)).toBeNull();
expect(normalizeSessionTitle(42)).toBeNull();
});
});
describe("titleFromSessionInfoPayload", () => {
it("returns undefined when the payload has no title field", () => {
expect(titleFromSessionInfoPayload({ model: "test/model" })).toBeUndefined();
expect(titleFromSessionInfoPayload(null)).toBeUndefined();
});
it("returns null when the title field is present but empty", () => {
expect(titleFromSessionInfoPayload({ title: "" })).toBeNull();
expect(titleFromSessionInfoPayload({ title: " " })).toBeNull();
});
it("returns the normalized title when present", () => {
expect(titleFromSessionInfoPayload({ title: " Live session title " })).toBe(
"Live session title",
);
});
});
+15
View File
@@ -0,0 +1,15 @@
export function normalizeSessionTitle(raw: unknown): string | null {
if (typeof raw !== "string") return null;
const title = raw.trim();
return title ? title : null;
}
export function titleFromSessionInfoPayload(
payload: unknown,
): string | null | undefined {
if (!payload || typeof payload !== "object" || !("title" in payload)) {
return undefined;
}
return normalizeSessionTitle((payload as { title?: unknown }).title);
}
+99
View File
@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import {
firstImageFromClipboard,
imageFilesFromTransfer,
transferMayContainImage,
} from "./chatImagePaste";
// Minimal DataTransfer stand-ins. jsdom's DataTransfer doesn't let us seed
// items/files, so we hand-roll the shape the helpers read.
function makeItem(kind: string, type: string, file: File | null) {
return { kind, type, getAsFile: () => file } as unknown as DataTransferItem;
}
function makeData(opts: {
items?: DataTransferItem[];
files?: File[];
}): DataTransfer {
const items = opts.items ?? [];
const files = opts.files ?? [];
const itemList: Record<string | number, unknown> = { length: items.length };
items.forEach((it, i) => {
itemList[i] = it;
});
const fileList: Record<string | number, unknown> = { length: files.length };
files.forEach((f, i) => {
fileList[i] = f;
});
return {
items: itemList,
files: fileList,
} as unknown as DataTransfer;
}
const png = new File([new Uint8Array([1, 2, 3])], "x.png", {
type: "image/png",
});
const gif = new File([new Uint8Array([4, 5])], "y.gif", {
type: "image/gif",
});
describe("firstImageFromClipboard", () => {
it("returns null for null clipboard data", () => {
expect(firstImageFromClipboard(null)).toBeNull();
});
it("finds an image via items[].getAsFile()", () => {
const data = makeData({ items: [makeItem("file", "image/png", png)] });
expect(firstImageFromClipboard(data)).toBe(png);
});
it("ignores non-file and non-image items", () => {
const data = makeData({
items: [
makeItem("string", "text/plain", null),
makeItem("file", "application/pdf", new File([], "a.pdf")),
],
});
expect(firstImageFromClipboard(data)).toBeNull();
});
it("falls back to files[] when items are absent (Safari/Firefox)", () => {
const data = makeData({ files: [png] });
expect(firstImageFromClipboard(data)).toBe(png);
});
it("returns null when nothing image-like is present", () => {
const data = makeData({
files: [new File([], "notes.txt", { type: "text/plain" })],
});
expect(firstImageFromClipboard(data)).toBeNull();
});
});
describe("imageFilesFromTransfer", () => {
it("dedupes the same file when present in both items and files", () => {
const data = makeData({
items: [makeItem("file", "image/png", png)],
files: [png, gif],
});
expect(imageFilesFromTransfer(data)).toEqual([png, gif]);
});
});
describe("transferMayContainImage", () => {
it("is true for image items even when type is empty (some browsers)", () => {
const data = makeData({
items: [makeItem("file", "", png)],
});
expect(transferMayContainImage(data)).toBe(true);
});
it("is false for text-only transfers", () => {
const data = makeData({
items: [makeItem("string", "text/plain", null)],
});
expect(transferMayContainImage(data)).toBe(false);
});
});
+164
View File
@@ -0,0 +1,164 @@
import { authedFetch } from "@/lib/api";
// Clipboard image MIME → file extension. Mirrors the set the TUI's /image
// attach path and the gateway's image sniffer accept.
const IMAGE_MIME_EXT: Record<string, string> = {
"image/png": "png",
"image/jpeg": "jpg",
"image/gif": "gif",
"image/webp": "webp",
"image/bmp": "bmp",
};
// Anthropic caps a single vision image at ~25 MB; reject earlier client-side
// with a clear message rather than round-tripping a doomed upload.
const MAX_IMAGE_BYTES = 25 * 1024 * 1024;
export interface ChatImageUploadResult {
/** Absolute path under HERMES_HOME/images the gateway wrote. */
path: string;
/** Byte size of the uploaded image. */
bytes: number;
/** Basename written on the server. */
name: string;
mime_type: string;
}
function imageFileKey(file: File): string {
return `${file.name}\0${file.type}\0${file.size}\0${file.lastModified}`;
}
function addImageFile(files: File[], seen: Set<string>, file: File | null) {
if (!file || !file.type.startsWith("image/")) return;
const key = imageFileKey(file);
if (seen.has(key)) return;
seen.add(key);
files.push(file);
}
/** Pull every image file out of a DataTransfer (clipboard or drop). */
export function imageFilesFromTransfer(
data: DataTransfer | null,
): File[] {
if (!data) return [];
const files: File[] = [];
const seen = new Set<string>();
if (data.items?.length) {
for (let i = 0; i < data.items.length; i++) {
const item = data.items[i];
if (item.kind === "file" && item.type.startsWith("image/")) {
addImageFile(files, seen, item.getAsFile());
}
}
}
if (data.files?.length) {
for (let i = 0; i < data.files.length; i++) {
addImageFile(files, seen, data.files[i]);
}
}
return files;
}
/** Pull the first image blob out of a DataTransfer, or null if none present. */
export function firstImageFromClipboard(
data: DataTransfer | null,
): File | null {
return imageFilesFromTransfer(data)[0] ?? null;
}
/** True when a drag payload may contain an image (for dragover preventDefault). */
export function transferMayContainImage(data: DataTransfer | null): boolean {
if (!data) return false;
if (data.items?.length) {
for (let i = 0; i < data.items.length; i++) {
const item = data.items[i];
if (
item.kind === "file" &&
(!item.type || item.type.startsWith("image/"))
) {
return true;
}
}
return false;
}
if (data.files?.length) {
for (let i = 0; i < data.files.length; i++) {
if (data.files[i].type.startsWith("image/")) return true;
}
}
return false;
}
function fileToDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () =>
reject(reader.error ?? new Error("image read failed"));
reader.onload = () => {
const result = reader.result;
if (typeof result === "string") {
resolve(result);
} else {
reject(new Error("image read failed"));
}
};
reader.readAsDataURL(file);
});
}
/**
* Upload a browser clipboard/drop image to ``HERMES_HOME/images`` via the
* dedicated chat upload endpoint and return the absolute gateway path.
*
* The dashboard Chat tab is an xterm mirror of a TUI running INSIDE the
* gateway. The container has no access to the browser's clipboard, so the
* server-side ``clipboard.paste`` path can never see a pasted image.
* Upload the bytes the browser already holds, then hand the path to the
* TUI's ``/image`` command.
*/
export async function uploadChatImage(
blob: Blob,
profile = "",
): Promise<ChatImageUploadResult> {
if (blob.size === 0) throw new Error("clipboard image is empty");
if (blob.size > MAX_IMAGE_BYTES) {
const mb = Math.round(MAX_IMAGE_BYTES / (1024 * 1024));
throw new Error(`image too large (max ${mb} MB)`);
}
const mime = blob.type || "image/png";
const ext = IMAGE_MIME_EXT[mime] || "png";
const filename =
blob instanceof File && blob.name
? blob.name
: `clipboard.${ext}`;
const file =
blob instanceof File
? blob
: new File([blob], filename, { type: mime });
const dataUrl = await fileToDataUrl(file);
const qs = profile ? `?profile=${encodeURIComponent(profile)}` : "";
const res = await authedFetch(`/api/chat/image-upload${qs}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
data_url: dataUrl,
filename,
}),
});
if (!res.ok) {
const text = await res.text().catch(() => res.statusText);
throw new Error(text || `HTTP ${res.status}`);
}
const uploaded = (await res.json()) as ChatImageUploadResult;
if (!uploaded?.path) {
throw new Error("image upload did not return a path");
}
return uploaded;
}
+55
View File
@@ -0,0 +1,55 @@
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import { describe, expect, it } from "vitest";
// Regression guard: every dashboard copy action must go through
// copyTextToClipboard (web/src/lib/clipboard.ts), which falls back to a
// selection-based copy when the async Clipboard API is unavailable —
// e.g. self-hosted dashboards served over plain HTTP on a LAN, where
// window.isSecureContext is false and navigator.clipboard is undefined.
//
// Clipboard READS (paste paths) have no equivalent legacy fallback and are
// exempt; they must feature-detect and fail soft instead.
//
// Ported from paperclipai/paperclip#10875.
const SRC_ROOT = join(__dirname, "..");
// The helper itself is the single allowed writer.
const ALLOWLIST = new Set(["lib/clipboard.ts"]);
function collectSourceFiles(dir: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) {
collectSourceFiles(full, out);
} else if (/\.(ts|tsx)$/.test(entry) && !/\.(test|spec)\.(ts|tsx)$/.test(entry)) {
out.push(full);
}
}
return out;
}
describe("clipboard write regression guard", () => {
it("web/src has no direct navigator.clipboard write calls outside lib/clipboard.ts", () => {
const offenders: string[] = [];
for (const file of collectSourceFiles(SRC_ROOT)) {
const rel = relative(SRC_ROOT, file).replace(/\\/g, "/");
if (ALLOWLIST.has(rel)) continue;
const source = readFileSync(file, "utf8");
// Match writeText / write on navigator.clipboard (incl. optional
// chaining); reads (read / readText) are intentionally not matched.
const pattern = /navigator\.clipboard\??\s*\.\s*write(Text)?\s*[(.]/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(source)) !== null) {
const line = source.slice(0, match.index).split("\n").length;
offenders.push(`${rel}:${line}`);
}
}
expect(
offenders,
`Direct navigator.clipboard writes found — use copyTextToClipboard from "@/lib/clipboard" instead:\n${offenders.join("\n")}`,
).toEqual([]);
});
});
+114
View File
@@ -0,0 +1,114 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { copyTextToClipboard } from "./clipboard";
const originalNavigator = globalThis.navigator;
const originalDocument = globalThis.document;
const originalWindow = globalThis.window;
function setGlobal<K extends keyof typeof globalThis>(
key: K,
value: (typeof globalThis)[K] | undefined,
) {
Object.defineProperty(globalThis, key, {
configurable: true,
value,
});
}
afterEach(() => {
setGlobal("navigator", originalNavigator);
setGlobal("document", originalDocument);
setGlobal("window", originalWindow);
vi.restoreAllMocks();
});
describe("copyTextToClipboard", () => {
it("uses navigator.clipboard when available", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
setGlobal(
"navigator",
{ clipboard: { writeText } } as unknown as Navigator,
);
setGlobal("document", undefined);
await expect(copyTextToClipboard("CODEX-1234")).resolves.toBe(true);
expect(writeText).toHaveBeenCalledWith("CODEX-1234");
});
it("falls back to selection copy when Clipboard API fails", async () => {
const writeText = vi.fn().mockRejectedValue(new Error("not allowed"));
const appendChild = vi.fn();
const removeChild = vi.fn();
const execCommand = vi.fn().mockReturnValue(true);
const textarea = {
focus: vi.fn(),
select: vi.fn(),
setAttribute: vi.fn(),
setSelectionRange: vi.fn(),
style: {},
value: "",
} as unknown as HTMLTextAreaElement;
setGlobal(
"navigator",
{ clipboard: { writeText } } as unknown as Navigator,
);
setGlobal("document", {
body: { appendChild, removeChild },
createElement: vi.fn(() => textarea),
execCommand,
getSelection: vi.fn(() => null),
} as unknown as Document);
await expect(copyTextToClipboard("CODEX-1234")).resolves.toBe(true);
expect(writeText).toHaveBeenCalledWith("CODEX-1234");
expect(textarea.value).toBe("CODEX-1234");
expect(appendChild).toHaveBeenCalledWith(textarea);
expect(textarea.select).toHaveBeenCalled();
expect(execCommand).toHaveBeenCalledWith("copy");
expect(removeChild).toHaveBeenCalledWith(textarea);
});
it("uses selection copy directly in insecure browser contexts", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
const appendChild = vi.fn();
const removeChild = vi.fn();
const execCommand = vi.fn().mockReturnValue(true);
const textarea = {
focus: vi.fn(),
select: vi.fn(),
setAttribute: vi.fn(),
setSelectionRange: vi.fn(),
style: {},
value: "",
} as unknown as HTMLTextAreaElement;
setGlobal(
"navigator",
{ clipboard: { writeText } } as unknown as Navigator,
);
setGlobal(
"window",
{ isSecureContext: false } as unknown as Window & typeof globalThis,
);
setGlobal("document", {
body: { appendChild, removeChild },
createElement: vi.fn(() => textarea),
execCommand,
getSelection: vi.fn(() => null),
} as unknown as Document);
await expect(copyTextToClipboard("CODEX-1234")).resolves.toBe(true);
expect(writeText).not.toHaveBeenCalled();
expect(execCommand).toHaveBeenCalledWith("copy");
});
it("returns false when no copy mechanism is available", async () => {
setGlobal("navigator", {} as Navigator);
setGlobal("document", undefined);
await expect(copyTextToClipboard("CODEX-1234")).resolves.toBe(false);
});
});
+56
View File
@@ -0,0 +1,56 @@
export async function copyTextToClipboard(text: string): Promise<boolean> {
const clipboard =
typeof navigator === "undefined" ? undefined : navigator.clipboard;
const secureContext =
typeof window === "undefined" ? true : window.isSecureContext;
if (secureContext && clipboard?.writeText) {
try {
await clipboard.writeText(text);
return true;
} catch {
// Fall through to the selection-based copy path below.
}
}
if (typeof document === "undefined") {
return false;
}
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.top = "-1000px";
textarea.style.left = "-1000px";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
const selection = document.getSelection();
const ranges: Range[] = [];
if (selection) {
for (let i = 0; i < selection.rangeCount; i += 1) {
ranges.push(selection.getRangeAt(i));
}
}
textarea.focus();
textarea.select();
textarea.setSelectionRange(0, textarea.value.length);
let copied = false;
try {
copied = document.execCommand("copy");
} catch {
copied = false;
} finally {
document.body.removeChild(textarea);
if (selection) {
selection.removeAllRanges();
for (const range of ranges) {
selection.addRange(range);
}
}
}
return copied;
}
+202
View File
@@ -0,0 +1,202 @@
import { describe, expect, it } from "vitest";
import {
buildCronJobPayload,
cronJobHasExecutionContent,
cronJobFormFromJob,
cronLastResult,
splitCronList,
type CronJobFormState,
} from "./cron-job";
import type { CronJob } from "./api";
function form(overrides: Partial<CronJobFormState> = {}): CronJobFormState {
return {
name: "",
prompt: "prompt",
schedule: "every 1h",
deliver: "local",
skills: [],
provider: "",
model: "",
base_url: "",
script: "",
no_agent: false,
context_from: "",
continuity: false,
enabled_toolsets: [],
workdir: "",
...overrides,
};
}
describe("splitCronList", () => {
it("normalizes comma and newline separated cron list fields", () => {
expect(splitCronList(" web, terminal\nfile ,, ")).toEqual([
"web",
"terminal",
"file",
]);
});
});
describe("buildCronJobPayload", () => {
it("normalizes list fields and base URLs", () => {
const payload = buildCronJobPayload(
form({
base_url: "https://example.invalid/v1/",
enabled_toolsets: ["web", ""],
context_from: "upstream-a\nupstream-b",
}),
);
expect(payload).toMatchObject({
base_url: "https://example.invalid/v1",
context_from: ["upstream-a", "upstream-b"],
enabled_toolsets: ["web"],
});
});
it("stores continuity as the reserved self entry", () => {
const payload = buildCronJobPayload(
form({ continuity: true, context_from: "upstream-a" }),
);
expect(payload.context_from).toEqual(["upstream-a", "self"]);
});
it("continuity off strips any hand-typed self entry", () => {
const payload = buildCronJobPayload(
form({ continuity: false, context_from: "SELF\nupstream-a" }),
);
expect(payload.context_from).toEqual(["upstream-a"]);
});
it("keeps clear operations explicit for update payloads", () => {
const payload = buildCronJobPayload(form({ schedule: "every 2h" }));
expect(payload).toMatchObject({
schedule: "every 2h",
provider: null,
model: null,
base_url: null,
script: null,
no_agent: false,
context_from: null,
enabled_toolsets: null,
workdir: null,
});
});
});
describe("cronJobHasExecutionContent", () => {
it("treats a script as execution content for agent-backed cron jobs", () => {
const payload = buildCronJobPayload(
form({ prompt: "", skills: [], script: "collect-status.py" }),
);
expect(cronJobHasExecutionContent(payload)).toBe(true);
});
it("rejects payloads with no prompt, skills, or script", () => {
const payload = buildCronJobPayload(form({ prompt: "", skills: [], script: "" }));
expect(cronJobHasExecutionContent(payload)).toBe(false);
});
});
describe("cronJobFormFromJob", () => {
it("preserves schedule fallback and editable list fields", () => {
const job: CronJob = {
id: "abc",
enabled: true,
schedule_display: "every 1h",
context_from: ["upstream-a", "upstream-b"],
enabled_toolsets: ["web"],
};
expect(cronJobFormFromJob(job)).toMatchObject({
schedule: "every 1h",
context_from: "upstream-a\nupstream-b",
continuity: false,
enabled_toolsets: ["web"],
});
});
it("splits the stored self entry into the continuity toggle", () => {
const job: CronJob = {
id: "abc",
enabled: true,
schedule_display: "every 1h",
context_from: ["self", "upstream-a"],
};
expect(cronJobFormFromJob(job)).toMatchObject({
context_from: "upstream-a",
continuity: true,
});
});
it("prefers one-shot run_at over the human display string", () => {
const job: CronJob = {
id: "once-job",
enabled: true,
schedule: {
kind: "once",
run_at: "2026-02-03T14:00:00+08:00",
},
schedule_display: "once at 2026-02-03 14:00",
};
expect(cronJobFormFromJob(job)).toMatchObject({
schedule: "2026-02-03T14:00:00+08:00",
});
});
});
describe("cronLastResult", () => {
it("renders nothing for a job that never ran", () => {
expect(cronLastResult({ last_status: null })).toBeNull();
expect(cronLastResult({ last_status: "" })).toBeNull();
});
it("is green for ok with no detail", () => {
expect(cronLastResult({ last_status: "ok", last_error: null })).toEqual({
status: "ok",
tone: "success",
detail: null,
});
});
it("is amber for delivery_failed and explains it from last_delivery_error", () => {
// The agent run succeeded (last_error is null for these runs); the reason
// lives in last_delivery_error. Must never render as green or as "unknown".
expect(
cronLastResult({
last_status: "delivery_failed",
last_error: null,
last_delivery_error: "telegram: 502 Bad Gateway",
}),
).toEqual({
status: "delivery_failed",
tone: "warning",
detail: "telegram: 502 Bad Gateway",
});
});
it("is red for error and any unrecognised literal", () => {
expect(cronLastResult({ last_status: "error", last_error: "boom" })).toEqual({
status: "error",
tone: "destructive",
detail: "boom",
});
expect(cronLastResult({ last_status: "something_new" })?.tone).toBe("destructive");
});
it("is amber for blocked_config (preflight refused to burn a run)", () => {
expect(
cronLastResult({ last_status: "blocked_config", last_error: "missing API key" }),
).toEqual({ status: "blocked_config", tone: "warning", detail: "missing API key" });
});
});
+140
View File
@@ -0,0 +1,140 @@
import type { CronJob, CronJobMutation } from "./api";
export interface CronJobFormState {
name: string;
prompt: string;
schedule: string;
deliver: string;
skills: string[];
provider: string;
model: string;
base_url: string;
script: string;
no_agent: boolean;
context_from: string;
continuity: boolean;
enabled_toolsets: string[];
workdir: string;
}
/** Split a comma/newline list (or array) into trimmed, non-empty items. */
export function splitCronList(value: unknown): string[] {
const items = Array.isArray(value)
? value
: typeof value === "string"
? value.split(/[\n,]/)
: [];
return items.map((item) => String(item).trim()).filter(Boolean);
}
/** Trim to a non-empty string, or null. Optionally strip trailing slashes
* (base URLs). Mirrors the backend's `_cron_optional_text`. */
function optionalText(value: string, stripTrailingSlash = false): string | null {
const text = stripTrailingSlash ? value.trim().replace(/\/+$/, "") : value.trim();
return text || null;
}
/** Read a stored string field as a plain string ("" when absent). */
function asString(value: unknown): string {
return typeof value === "string" ? value : "";
}
/** Build the create/update payload. Optional fields collapse to null so an
* update explicitly clears them rather than leaving stale values. */
export function buildCronJobPayload(form: CronJobFormState): CronJobMutation {
// The `continuity` toggle is stored as the reserved "self" entry in
// context_from (the job's own previous output). Users never type "self" —
// the checkbox is the surface; strip any hand-typed variant first.
const contextFrom = splitCronList(form.context_from).filter(
(item) => item.toLowerCase() !== "self",
);
if (form.continuity) contextFrom.push("self");
const enabledToolsets = form.enabled_toolsets.filter(Boolean);
return {
name: form.name.trim(),
prompt: form.prompt.trim(),
schedule: form.schedule.trim(),
deliver: form.deliver.trim() || "local",
skills: form.skills.filter(Boolean),
provider: optionalText(form.provider),
model: optionalText(form.model),
base_url: optionalText(form.base_url, true),
script: optionalText(form.script),
no_agent: Boolean(form.no_agent),
context_from: contextFrom.length > 0 ? contextFrom : null,
enabled_toolsets: enabledToolsets.length > 0 ? enabledToolsets : null,
workdir: optionalText(form.workdir),
};
}
export function cronJobHasExecutionContent(
job: Pick<CronJobMutation, "prompt" | "skills" | "script">,
): boolean {
const skills = Array.isArray(job.skills) ? job.skills.filter(Boolean) : [];
return Boolean(asString(job.prompt).trim() || asString(job.script).trim() || skills.length);
}
export function cronJobFormFromJob(job: CronJob): CronJobFormState {
const storedRefs = splitCronList(job.context_from);
// Raw store records carry the reserved "self" entry inside context_from;
// tool/RPC-formatted records strip it and set an explicit continuity flag.
const continuity =
Boolean((job as { continuity?: boolean }).continuity) ||
storedRefs.some((item) => item.toLowerCase() === "self");
const externalRefs = storedRefs.filter((item) => item.toLowerCase() !== "self");
return {
name: asString(job.name),
prompt: asString(job.prompt),
schedule:
asString(job.schedule?.expr) ||
asString(job.schedule?.run_at) ||
asString(job.schedule_display),
deliver: asString(job.deliver) || "local",
skills: Array.isArray(job.skills) ? job.skills.filter(Boolean) : [],
provider: asString(job.provider),
model: asString(job.model),
base_url: asString(job.base_url),
script: asString(job.script),
no_agent: Boolean(job.no_agent),
context_from: externalRefs.join("\n"),
continuity,
enabled_toolsets: splitCronList(job.enabled_toolsets),
workdir: asString(job.workdir),
};
}
/** How a job's `last_status` should render. The scheduler writes a small,
* closed set of literals; every literal maps to an explicit tone here so a
* new status can never fall through to a neutral "unknown"-looking badge.
* In particular `delivery_failed` (agent run succeeded, output never reached
* the target) is amber, not green and not the same red as a run error, and
* its detail lives in `last_delivery_error` (last_error is null for it). */
export type CronLastResultTone = "success" | "warning" | "destructive";
export interface CronLastResult {
status: string;
tone: CronLastResultTone;
/** Human detail to show next to the badge; null when nothing to add. */
detail: string | null;
}
const CRON_LAST_RESULT_TONE: Record<string, CronLastResultTone> = {
ok: "success",
delivery_failed: "warning",
blocked_config: "warning",
error: "destructive",
};
export function cronLastResult(
job: Pick<CronJob, "last_status" | "last_error" | "last_delivery_error">,
): CronLastResult | null {
const status = asString(job.last_status).trim();
if (!status) return null;
const tone = CRON_LAST_RESULT_TONE[status] ?? "destructive";
if (status === "ok") return { status, tone, detail: null };
const detail =
status === "delivery_failed"
? asString(job.last_delivery_error).trim() || asString(job.last_error).trim()
: asString(job.last_error).trim() || asString(job.last_delivery_error).trim();
return { status, tone, detail: detail || null };
}
+114
View File
@@ -0,0 +1,114 @@
import { describe, expect, it, vi } from "vitest";
import { createCronTriggerController } from "@hermes/shared";
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, reject, resolve };
}
describe("createCronTriggerController", () => {
it("announces immediately and coalesces the same job while it is running", async () => {
const request = deferred<string>();
const order: string[] = [];
const action = vi.fn(() => {
order.push("action");
return request.promise;
});
const onStarted = vi.fn(() => order.push("started"));
const onRunningChange = vi.fn();
const controller = createCronTriggerController(onRunningChange);
const first = controller.run("profile-a:job-1", action, onStarted);
const duplicate = await controller.run("profile-a:job-1", action, onStarted);
expect(action).toHaveBeenCalledTimes(1);
expect(onStarted).toHaveBeenCalledTimes(1);
expect(order).toEqual(["started", "action"]);
expect(onRunningChange).toHaveBeenNthCalledWith(1, "profile-a:job-1", true);
expect(duplicate).toEqual({ started: false, value: null });
request.resolve("done");
await expect(first).resolves.toEqual({ started: true, value: "done" });
expect(onRunningChange).toHaveBeenLastCalledWith("profile-a:job-1", false);
});
it("releases the job after failure so a retry can start", async () => {
const request = deferred<void>();
const controller = createCronTriggerController();
const failed = controller.run("job-1", () => request.promise);
request.reject(new Error("failed"));
await expect(failed).rejects.toThrow("failed");
await expect(controller.run("job-1", async () => "retried")).resolves.toEqual({
started: true,
value: "retried",
});
});
it("releases the job when the immediate feedback callback fails", async () => {
const controller = createCronTriggerController();
await expect(
controller.run("job-1", async () => "not-called", () => {
throw new Error("toast failed");
}),
).rejects.toThrow("toast failed");
await expect(controller.run("job-1", async () => "retried")).resolves.toEqual({
started: true,
value: "retried",
});
});
it("allows the same job id in different profiles to run concurrently", async () => {
const defaultRequest = deferred<string>();
const workRequest = deferred<string>();
const defaultAction = vi.fn(() => defaultRequest.promise);
const workAction = vi.fn(() => workRequest.promise);
const controller = createCronTriggerController();
const defaultRun = controller.run("default:job-1", defaultAction);
const workRun = controller.run("work:job-1", workAction);
expect(defaultAction).toHaveBeenCalledTimes(1);
expect(workAction).toHaveBeenCalledTimes(1);
defaultRequest.resolve("default");
workRequest.resolve("work");
await expect(defaultRun).resolves.toEqual({ started: true, value: "default" });
await expect(workRun).resolves.toEqual({ started: true, value: "work" });
});
it("releases the job when the running-state callback fails", async () => {
const controller = createCronTriggerController((_key, running) => {
if (running) throw new Error("state callback failed");
});
await expect(controller.run("job-1", async () => "not-called")).rejects.toThrow(
"state callback failed",
);
expect(controller.isRunning("job-1")).toBe(false);
});
it("releases the job before the stopped-state callback runs", async () => {
const action = vi.fn(async () => "done");
const controller = createCronTriggerController((_key, running) => {
if (!running) throw new Error("stopped callback failed");
});
await expect(controller.run("job-1", action)).rejects.toThrow("stopped callback failed");
expect(controller.isRunning("job-1")).toBe(false);
await expect(controller.run("job-1", action)).rejects.toThrow("stopped callback failed");
expect(action).toHaveBeenCalledTimes(2);
});
});
+70
View File
@@ -0,0 +1,70 @@
import { describe, expect, it, vi } from "vitest";
import {
attemptDashboardTokenReloadOnce,
clearDashboardTokenReloadAttempt,
maybeReloadForLoopbackWsAuthFailure,
} from "./dashboard-auth-reload";
function makeStorage() {
const values = new Map<string, string>();
return {
getItem(key: string) {
return values.get(key) ?? null;
},
removeItem(key: string) {
values.delete(key);
},
setItem(key: string, value: string) {
values.set(key, value);
},
};
}
describe("attemptDashboardTokenReloadOnce", () => {
it("reloads once and latches the attempt", () => {
const storage = makeStorage();
const reload = vi.fn();
expect(attemptDashboardTokenReloadOnce(storage, reload)).toBe(true);
expect(reload).toHaveBeenCalledTimes(1);
expect(attemptDashboardTokenReloadOnce(storage, reload)).toBe(false);
expect(reload).toHaveBeenCalledTimes(1);
});
it("clears the latch when asked", () => {
const storage = makeStorage();
const reload = vi.fn();
expect(attemptDashboardTokenReloadOnce(storage, reload)).toBe(true);
clearDashboardTokenReloadAttempt(storage);
expect(attemptDashboardTokenReloadOnce(storage, reload)).toBe(true);
expect(reload).toHaveBeenCalledTimes(2);
});
});
describe("maybeReloadForLoopbackWsAuthFailure", () => {
it("reloads once for loopback 4401 closes", () => {
const storage = makeStorage();
const reload = vi.fn();
expect(
maybeReloadForLoopbackWsAuthFailure(4401, false, storage, reload),
).toBe(true);
expect(reload).toHaveBeenCalledTimes(1);
});
it("does not reload in gated mode or for other close codes", () => {
const storage = makeStorage();
const reload = vi.fn();
expect(
maybeReloadForLoopbackWsAuthFailure(4401, true, storage, reload),
).toBe(false);
expect(
maybeReloadForLoopbackWsAuthFailure(4403, false, storage, reload),
).toBe(false);
expect(reload).not.toHaveBeenCalled();
});
});
+69
View File
@@ -0,0 +1,69 @@
type StorageLike = Pick<Storage, "getItem" | "removeItem" | "setItem">;
const TOKEN_RELOAD_STORAGE_KEY = "hermes.tokenReloadAttempted";
function dashboardAuthRequired(): boolean {
return typeof window !== "undefined" && !!window.__HERMES_AUTH_REQUIRED__;
}
function reloadDashboardWindow(): void {
if (typeof window !== "undefined") {
window.location.reload();
}
}
function dashboardSessionStorage(): StorageLike | null {
if (typeof window === "undefined") return null;
try {
return window.sessionStorage;
} catch {
return null;
}
}
export function clearDashboardTokenReloadAttempt(
storage: StorageLike | null = dashboardSessionStorage(),
): void {
try {
storage?.removeItem(TOKEN_RELOAD_STORAGE_KEY);
} catch {
/* privacy mode / blocked storage — ignore */
}
}
export function attemptDashboardTokenReloadOnce(
storage: StorageLike | null = dashboardSessionStorage(),
reload: () => void = reloadDashboardWindow,
): boolean {
let alreadyReloaded = false;
try {
alreadyReloaded =
storage?.getItem(TOKEN_RELOAD_STORAGE_KEY) === "1";
} catch {
/* privacy mode / blocked storage — fall through */
}
if (alreadyReloaded) {
return false;
}
try {
storage?.setItem(TOKEN_RELOAD_STORAGE_KEY, "1");
} catch {
/* privacy mode / blocked storage — best effort */
}
reload();
return true;
}
export function maybeReloadForLoopbackWsAuthFailure(
code: number,
authRequired = dashboardAuthRequired(),
storage: StorageLike | null = dashboardSessionStorage(),
reload: () => void = reloadDashboardWindow,
): boolean {
if (authRequired || code !== 4401) {
return false;
}
return attemptDashboardTokenReloadOnce(storage, reload);
}
+24
View File
@@ -0,0 +1,24 @@
declare global {
interface Window {
/**
* Injected by the server as `true`. The embedded TUI Chat surface
* (`/chat`, `/api/ws`, `/api/pty`) is always enabled, so this is
* effectively a constant; kept on `window` for any consumer that reads
* it directly and for parity with the server's bootstrap script.
*/
__HERMES_DASHBOARD_EMBEDDED_CHAT__?: boolean;
}
}
/**
* Whether the dashboard's embedded TUI Chat surface is available.
*
* The embedded chat (`/chat` tab, `/api/ws` + `/api/pty` WebSockets) is now
* an unconditional part of the dashboard — the desktop app and the in-browser
* Chat tab both depend on it — so this always returns `true`. The function is
* retained as a stable seam so call sites don't need to change if the surface
* ever becomes conditional again.
*/
export function isDashboardEmbeddedChatEnabled(): boolean {
return true;
}
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import {
DASHBOARD_MODAL_BACKDROP,
DASHBOARD_MODAL_PANEL,
shouldCloseOuterModalOnEscape,
} from "./dashboard-modal-shell";
describe("dashboard modal shell", () => {
it("uses an opaque panel (bg-card), not the glass Card default", () => {
expect(DASHBOARD_MODAL_PANEL).toMatch(/\bbg-card\b/);
expect(DASHBOARD_MODAL_PANEL).not.toMatch(/bg-background-base\/\d+/);
});
it("keeps the backdrop above page chrome (z-[100])", () => {
expect(DASHBOARD_MODAL_BACKDROP).toMatch(/z-\[100\]/);
expect(DASHBOARD_MODAL_BACKDROP).toMatch(/\bbg-background\/85\b/);
});
it("does not close the outer modal on Escape while a nested picker is open", () => {
expect(shouldCloseOuterModalOnEscape(true)).toBe(false);
expect(shouldCloseOuterModalOnEscape(false)).toBe(true);
});
});
+29
View File
@@ -0,0 +1,29 @@
/**
* Shared dashboard dialog shell classes.
*
* Page `<Card>` defaults to `bg-background-base/80` (glass). That looks fine
* on the page canvas, but as a modal panel it lets the Models page bleed
* through and kills readability — especially on Cyberpunk / mobile.
*
* Modal panels must use opaque `bg-card`; backdrops use the same z-index
* band as Auxiliary / Confirm dialogs so they sit above page chrome.
*
* Callers must `createPortal(..., document.body)` — `z-[100]` alone cannot
* escape the dashboard column's `relative z-2` stacking context (see
* ModelPickerDialog / ToolsetConfigDrawer).
*/
export const DASHBOARD_MODAL_BACKDROP =
"fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4";
export const DASHBOARD_MODAL_PANEL =
"relative w-full border border-border bg-card shadow-2xl";
/**
* Outer modals that host a nested picker (e.g. MoA → ModelPickerDialog)
* must ignore Escape while the picker is open; the picker owns that key.
*/
export function shouldCloseOuterModalOnEscape(
nestedPickerOpen: boolean,
): boolean {
return !nestedPickerOpen;
}
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import type { EnvVarInfo } from "./api";
import { removeDeletedEnvVarFromState } from "./env-state";
function envVar(overrides: Partial<EnvVarInfo> = {}): EnvVarInfo {
return {
is_set: true,
redacted_value: "secr...alue",
description: "",
url: null,
category: "provider",
is_password: true,
tools: [],
advanced: false,
...overrides,
};
}
describe("removeDeletedEnvVarFromState", () => {
it("removes a deleted custom key from dashboard state", () => {
const vars = {
WHATSAPP_DEBUG: envVar({ category: "custom", custom: true }),
OPENAI_API_KEY: envVar(),
};
const updated = removeDeletedEnvVarFromState(vars, "WHATSAPP_DEBUG");
expect(updated).not.toHaveProperty("WHATSAPP_DEBUG");
expect(updated?.OPENAI_API_KEY).toBe(vars.OPENAI_API_KEY);
});
it("keeps a catalog key available while marking it unset", () => {
const vars = { OPENAI_API_KEY: envVar() };
const updated = removeDeletedEnvVarFromState(vars, "OPENAI_API_KEY");
expect(updated?.OPENAI_API_KEY).toEqual({
...vars.OPENAI_API_KEY,
is_set: false,
redacted_value: null,
});
});
});
+21
View File
@@ -0,0 +1,21 @@
import type { EnvVarInfo } from "./api";
/** Reconcile a successful DELETE /api/env response with the Keys page state. */
export function removeDeletedEnvVarFromState(
vars: Record<string, EnvVarInfo> | null,
key: string,
): Record<string, EnvVarInfo> | null {
const info = vars?.[key];
if (!vars || !info) return vars;
if (info.custom) {
const updated = { ...vars };
delete updated[key];
return updated;
}
return {
...vars,
[key]: { ...info, is_set: false, redacted_value: null },
};
}
+124
View File
@@ -0,0 +1,124 @@
import { describe, expect, it } from "vitest";
import {
EVENTS_MAX_RECONNECT_ATTEMPTS,
EVENTS_RECONNECT_BASE_MS,
EVENTS_RECONNECT_MAX_MS,
eventsGaveUpMessage,
eventsReconnectDelayMs,
eventsReconnectingMessage,
eventsRejectedMessage,
isEventsAuthRejection,
isEventsFeedMessage,
shouldRetryEventsClose,
EVENTS_DISCONNECTED_MESSAGE,
} from "./events-reconnect";
describe("eventsReconnectDelayMs", () => {
it("doubles from the base delay", () => {
expect(eventsReconnectDelayMs(0)).toBe(EVENTS_RECONNECT_BASE_MS);
expect(eventsReconnectDelayMs(1)).toBe(EVENTS_RECONNECT_BASE_MS * 2);
expect(eventsReconnectDelayMs(2)).toBe(EVENTS_RECONNECT_BASE_MS * 4);
expect(eventsReconnectDelayMs(3)).toBe(EVENTS_RECONNECT_BASE_MS * 8);
});
it("clamps at the cap and never exceeds it", () => {
for (
let attempt = 0;
attempt <= EVENTS_MAX_RECONNECT_ATTEMPTS + 5;
attempt++
) {
expect(eventsReconnectDelayMs(attempt)).toBeLessThanOrEqual(
EVENTS_RECONNECT_MAX_MS,
);
}
expect(eventsReconnectDelayMs(99)).toBe(EVENTS_RECONNECT_MAX_MS);
});
it("is monotonically non-decreasing", () => {
let previous = 0;
for (let attempt = 0; attempt < 20; attempt++) {
const delay = eventsReconnectDelayMs(attempt);
expect(delay).toBeGreaterThanOrEqual(previous);
previous = delay;
}
});
it("stays finite for absurd attempt counts", () => {
expect(Number.isFinite(eventsReconnectDelayMs(10_000))).toBe(true);
});
});
describe("shouldRetryEventsClose", () => {
it("retries transient drops", () => {
// 1005 (no status) and 1006 (abnormal) are what a killed gateway and a
// dropped network produce respectively.
for (const code of [1001, 1005, 1006, 1011, 1012, 1013]) {
expect(shouldRetryEventsClose(code)).toBe(true);
}
});
it("does not retry a normal closure", () => {
expect(shouldRetryEventsClose(1000)).toBe(false);
});
it("does not retry auth rejections", () => {
expect(shouldRetryEventsClose(4401)).toBe(false);
expect(shouldRetryEventsClose(4403)).toBe(false);
});
it("retries when the code is missing", () => {
expect(shouldRetryEventsClose(undefined)).toBe(true);
});
it("never both retries and reports an auth rejection", () => {
for (const code of [1000, 1005, 1006, 4401, 4403, 4500]) {
expect(shouldRetryEventsClose(code) && isEventsAuthRejection(code)).toBe(
false,
);
}
});
});
describe("isEventsFeedMessage", () => {
it("recognizes every message this module can surface", () => {
expect(isEventsFeedMessage(EVENTS_DISCONNECTED_MESSAGE)).toBe(true);
expect(isEventsFeedMessage(eventsReconnectingMessage(4_000))).toBe(true);
expect(isEventsFeedMessage(eventsRejectedMessage(4401))).toBe(true);
expect(isEventsFeedMessage(eventsGaveUpMessage())).toBe(true);
});
it("does not claim messages owned by other banner sources", () => {
// The banner is shared with info.credential_warning and the JSON-RPC
// sidecar — clearing those on an events reconnect would hide a real
// problem from the user.
expect(isEventsFeedMessage("ANTHROPIC_API_KEY is not set")).toBe(false);
expect(isEventsFeedMessage("WebSocket connection failed")).toBe(false);
expect(isEventsFeedMessage("gateway not connected")).toBe(false);
expect(isEventsFeedMessage(null)).toBe(false);
expect(isEventsFeedMessage("")).toBe(false);
});
});
describe("reconnect message copy", () => {
it("renders the delay in whole seconds", () => {
expect(eventsReconnectingMessage(1_000)).toContain("1s");
expect(eventsReconnectingMessage(30_000)).toContain("30s");
});
it("names the close code in the rejection message", () => {
expect(eventsRejectedMessage(4403)).toContain("4403");
});
it("does not reference the tools box removed in #51737", () => {
const messages = [
EVENTS_DISCONNECTED_MESSAGE,
eventsReconnectingMessage(1_000),
eventsRejectedMessage(4401),
eventsGaveUpMessage(),
];
for (const message of messages) {
expect(message).not.toContain("tool calls");
}
});
});
+84
View File
@@ -0,0 +1,84 @@
/**
* Reconnect policy for the ChatSidebar `/api/events` subscriber socket.
*
* Pure helpers, no DOM: the component owns the socket and the timer, this
* module owns the arithmetic and the "is this close code worth retrying"
* decision so both can be unit-tested without a fake WebSocket.
*/
export const EVENTS_RECONNECT_BASE_MS = 1_000;
export const EVENTS_RECONNECT_MAX_MS = 30_000;
export const EVENTS_MAX_RECONNECT_ATTEMPTS = 15;
/** Bound ticket minting plus the WebSocket opening handshake. */
export const EVENTS_CONNECT_TIMEOUT_MS = 15_000;
/** Normal closure — the server said goodbye, don't chase it. */
const WS_CLOSE_NORMAL = 1000;
/** Ticket rejected / forbidden: retrying just burns tickets, user must reload. */
const WS_CLOSE_AUTH_CODES = new Set([4401, 4403]);
/**
* Exponential backoff, 1s → 2s → 4s → … → 30s cap.
*
* `attempt` is 0-based: attempt 0 is the first retry after the initial
* connection dropped.
*/
export function eventsReconnectDelayMs(attempt: number): number {
const exponent = Math.max(0, Math.trunc(attempt));
// 2 ** exponent overflows to Infinity long before it matters; Math.min
// still clamps correctly, but guard anyway so the delay stays a number.
const raw = EVENTS_RECONNECT_BASE_MS * 2 ** Math.min(exponent, 32);
return Math.min(raw, EVENTS_RECONNECT_MAX_MS);
}
/**
* Whether a close code should trigger a retry.
*
* Auth rejections are terminal (the banner tells the user to reload) and a
* normal 1000 close is intentional. Everything else — gateway restart,
* network drop, 1005/1006, proxy timeout — is worth retrying.
*/
export function shouldRetryEventsClose(code: number | undefined): boolean {
if (code === undefined) {
return true;
}
return code !== WS_CLOSE_NORMAL && !WS_CLOSE_AUTH_CODES.has(code);
}
export function isEventsAuthRejection(code: number | undefined): boolean {
return code !== undefined && WS_CLOSE_AUTH_CODES.has(code);
}
// The sidebar's banner is shared with `info.credential_warning` and with the
// JSON-RPC sidecar's errors, so the events socket may only clear a message it
// wrote itself. Everything this module can put in the banner is listed here.
export const EVENTS_DISCONNECTED_MESSAGE =
"events feed disconnected — the chat title may not update";
export function eventsReconnectingMessage(delayMs: number): string {
return `events feed disconnected — reconnecting in ${Math.round(delayMs / 1000)}s…`;
}
export function eventsRejectedMessage(code: number): string {
return `events feed rejected (${code}) — reload the page`;
}
export function eventsGaveUpMessage(): string {
return `events feed disconnected — gave up after ${EVENTS_MAX_RECONNECT_ATTEMPTS} attempts, reload the page`;
}
/**
* True when `message` is one this module produced, i.e. safe to clear on a
* successful reconnect. Guards against stomping a `credential_warning` or a
* sidecar error that happens to be showing when the feed recovers.
*/
export function isEventsFeedMessage(message: string | null): boolean {
if (!message) {
return false;
}
return message.startsWith("events feed ");
}
+9
View File
@@ -0,0 +1,9 @@
/**
* Format a token count as a human-readable string (e.g. 1M, 128K, 4096).
* Strips trailing ".0" for clean round numbers.
*/
export function formatTokenCount(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(n % 1_000 === 0 ? 0 : 1)}K`;
return String(n);
}
+192
View File
@@ -0,0 +1,192 @@
// Lightweight fuzzy subsequence scorer for picker filtering.
//
// Matches a query as an ordered subsequence of the target (so `g4o` matches
// `gpt-4o`) and scores by match quality so callers can rank results. Higher
// score is a better match. Returns the matched character indices so callers
// can highlight them.
//
// The scoring favours, in rough order: exact full match, prefix match, matches
// that start on a word boundary (after `-`, `_`, `/`, `.`, space, or a
// lower→upper case transition), contiguous runs, and earlier matches. This is
// intentionally simple — no external dependency — but good enough to make
// `son4` rank `claude-sonnet-4` above an incidental scattered hit.
//
// This is a logically identical copy of ui-tui/src/lib/fuzzy.ts (only prettier
// formatting differs); keep the two in sync. The TUI copy carries the vitest
// suite (this `web` package has no test runner), so behavioural changes should
// be validated there.
export interface FuzzyMatch {
/** Total score; higher is better. */
score: number;
/** Indices into the original (non-lowercased) target that were matched. */
positions: number[];
}
const WORD_BOUNDARY = /[-_/.\s]/;
function isBoundary(target: string, index: number): boolean {
if (index === 0) {
return true;
}
const prev = target[index - 1];
if (WORD_BOUNDARY.test(prev)) {
return true;
}
// camelCase / lower→upper transition (e.g. the `O` in `gptO`).
const cur = target[index];
return (
prev === prev.toLowerCase() &&
cur !== cur.toLowerCase() &&
cur === cur.toUpperCase()
);
}
/**
* Score a single query token against a target. Returns null when the token is
* not a subsequence of the target. An empty query scores 0 with no positions.
*/
export function fuzzyScore(target: string, query: string): FuzzyMatch | null {
if (!query) {
return { score: 0, positions: [] };
}
const lowerTarget = target.toLowerCase();
const lowerQuery = query.toLowerCase();
const positions: number[] = [];
let score = 0;
let prevIndex = -1;
let searchFrom = 0;
for (const ch of lowerQuery) {
const idx = lowerTarget.indexOf(ch, searchFrom);
if (idx < 0) {
return null;
}
positions.push(idx);
// Base point for the matched character.
score += 1;
// Contiguous with the previous match → strong bonus.
if (prevIndex >= 0 && idx === prevIndex + 1) {
score += 5;
} else if (prevIndex >= 0) {
// Penalise the gap we had to skip (capped), so contiguous beats scattered.
score -= Math.min(idx - prevIndex - 1, 3);
}
// Word-boundary / start-of-string matches are meaningful.
if (isBoundary(target, idx)) {
score += 3;
}
// Matching the very first character of the target is the strongest signal.
if (idx === 0) {
score += 5;
}
prevIndex = idx;
searchFrom = idx + 1;
}
// Prefix bonus: the query matched a contiguous prefix of the target.
if (
positions.length &&
positions[0] === 0 &&
positions[positions.length - 1] === positions.length - 1
) {
score += 8;
}
// Exact full match dominates everything else.
if (lowerTarget === lowerQuery) {
score += 20;
}
// Slightly prefer shorter targets when scores are otherwise close, so a
// query that fully prefixes a short id beats the same prefix on a long one.
score -= lowerTarget.length * 0.01;
return { score, positions };
}
/**
* Score a target against a whitespace-separated, multi-token query. Every token
* must match (AND semantics); the result aggregates per-token scores and the
* union of matched positions. Returns null if any token fails to match.
*/
export function fuzzyScoreMulti(
target: string,
query: string,
): FuzzyMatch | null {
const tokens = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
if (!tokens.length) {
return { score: 0, positions: [] };
}
let score = 0;
const positionSet = new Set<number>();
for (const token of tokens) {
const match = fuzzyScore(target, token);
if (!match) {
return null;
}
score += match.score;
for (const pos of match.positions) {
positionSet.add(pos);
}
}
return { score, positions: [...positionSet].sort((a, b) => a - b) };
}
export interface RankedItem<T> {
item: T;
score: number;
positions: number[];
}
/**
* Filter + rank a list by a fuzzy query against a derived text key. Non-matching
* items are dropped; matches are sorted by score (descending), ties broken by
* the original index so ordering is stable for equal scores. An empty query
* returns every item in original order with no positions.
*/
export function fuzzyRank<T>(
items: readonly T[],
query: string,
toText: (item: T) => string,
): RankedItem<T>[] {
const trimmed = query.trim();
if (!trimmed) {
return items.map((item) => ({ item, score: 0, positions: [] }));
}
const ranked: Array<RankedItem<T> & { index: number }> = [];
items.forEach((item, index) => {
const match = fuzzyScoreMulti(toText(item), trimmed);
if (match) {
ranked.push({ item, score: match.score, positions: match.positions, index });
}
});
ranked.sort((a, b) => b.score - a.score || a.index - b.index);
return ranked.map(({ item, score, positions }) => ({ item, score, positions }));
}
+96
View File
@@ -0,0 +1,96 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { GatewayClient } from "./gatewayClient";
const reloadMocks = vi.hoisted(() => ({
maybeReloadForLoopbackWsAuthFailure: vi.fn(() => false),
}));
vi.mock("./dashboard-auth-reload", () => ({
maybeReloadForLoopbackWsAuthFailure:
reloadMocks.maybeReloadForLoopbackWsAuthFailure,
}));
class FakeWebSocket {
static instances: FakeWebSocket[] = [];
static OPEN = 1;
listeners = new Map<string, Array<(event: EventLike) => void>>();
readyState = 0;
url: string;
constructor(url: string) {
this.url = url;
FakeWebSocket.instances.push(this);
}
addEventListener(type: string, cb: (event: EventLike) => void) {
const list = this.listeners.get(type) ?? [];
list.push(cb);
this.listeners.set(type, list);
}
close() {}
emit(type: string, event: EventLike) {
for (const cb of this.listeners.get(type) ?? []) {
cb(event);
}
}
removeEventListener(type: string, cb: (event: EventLike) => void) {
const list = this.listeners.get(type) ?? [];
this.listeners.set(
type,
list.filter((item) => item !== cb),
);
}
send() {}
}
type EventLike = {
code?: number;
};
beforeEach(() => {
FakeWebSocket.instances = [];
reloadMocks.maybeReloadForLoopbackWsAuthFailure.mockClear();
vi.stubGlobal("WebSocket", FakeWebSocket);
Object.defineProperty(window, "__HERMES_SESSION_TOKEN__", {
configurable: true,
value: "stale-token",
writable: true,
});
Object.defineProperty(window, "__HERMES_AUTH_REQUIRED__", {
configurable: true,
value: false,
writable: true,
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("GatewayClient", () => {
it("treats loopback 4401 closes as stale-token reload candidates", async () => {
reloadMocks.maybeReloadForLoopbackWsAuthFailure.mockReturnValue(true);
const gw = new GatewayClient();
const connectPromise = gw.connect();
await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1));
const socket = FakeWebSocket.instances[0];
socket.readyState = 1;
socket.emit("open", {});
await connectPromise;
socket.emit("close", { code: 4401 });
expect(
reloadMocks.maybeReloadForLoopbackWsAuthFailure,
).toHaveBeenCalledWith(4401);
expect(gw.connectionState).toBe("open");
});
});
+63
View File
@@ -0,0 +1,63 @@
/**
* Browser WebSocket client for the tui_gateway JSON-RPC protocol.
*
* Speaks the exact same newline-delimited JSON-RPC dialect that the Ink TUI
* drives over stdio. The server-side transport abstraction
* (tui_gateway/transport.py + ws.py) routes the same dispatcher's writes
* onto either stdout or a WebSocket depending on how the client connected.
*
* const gw = new GatewayClient()
* await gw.connect()
* const { session_id } = await gw.request<{ session_id: string }>("session.create")
* gw.on("message.delta", (ev) => console.log(ev.payload?.text))
* await gw.request("prompt.submit", { session_id, text: "hi" })
*/
import {
JsonRpcGatewayClient,
buildHermesWebSocketUrl,
type ConnectionState,
type GatewayEvent,
type GatewayEventName,
} from "@hermes/shared";
import { HERMES_BASE_PATH, buildWsAuthParam } from "@/lib/api";
import { maybeReloadForLoopbackWsAuthFailure } from "@/lib/dashboard-auth-reload";
export type { ConnectionState, GatewayEvent, GatewayEventName };
export class GatewayClient extends JsonRpcGatewayClient {
constructor() {
super({
closedErrorMessage: "WebSocket closed",
connectErrorMessage: "WebSocket connection failed",
notConnectedErrorMessage: "gateway not connected",
onSocketClose: (event) => maybeReloadForLoopbackWsAuthFailure(event.code),
requestIdPrefix: "w",
});
}
async connect(token?: string): Promise<void> {
if (this.connectionState === "open" || this.connectionState === "connecting") {
return;
}
// Gated mode: legacy ``?token=`` is rejected by ``_ws_auth_ok``; the SPA
// must fetch a single-use ticket. Explicit ``token`` keeps the test-only
// override path.
const authParam = token ? (["token", token] as const) : await buildWsAuthParam();
if (!authParam[1]) {
throw new Error(
"Session token not available — page must be served by the Hermes dashboard server",
);
}
await super.connect(
buildHermesWebSocketUrl({
authParam,
basePath: HERMES_BASE_PATH,
path: "/api/ws",
}),
);
}
}
+79
View File
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import {
computeKeyboardInset,
KEYBOARD_INSET_MIN_PX,
shouldPinScroll,
} from "./keyboard-inset";
describe("computeKeyboardInset", () => {
it("returns 0 when visualViewport is unavailable", () => {
expect(computeKeyboardInset(null, 800)).toBe(0);
expect(computeKeyboardInset(undefined, 800)).toBe(0);
});
it("returns 0 when no keyboard is showing (vv fills layout)", () => {
expect(computeKeyboardInset({ height: 800, offsetTop: 0 }, 800)).toBe(0);
});
it("measures the obscured region below the visual viewport", () => {
// 800px layout, keyboard eats 320px: vv.height = 480.
expect(computeKeyboardInset({ height: 480, offsetTop: 0 }, 800)).toBe(320);
});
it("accounts for visual-viewport offsetTop (iOS keyboard scroll)", () => {
// iOS nudged the visual viewport down 40px; keyboard covers the rest.
expect(computeKeyboardInset({ height: 480, offsetTop: 40 }, 800)).toBe(
280,
);
});
it("ignores small deltas from collapsing browser chrome", () => {
// URL bar show/hide produces deltas well under a real keyboard height.
const delta = KEYBOARD_INSET_MIN_PX - 1;
expect(
computeKeyboardInset({ height: 800 - delta, offsetTop: 0 }, 800),
).toBe(0);
});
it("accepts insets at exactly the threshold", () => {
expect(
computeKeyboardInset(
{ height: 800 - KEYBOARD_INSET_MIN_PX, offsetTop: 0 },
800,
),
).toBe(KEYBOARD_INSET_MIN_PX);
});
it("never goes negative when vv is larger than layout height", () => {
// Rotation / zoom races can transiently report vv.height > innerHeight.
expect(computeKeyboardInset({ height: 900, offsetTop: 0 }, 800)).toBe(0);
});
it("returns 0 for degenerate layout heights", () => {
expect(computeKeyboardInset({ height: 480, offsetTop: 0 }, 0)).toBe(0);
expect(computeKeyboardInset({ height: 480, offsetTop: 0 }, -1)).toBe(0);
expect(computeKeyboardInset({ height: 480, offsetTop: 0 }, NaN)).toBe(0);
});
it("returns 0 for non-finite viewport values", () => {
expect(computeKeyboardInset({ height: NaN, offsetTop: 0 }, 800)).toBe(0);
expect(computeKeyboardInset({ height: 480, offsetTop: NaN }, 800)).toBe(0);
});
it("rounds fractional geometry to whole pixels", () => {
// iOS reports fractional vv heights under pinch zoom.
expect(
computeKeyboardInset({ height: 479.5, offsetTop: 0.25 }, 800),
).toBe(320);
});
});
describe("shouldPinScroll", () => {
it("pins while a keyboard inset is active", () => {
expect(shouldPinScroll(320)).toBe(true);
});
it("does not pin without a keyboard", () => {
expect(shouldPinScroll(0)).toBe(false);
});
});
+71
View File
@@ -0,0 +1,71 @@
/**
* Soft-keyboard inset math for the dashboard chat terminal (NS-434).
*
* Problem: when the on-screen keyboard opens on mobile, neither iOS Safari
* nor Android Chrome (default `interactive-widget=resizes-visual`) shrinks
* the *layout* viewport — the keyboard just overlays it. Our app shell is
* `h-dvh`, so the terminal host's bounding box doesn't change, `fit()`
* computes the same (cols, rows), and the PTY never re-lays-out. The Ink
* input line — drawn at the bottom of the grid — ends up hidden underneath
* the keyboard.
*
* The only reliable signal is `window.visualViewport`: its `height` shrinks
* to the visible region above the keyboard, and `offsetTop` reflects any
* visual-viewport scroll (iOS nudges the page when an input focuses). The
* keyboard inset is the part of the layout viewport below the visual one:
*
* inset = layoutHeight - vv.height - vv.offsetTop
*
* ChatPage applies this as bottom padding on the terminal wrapper, which
* shrinks the xterm host → ResizeObserver refit → `term.onResize` sends
* `RESIZE` to the PTY → Ink redraws the input line above the keyboard.
*
* We also set `interactive-widget=resizes-content` in the viewport meta so
* Android Chrome 108+ resizes the layout viewport natively; there the inset
* computes ≈ 0 and this path is a harmless no-op. iOS ignores the directive
* and takes the JS path.
*/
/**
* Insets smaller than this are treated as 0. Collapsing browser chrome
* (URL bar show/hide) produces small transient height deltas that would
* otherwise thrash the terminal grid; real soft keyboards are ≥ ~150px.
*/
export const KEYBOARD_INSET_MIN_PX = 80;
export interface ViewportGeometry {
/** `visualViewport.height` — visible height above the keyboard. */
height: number;
/** `visualViewport.offsetTop` — visual viewport's offset into layout. */
offsetTop: number;
}
/**
* Height (px) of the layout viewport currently obscured by the soft
* keyboard, or 0 when no keyboard is showing / the signal is unusable.
*/
export function computeKeyboardInset(
viewport: ViewportGeometry | null | undefined,
layoutHeightPx: number,
): number {
if (!viewport || !Number.isFinite(layoutHeightPx) || layoutHeightPx <= 0) {
return 0;
}
const { height, offsetTop } = viewport;
if (!Number.isFinite(height) || !Number.isFinite(offsetTop)) return 0;
const inset = Math.round(layoutHeightPx - height - offsetTop);
return inset >= KEYBOARD_INSET_MIN_PX ? inset : 0;
}
/**
* Whether the page scroll should be pinned back to the top.
*
* The dashboard shell is a fixed `h-dvh` column and must never scroll, but
* iOS Safari auto-scrolls the *page* when a focused input would sit under
* the keyboard (xterm's hidden textarea triggers this). Pin whenever a
* keyboard is present so the terminal chrome stays put; the terminal's own
* scrollback handles content visibility.
*/
export function shouldPinScroll(nextInsetPx: number): boolean {
return nextInsetPx > 0;
}
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { classifyLine } from "./log-classify";
const TS = "2026-07-26 13:07:45,228";
describe("classifyLine", () => {
it("classifies by the structured level token", () => {
expect(classifyLine(`${TS} INFO run_agent: client created`)).toBe("info");
expect(classifyLine(`${TS} DEBUG registry: scanned 12 tools`)).toBe(
"debug",
);
expect(classifyLine(`${TS} WARNING hermes_state: WAL disabled`)).toBe(
"warning",
);
expect(classifyLine(`${TS} ERROR gateway: connect failed`)).toBe("error");
expect(classifyLine(`${TS} CRITICAL agent: giving up`)).toBe("error");
});
it("does NOT flag INFO lines whose payload mentions errors", () => {
// Regression: these rendered red with the old substring heuristic.
expect(
classifyLine(
`${TS} INFO tui_gateway.ws: ws closed peer=127.0.0.1:43212 parse_errors=0 dispatch_crashes=0 send_failures=1`,
),
).toBe("info");
expect(classifyLine(`${TS} INFO logs: rotating errors.log`)).toBe("info");
expect(
classifyLine(`${TS} INFO retry: recovered from FatalError subclass`),
).toBe("info");
});
it("does NOT let payload text spoof a higher level", () => {
expect(
classifyLine(`${TS} INFO chat: user said "ERROR ERROR ERROR"`),
).toBe("info");
expect(classifyLine(`${TS} DEBUG probe: WARNING string seen`)).toBe(
"debug",
);
});
it("falls back to word-boundary matching for untimestamped lines", () => {
expect(classifyLine("ValueError: bad input")).toBe("info"); // no bare token
expect(classifyLine("ERROR: something broke")).toBe("error");
expect(classifyLine("Traceback (most recent call last):")).toBe("error");
expect(classifyLine(" WARNING low disk")).toBe("warning");
expect(classifyLine("errors=0 all good")).toBe("info");
expect(classifyLine("wrote to errors.log")).toBe("info");
expect(classifyLine("just a plain line")).toBe("info");
});
});
+36
View File
@@ -0,0 +1,36 @@
/**
* Log-line level classification for the dashboard Logs page.
*
* Prefers the structured level token emitted by hermes_logging
* ("2026-07-26 13:07:45,228 INFO …"); falls back to word-boundary matching
* for untimestamped lines (tracebacks, wrapped continuations). Plain
* substring matching is deliberately avoided — INFO lines carrying payloads
* like "parse_errors=0" or paths like "errors.log" must not render red.
*/
export type LogLevel = "error" | "warning" | "info" | "debug";
// Level token as emitted by hermes_logging, anchored to the line head so
// payload text can't spoof the level.
const LEVEL_TOKEN_RE =
/^\d{4}-\d{2}-\d{2}[ T][\d:,.]+\s+(DEBUG|INFO|WARNING|WARN|ERROR|CRITICAL|FATAL)\b/;
export function classifyLine(line: string): LogLevel {
const token = LEVEL_TOKEN_RE.exec(line)?.[1];
if (token) {
if (token === "ERROR" || token === "CRITICAL" || token === "FATAL")
return "error";
if (token === "WARNING" || token === "WARN") return "warning";
if (token === "DEBUG") return "debug";
return "info";
}
const upper = line.toUpperCase();
if (
/\b(ERROR|CRITICAL|FATAL)\b/.test(upper) ||
upper.startsWith("TRACEBACK (")
)
return "error";
if (/\b(WARNING|WARN)\b/.test(upper)) return "warning";
if (/\bDEBUG\b/.test(upper)) return "debug";
return "info";
}
+146
View File
@@ -0,0 +1,146 @@
import { describe, expect, it, vi } from "vitest";
import { completeMcpDashboardOAuth } from "./mcp-dashboard-oauth";
describe("completeMcpDashboardOAuth", () => {
it("opens the authorization URL in the dashboard browser and polls to approval", async () => {
const authWindow = { location: { href: "" }, opener: {} } as unknown as Window;
const open = vi.fn().mockReturnValue(authWindow);
const start = vi.fn().mockResolvedValue({
flow_id: "flow-1",
server_name: "reports",
status: "authorization_required",
authorization_url: "https://idp.example/authorize",
error: null,
});
const status = vi
.fn()
.mockResolvedValueOnce({
flow_id: "flow-1",
server_name: "reports",
status: "authorization_required",
authorization_url: "https://idp.example/authorize",
error: null,
tools: [],
})
.mockResolvedValueOnce({
flow_id: "flow-1",
server_name: "reports",
status: "approved",
authorization_url: "https://idp.example/authorize",
error: null,
tools: [{ name: "list_reports", description: "List reports" }],
});
const result = await completeMcpDashboardOAuth({
serverName: "reports",
start,
status,
open,
sleep: async () => {},
});
expect(open).toHaveBeenCalledWith(
"about:blank",
"_blank",
);
expect(authWindow.opener).toBeNull();
expect(authWindow.location.href).toBe("https://idp.example/authorize");
expect(status).toHaveBeenCalledTimes(2);
expect(result.status).toBe("approved");
});
it("surfaces a terminal OAuth error", async () => {
const close = vi.fn();
await expect(
completeMcpDashboardOAuth({
serverName: "reports",
start: async () => ({
flow_id: "flow-2",
server_name: "reports",
status: "error",
authorization_url: null,
error: "registration denied",
}),
status: vi.fn(),
open: vi.fn().mockReturnValue({ location: { href: "" }, close }),
sleep: async () => {},
}),
).rejects.toThrow("registration denied");
expect(close).toHaveBeenCalledOnce();
});
it("fails before starting when the browser blocks the popup", async () => {
const start = vi.fn();
await expect(
completeMcpDashboardOAuth({
serverName: "reports",
start,
status: vi.fn(),
open: vi.fn().mockReturnValue(null),
}),
).rejects.toThrow("popup was blocked");
expect(start).not.toHaveBeenCalled();
});
it("fails when the authorization window closes before approval", async () => {
const authWindow = { location: { href: "" }, opener: {}, closed: false } as unknown as Window;
const status = vi.fn().mockImplementation(async () => {
Object.defineProperty(authWindow, "closed", { value: true });
return {
flow_id: "flow-closed",
server_name: "reports",
status: "authorization_required",
authorization_url: "https://idp.example/authorize",
error: null,
};
});
await expect(
completeMcpDashboardOAuth({
serverName: "reports",
start: async () => ({
flow_id: "flow-closed",
server_name: "reports",
status: "authorization_required",
authorization_url: "https://idp.example/authorize",
error: null,
}),
status,
open: vi.fn().mockReturnValue(authWindow),
sleep: async () => {},
}),
).rejects.toThrow("authorization window was closed");
});
it("retries a transient status failure", async () => {
const authWindow = { location: { href: "" }, opener: {}, closed: false } as unknown as Window;
const status = vi
.fn()
.mockRejectedValueOnce(new Error("temporary network failure"))
.mockResolvedValueOnce({
flow_id: "flow-retry",
server_name: "reports",
status: "approved",
authorization_url: "https://idp.example/authorize",
error: null,
tools: [],
});
const result = await completeMcpDashboardOAuth({
serverName: "reports",
start: async () => ({
flow_id: "flow-retry",
server_name: "reports",
status: "authorization_required",
authorization_url: "https://idp.example/authorize",
error: null,
}),
status,
open: vi.fn().mockReturnValue(authWindow),
sleep: async () => {},
});
expect(result.status).toBe("approved");
expect(status).toHaveBeenCalledTimes(2);
});
});
+66
View File
@@ -0,0 +1,66 @@
import type { McpOAuthFlow } from "./api";
type CompleteOptions = {
serverName: string;
start: (name: string) => Promise<McpOAuthFlow>;
status: (flowId: string) => Promise<McpOAuthFlow>;
open: (url?: string | URL, target?: string, features?: string) => unknown;
sleep?: (milliseconds: number) => Promise<void>;
maxPollFailures?: number;
};
const defaultSleep = (milliseconds: number) =>
new Promise<void>((resolve) => window.setTimeout(resolve, milliseconds));
export async function completeMcpDashboardOAuth({
serverName,
start,
status,
open,
sleep = defaultSleep,
maxPollFailures = 3,
}: CompleteOptions): Promise<McpOAuthFlow> {
// Open synchronously from the click handler, before the first await. Browsers
// otherwise classify the later OAuth popup as unsolicited and block it.
const authWindow = open("about:blank", "_blank") as Window | null;
if (!authWindow) {
throw new Error("OAuth popup was blocked — allow popups for this dashboard and retry");
}
authWindow.opener = null;
let started: McpOAuthFlow;
try {
started = await start(serverName);
if (started.status === "error") {
throw new Error(started.error || "OAuth failed to start");
}
if (!started.authorization_url) {
throw new Error("OAuth server did not provide an authorization URL");
}
authWindow.location.href = started.authorization_url;
} catch (error) {
authWindow.close();
throw error;
}
let pollFailures = 0;
for (;;) {
let current: McpOAuthFlow;
try {
current = await status(started.flow_id);
pollFailures = 0;
} catch (error) {
pollFailures += 1;
if (pollFailures >= maxPollFailures) throw error;
await sleep(1000);
continue;
}
if (current.status === "approved") return current;
if (current.status === "error") {
throw new Error(current.error || "OAuth authorization failed");
}
if (authWindow.closed) {
throw new Error("OAuth authorization window was closed before completion");
}
await sleep(1000);
}
}
+99
View File
@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import { buildMcpServerCreate, emptyMcpServerDraft } from "./mcp-server-create";
describe("buildMcpServerCreate", () => {
it("builds an HTTP Bearer request without stdio fields", () => {
const server = buildMcpServerCreate({
...emptyMcpServerDraft(),
name: " Linear ",
url: " https://mcp.linear.app/mcp ",
httpAuth: "header",
bearerToken: "Bearer secret-token",
command: "ignored",
args: "--ignored",
env: "IGNORED=value",
});
expect(server).toEqual({
name: "Linear",
url: "https://mcp.linear.app/mcp",
auth: "header",
bearer_token: "Bearer secret-token",
});
});
it("builds OAuth and unauthenticated HTTP requests without a token", () => {
expect(
buildMcpServerCreate({
...emptyMcpServerDraft(),
name: "oauth",
url: "https://example.com/mcp",
httpAuth: "oauth",
}),
).toEqual({
name: "oauth",
url: "https://example.com/mcp",
auth: "oauth",
});
expect(
buildMcpServerCreate({
...emptyMcpServerDraft(),
name: "public",
url: "https://example.com/mcp",
}),
).toEqual({
name: "public",
url: "https://example.com/mcp",
});
});
it("parses stdio arguments and environment assignments", () => {
const server = buildMcpServerCreate({
...emptyMcpServerDraft(),
name: "local",
transport: "stdio",
command: " uvx ",
args: "mcp-server, --debug",
env: "API_KEY=secret\nURL=https://example.com?a=b\nINVALID",
});
expect(server).toEqual({
name: "local",
command: "uvx",
args: ["mcp-server", "--debug"],
env: {
API_KEY: "secret",
URL: "https://example.com?a=b",
},
});
});
it("rejects missing transport fields and Bearer tokens", () => {
expect(() => buildMcpServerCreate(emptyMcpServerDraft())).toThrow(
"Name required",
);
expect(() =>
buildMcpServerCreate({
...emptyMcpServerDraft(),
name: "remote",
}),
).toThrow("URL required");
expect(() =>
buildMcpServerCreate({
...emptyMcpServerDraft(),
name: "remote",
url: "https://example.com/mcp",
httpAuth: "header",
}),
).toThrow("Bearer token required");
expect(() =>
buildMcpServerCreate({
...emptyMcpServerDraft(),
name: "local",
transport: "stdio",
}),
).toThrow("Command required");
});
});
+78
View File
@@ -0,0 +1,78 @@
import type { McpHttpAuth, McpServerCreate } from "@/lib/api";
export type McpTransport = "http" | "stdio";
export interface McpServerDraft {
name: string;
transport: McpTransport;
url: string;
httpAuth: McpHttpAuth;
bearerToken: string;
command: string;
args: string;
env: string;
}
export function emptyMcpServerDraft(): McpServerDraft {
return {
name: "",
transport: "http",
url: "",
httpAuth: "none",
bearerToken: "",
command: "",
args: "",
env: "",
};
}
function parseArgs(raw: string): string[] {
return raw
.split(/[\s,]+/)
.map((value) => value.trim())
.filter(Boolean);
}
function parseEnv(raw: string): Record<string, string> {
const env: Record<string, string> = {};
for (const rawLine of raw.split("\n")) {
const line = rawLine.trim();
if (!line) continue;
const separator = line.indexOf("=");
if (separator === -1) continue;
const key = line.slice(0, separator).trim();
const value = line.slice(separator + 1).trim();
if (key) env[key] = value;
}
return env;
}
export function buildMcpServerCreate(draft: McpServerDraft): McpServerCreate {
const name = draft.name.trim();
if (!name) throw new Error("Name required");
if (draft.transport === "http") {
const url = draft.url.trim();
if (!url) throw new Error("URL required");
if (draft.httpAuth === "header" && !draft.bearerToken.trim()) {
throw new Error("Bearer token required");
}
const server: McpServerCreate = { name, url };
if (draft.httpAuth !== "none") server.auth = draft.httpAuth;
if (draft.httpAuth === "header") {
server.bearer_token = draft.bearerToken;
}
return server;
}
const command = draft.command.trim();
if (!command) throw new Error("Command required");
const server: McpServerCreate = { name, command };
const args = parseArgs(draft.args);
if (args.length) server.args = args;
const env = parseEnv(draft.env);
if (Object.keys(env).length) server.env = env;
return server;
}
+38
View File
@@ -0,0 +1,38 @@
import { describe, it, expect } from "vitest";
import { queryMatchesProviderOnly } from "./model-picker-filter";
describe("queryMatchesProviderOnly", () => {
it("returns true when the query finds the provider but no model id (issue #65374)", () => {
// Reproduces the exact case from the issue: typing "aws" locates the
// "AWS Build" provider, but none of its Claude model ids contain "aws".
const provider = { name: "AWS Build", slug: "aws-build" };
const models = ["claude-sonnet-4.5", "claude-sonnet-4", "claude-haiku-4.5"];
expect(queryMatchesProviderOnly(provider, models, "aws")).toBe(true);
});
it("returns false when the query also matches a model id — keeps normal filtering", () => {
const provider = { name: "AWS Build", slug: "aws-build" };
const models = ["claude-sonnet-4.5", "claude-sonnet-4", "claude-haiku-4.5"];
expect(queryMatchesProviderOnly(provider, models, "sonnet")).toBe(false);
});
it("returns false when the query does not match the provider at all", () => {
const provider = { name: "AWS Build", slug: "aws-build" };
const models = ["claude-sonnet-4.5"];
expect(queryMatchesProviderOnly(provider, models, "openrouter")).toBe(false);
});
it("returns false for an empty query", () => {
const provider = { name: "AWS Build", slug: "aws-build" };
const models = ["claude-sonnet-4.5"];
expect(queryMatchesProviderOnly(provider, models, "")).toBe(false);
});
it("returns false when there is no selected provider", () => {
expect(queryMatchesProviderOnly(null, ["claude-sonnet-4.5"], "aws")).toBe(false);
});
});
+23
View File
@@ -0,0 +1,23 @@
import { fuzzyScoreMulti } from "@/lib/fuzzy";
/**
* True when `trimmedQuery` located the selected provider by name/slug but
* matches none of its models by id — the case where a single search box
* filtering both the provider and model columns would otherwise leave the
* model pane empty even though the user just successfully found the
* provider they were looking for.
*/
export function queryMatchesProviderOnly(
selectedProvider: { name: string; slug: string } | null,
models: readonly string[],
trimmedQuery: string,
): boolean {
if (!trimmedQuery || !selectedProvider) return false;
const matchesProvider =
fuzzyScoreMulti(`${selectedProvider.name} ${selectedProvider.slug}`, trimmedQuery) !=
null;
const matchesAnyModel = models.some((m) => fuzzyScoreMulti(m, trimmedQuery) != null);
return matchesProvider && !matchesAnyModel;
}
+31
View File
@@ -0,0 +1,31 @@
/**
* Extra tokens used only for model-picker search ranking.
*
* Wire IDs stay unchanged — some providers report short or brand-less ids
* (Kimi Coding's flagship is literally `k3`) that users still search for by
* the familiar `kimi-…` naming of sibling models.
*
* Keep in sync with ui-tui/src/lib/model-search-text.ts and
* hermes_cli/model_search.py. Behavioural tests live in the TUI package.
*/
const MODEL_SEARCH_ALIASES: Record<string, readonly string[]> = {
k3: ["kimi-k3", "kimi"],
// OpenCode Zen serves the "Ox Alpha" stealth model under an opaque
// preview slug; let users find it by its public codename.
"x-preview-f-free": ["ox-alpha", "ox"],
};
/** Haystack for fuzzy/substring model search; never changes the wire id. */
export function modelSearchText(model: string): string {
const id = model.trim();
if (!id) {
return model;
}
const aliases = MODEL_SEARCH_ALIASES[id.toLowerCase()];
if (!aliases?.length) {
return id;
}
return `${id} ${aliases.join(" ")}`;
}
+23
View File
@@ -0,0 +1,23 @@
export function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
const parts = path.split(".");
let cur: unknown = obj;
for (const p of parts) {
if (cur == null || typeof cur !== "object") return undefined;
cur = (cur as Record<string, unknown>)[p];
}
return cur;
}
export function setNestedValue(obj: Record<string, unknown>, path: string, value: unknown): Record<string, unknown> {
const clone = structuredClone(obj);
const parts = path.split(".");
let cur: Record<string, unknown> = clone;
for (let i = 0; i < parts.length - 1; i++) {
if (cur[parts[i]] == null || typeof cur[parts[i]] !== "object") {
cur[parts[i]] = {};
}
cur = cur[parts[i]] as Record<string, unknown>;
}
cur[parts[parts.length - 1]] = value;
return clone;
}
+135
View File
@@ -0,0 +1,135 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createPtyCompositionForwarder } from "./pty-composition";
describe("createPtyCompositionForwarder", () => {
afterEach(() => vi.useRealTimers());
it("forwards committed dead-key text when xterm emits no onData", () => {
vi.useFakeTimers();
const send = vi.fn();
const forwarder = createPtyCompositionForwarder(send);
forwarder.onCompositionEnd("ä");
vi.runAllTimers();
expect(send).toHaveBeenCalledExactlyOnceWith("ä");
});
it("leaves xterm's committed input alone when it arrives before the fallback", () => {
vi.useFakeTimers();
const send = vi.fn();
const forwarder = createPtyCompositionForwarder(send);
forwarder.onCompositionEnd("ä");
forwarder.noteTerminalData("äx");
vi.runAllTimers();
expect(send).not.toHaveBeenCalled();
});
it("forwards a pending composition after unrelated terminal data", () => {
vi.useFakeTimers();
const send = vi.fn();
const forwarder = createPtyCompositionForwarder(send);
forwarder.onCompositionEnd("ä");
forwarder.noteTerminalData("x");
vi.advanceTimersByTime(15);
expect(send).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(send).toHaveBeenCalledExactlyOnceWith("ä");
});
it("forwards a pending composition when unrelated data precedes matching chunks", () => {
vi.useFakeTimers();
const send = vi.fn();
const forwarder = createPtyCompositionForwarder(send);
forwarder.onCompositionEnd("ab");
forwarder.noteTerminalData("x");
forwarder.noteTerminalData("a");
forwarder.noteTerminalData("b");
vi.runAllTimers();
expect(send).toHaveBeenCalledExactlyOnceWith("ab");
});
it("cancels a pending composition when matching text arrives in clean chunks", () => {
vi.useFakeTimers();
const send = vi.fn();
const forwarder = createPtyCompositionForwarder(send);
forwarder.onCompositionEnd("ab");
forwarder.noteTerminalData("a");
forwarder.noteTerminalData("b");
vi.runAllTimers();
expect(send).not.toHaveBeenCalled();
});
it("ignores ESC/SGR data while matching composition chunks", () => {
vi.useFakeTimers();
const send = vi.fn();
const forwarder = createPtyCompositionForwarder(send);
forwarder.onCompositionEnd("ab");
forwarder.noteTerminalData("a");
forwarder.noteTerminalData("\x1b[<0;10;10M");
forwarder.noteTerminalData("b");
vi.runAllTimers();
expect(send).not.toHaveBeenCalled();
});
it("forwards a second composition after the first fallback completes", () => {
vi.useFakeTimers();
const send = vi.fn();
const forwarder = createPtyCompositionForwarder(send);
forwarder.onCompositionEnd("ä");
vi.runAllTimers();
forwarder.onCompositionEnd("ö");
vi.runAllTimers();
expect(send).toHaveBeenNthCalledWith(1, "ä");
expect(send).toHaveBeenNthCalledWith(2, "ö");
});
it("preserves an earlier rapid composition before scheduling the next", () => {
vi.useFakeTimers();
const send = vi.fn();
const forwarder = createPtyCompositionForwarder(send);
forwarder.onCompositionEnd("a");
forwarder.onCompositionEnd("ä");
vi.runAllTimers();
expect(send).toHaveBeenNthCalledWith(1, "a");
expect(send).toHaveBeenNthCalledWith(2, "ä");
});
it("cancels a pending composition on disposal", () => {
vi.useFakeTimers();
const send = vi.fn();
const forwarder = createPtyCompositionForwarder(send);
forwarder.onCompositionEnd("ä");
forwarder.dispose();
vi.runAllTimers();
expect(send).not.toHaveBeenCalled();
});
it("does not send an empty cancelled composition", () => {
vi.useFakeTimers();
const send = vi.fn();
const forwarder = createPtyCompositionForwarder(send);
forwarder.onCompositionEnd("");
vi.runAllTimers();
expect(send).not.toHaveBeenCalled();
});
});
+54
View File
@@ -0,0 +1,54 @@
/**
* Delays an IME/dead-key commit just long enough for xterm to emit onData.
*
* xterm is authoritative when it emits the commit. Browsers/layouts where it
* does not emit onData still forward the compositionend text on the next turn.
*/
export function createPtyCompositionForwarder(send: (data: string) => void) {
let pending: string | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;
let matchedTerminalPrefix = "";
let sawUnrelatedTerminalData = false;
const clearPending = () => {
pending = null;
matchedTerminalPrefix = "";
sawUnrelatedTerminalData = false;
if (timer) {
clearTimeout(timer);
timer = null;
}
};
return {
onCompositionEnd(data: string | null) {
if (!data) return;
// Preserve rapid consecutive commits instead of discarding the first.
const previous = pending;
clearPending();
if (previous) send(previous);
pending = data;
timer = setTimeout(() => {
const committed = pending;
clearPending();
if (committed) send(committed);
}, 16);
},
noteTerminalData(data: string) {
if (!pending || data.startsWith("\x1b") || sawUnrelatedTerminalData) return;
// xterm may split committed text across callbacks, but only a clean,
// leading match is authoritative. Once unrelated data arrives, retain
// the fallback even if later callbacks happen to spell the composition.
const observed = matchedTerminalPrefix + data;
if (observed.startsWith(pending)) {
clearPending();
} else if (pending.startsWith(observed)) {
matchedTerminalPrefix = observed;
} else {
sawUnrelatedTerminalData = true;
}
},
dispose: clearPending,
};
}
@@ -0,0 +1,80 @@
import { describe, expect, it, vi } from 'vitest'
import {
resolvePtyKeyboardShortcut,
sendPtyShortcutSequence,
} from './pty-keyboard-shortcuts'
import type { PtyConnectionState } from './pty-reconnect'
const key = (overrides: Partial<KeyboardEvent> = {}) =>
({
altKey: false,
ctrlKey: false,
key: '',
metaKey: false,
shiftKey: false,
...overrides,
}) as KeyboardEvent
describe('resolvePtyKeyboardShortcut', () => {
it('copies terminal selection with bare Ctrl+C on non-macOS', () => {
expect(
resolvePtyKeyboardShortcut(key({ ctrlKey: true, key: 'c' }), false, true),
).toBe('copy')
})
it('preserves Ctrl+C interrupt when nothing is selected', () => {
expect(
resolvePtyKeyboardShortcut(key({ ctrlKey: true, key: 'c' }), false, false),
).toBe('pass')
})
it('maps Ctrl+Backspace to backward word deletion', () => {
expect(
resolvePtyKeyboardShortcut(
key({ ctrlKey: true, key: 'Backspace' }),
false,
false,
),
).toBe('delete-word-backward')
})
it('maps Ctrl+Delete to forward word deletion', () => {
expect(
resolvePtyKeyboardShortcut(key({ ctrlKey: true, key: 'Delete' }), false, false),
).toBe('delete-word-forward')
})
})
describe('sendPtyShortcutSequence', () => {
const socket = (readyState: number = WebSocket.OPEN) => ({
readyState,
send: vi.fn(),
}) as unknown as Pick<WebSocket, 'readyState' | 'send'> & { send: ReturnType<typeof vi.fn> }
it('sends the sequence when the socket and PTY state are open', () => {
const ws = socket()
expect(sendPtyShortcutSequence(ws, 'open', '\x17')).toBe(true)
expect(ws.send).toHaveBeenCalledOnce()
expect(ws.send).toHaveBeenCalledWith('\x17')
})
it.each<PtyConnectionState>(['connecting', 'reconnecting', 'closed', 'ended'])(
'blocks shortcut bytes while the PTY state is %s',
(state) => {
const ws = socket()
expect(sendPtyShortcutSequence(ws, state, '\x1bd')).toBe(false)
expect(ws.send).not.toHaveBeenCalled()
},
)
it('blocks shortcut bytes when the socket is missing or not open', () => {
const ws = socket(WebSocket.CLOSING)
expect(sendPtyShortcutSequence(null, 'open', '\x17')).toBe(false)
expect(sendPtyShortcutSequence(ws, 'open', '\x17')).toBe(false)
expect(ws.send).not.toHaveBeenCalled()
})
})
+63
View File
@@ -0,0 +1,63 @@
import { shouldBlockPtyInput, type PtyConnectionState } from "./pty-reconnect";
const WS_OPEN = 1;
export type PtyKeyboardShortcut =
| "copy"
| "delete-word-backward"
| "delete-word-forward"
| "pass";
export function resolvePtyKeyboardShortcut(
ev: Pick<KeyboardEvent, "altKey" | "ctrlKey" | "key" | "metaKey" | "shiftKey">,
isMac: boolean,
hasTerminalSelection: boolean,
): PtyKeyboardShortcut {
const key = ev.key.toLowerCase();
const copyPressed = isMac
? ev.metaKey && !ev.ctrlKey
: ev.ctrlKey && !ev.altKey && !ev.metaKey;
if (copyPressed && key === "c" && hasTerminalSelection) {
return "copy";
}
if (
ev.ctrlKey &&
!ev.shiftKey &&
!ev.altKey &&
!ev.metaKey &&
ev.key === "Backspace"
) {
return "delete-word-backward";
}
if (
ev.ctrlKey &&
!ev.shiftKey &&
!ev.altKey &&
!ev.metaKey &&
ev.key === "Delete"
) {
return "delete-word-forward";
}
return "pass";
}
// Guarded sender for shortcut escape sequences. Applies the same gate as the
// term.onData path (socket OPEN + shouldBlockPtyInput) so a reconnecting or
// closed session never receives shortcut bytes.
export function sendPtyShortcutSequence(
ws: Pick<WebSocket, "readyState" | "send"> | null,
ptyState: PtyConnectionState,
sequence: string,
): boolean {
if (!ws || ws.readyState !== WS_OPEN || shouldBlockPtyInput(ptyState)) {
return false;
}
ws.send(sequence);
return true;
}
+100
View File
@@ -0,0 +1,100 @@
import { describe, expect, it } from "vitest";
import {
normalizePtyMobileInput,
shouldTreatInputAsMobileReplacement,
updatePtyInputLine,
} from "./pty-mobile-input";
describe("shouldTreatInputAsMobileReplacement", () => {
it("recognizes explicit browser replacement input", () => {
expect(shouldTreatInputAsMobileReplacement("insertReplacementText", "Kain", false)).toBe(true);
expect(shouldTreatInputAsMobileReplacement("insertFromComposition", "Kain", false)).toBe(true);
expect(shouldTreatInputAsMobileReplacement("insertCompositionText", "Kain", false)).toBe(true);
});
it("treats multi-character mobile insertText as replacement-like", () => {
expect(shouldTreatInputAsMobileReplacement("insertText", "Kain", true)).toBe(true);
expect(shouldTreatInputAsMobileReplacement("insertText", "K", true)).toBe(false);
expect(shouldTreatInputAsMobileReplacement("insertText", "Kain", false)).toBe(false);
});
});
describe("normalizePtyMobileInput", () => {
it("turns a Gboard full-line suggestion into a line replacement", () => {
const result = normalizePtyMobileInput(
"hello my name is Kain Kain",
"hello my name is kain",
true,
);
expect(result.normalized).toBe(true);
expect(result.nextLine).toBe("hello my name is Kain");
expect(result.data).toBe("\x7f".repeat("hello my name is kain".length) + "hello my name is Kain");
});
it("turns a Gboard last-word suggestion into a last-word replacement", () => {
const result = normalizePtyMobileInput("Kain", "hello my name is kain", true);
expect(result.normalized).toBe(true);
expect(result.nextLine).toBe("hello my name is Kain");
expect(result.data).toBe("\x7f".repeat("hello my name is kain".length) + "hello my name is Kain");
});
it("does not normalize ordinary appends when replacement is not active", () => {
const result = normalizePtyMobileInput(
"hello my name is Kain Kain",
"hello my name is kain",
false,
);
expect(result.normalized).toBe(false);
expect(result.nextLine).toBe("hello my name is kainhello my name is Kain Kain");
});
it("does not normalize control input", () => {
const result = normalizePtyMobileInput("\r", "hello", true);
expect(result.normalized).toBe(false);
expect(result.nextLine).toBe("");
expect(result.data).toBe("\r");
});
it("does not collapse legitimate single-letter reduplication", () => {
// "a a" is a plausible thing to type; the >=2-char guard keeps the
// duplicate-final-word collapse from eating it inside the window.
const result = normalizePtyMobileInput("a a", "a", true);
expect(result.normalized).toBe(false);
expect(result.data).toBe("a a");
});
});
describe("updatePtyInputLine", () => {
it("tracks printable text, delete, and submit", () => {
expect(updatePtyInputLine("", "abc")).toBe("abc");
expect(updatePtyInputLine("abc", "\x7f")).toBe("ab");
expect(updatePtyInputLine("abc", "\r")).toBe("");
});
it("resets tracking on escape sequences instead of appending their payload", () => {
// Left-arrow arrives as one CSI chunk; the tracker cannot model cursor
// moves, so it must disarm rather than record "hello[D".
expect(updatePtyInputLine("hello", "\x1b[D")).toBe("");
expect(updatePtyInputLine("hello", "\x1b[H")).toBe("");
expect(updatePtyInputLine("hello", "\x1bOP")).toBe("");
});
});
describe("normalizePtyMobileInput after cursor movement", () => {
it("does not emit a replacement against a tracker reset by arrow keys", () => {
// Simulate: type "hello my name is kain", press left-arrow, then a
// Gboard suggestion arrives. The tracker reset means no replacement
// heuristic can fire against a stale line snapshot.
const afterArrow = updatePtyInputLine("hello my name is kain", "\x1b[D");
const result = normalizePtyMobileInput("Kain", afterArrow, true);
expect(result.normalized).toBe(false);
expect(result.data).toBe("Kain");
});
});
+136
View File
@@ -0,0 +1,136 @@
const DELETE = "\x7f";
// How long (ms) after a mobile IME / replacement event we treat subsequent
// terminal input as a candidate line-replacement rather than a plain append.
// Exported so the ChatPage integration and tests share one tunable value.
export const MOBILE_REPLACEMENT_WINDOW_MS = 350;
function chars(text: string): string[] {
return Array.from(text);
}
function removeLastChar(text: string): string {
const c = chars(text);
c.pop();
return c.join("");
}
function isPlainText(data: string): boolean {
// eslint-disable-next-line no-control-regex -- terminal data may contain control chars
return !/[\x00-\x1f\x7f]/.test(data);
}
function lastWordMatch(line: string): RegExpMatchArray | null {
return line.match(/^(.*?)(\S+)(\s*)$/u);
}
function collapseDuplicatedFinalWord(text: string, previousLine: string): string {
const match = text.match(/^(.*?)(\S+)(\s+)(\S+)(\s*)$/u);
if (!match) return text;
const [, prefix, first, , second, trailing] = match;
if (first.toLocaleLowerCase() !== second.toLocaleLowerCase()) return text;
// Only collapse a duplication the tracked line already ended with — i.e.
// Gboard re-emitted the final word. Requiring a >=2-char word avoids
// eating legitimate single-letter reduplication ("a a", "i i") that a
// user may genuinely type inside the replacement window.
if (first.length < 2) return text;
if (!previousLine.trimEnd().toLocaleLowerCase().endsWith(first.toLocaleLowerCase())) {
return text;
}
return `${prefix}${first}${trailing}`;
}
function replacementLineForMobileInput(
currentLine: string,
incoming: string,
): string | null {
if (!currentLine || currentLine.length < 2 || !incoming) return null;
const currentLower = currentLine.toLocaleLowerCase();
const incomingLower = incoming.toLocaleLowerCase();
if (incomingLower.startsWith(currentLower)) {
return collapseDuplicatedFinalWord(incoming, currentLine);
}
const word = lastWordMatch(currentLine);
if (!word) return null;
const [, prefix, last, trailing] = word;
if (trailing) return null;
const incomingFirst = incoming.trimStart().split(/\s+/u)[0] ?? "";
if (
incomingFirst &&
incomingFirst.toLocaleLowerCase() === last.toLocaleLowerCase()
) {
return `${prefix}${collapseDuplicatedFinalWord(incoming, currentLine)}`;
}
return null;
}
export function shouldTreatInputAsMobileReplacement(
inputType: string | undefined,
data: string | null | undefined,
isMobileLike: boolean,
): boolean {
if (
inputType === "insertReplacementText" ||
inputType === "insertFromComposition" ||
inputType === "insertCompositionText"
) {
return true;
}
return isMobileLike && inputType === "insertText" && (data?.length ?? 0) > 1;
}
export function updatePtyInputLine(currentLine: string, data: string): string {
// Escape sequences (arrow keys, home/end, function keys, paste guards)
// move the cursor or edit the line in ways this flat tracker cannot
// model — and the per-char loop below would append their printable
// payload (e.g. the "[D" of a left-arrow) as if it were typed text.
// Reset instead: an unknown cursor position must disarm replacement
// normalization until the user starts a fresh, cleanly-tracked line.
if (data.includes("\x1b")) {
return "";
}
let next = currentLine;
for (const ch of chars(data)) {
if (ch === "\r" || ch === "\n") {
next = "";
} else if (ch === DELETE || ch === "\b") {
next = removeLastChar(next);
} else if (ch === "\x15") {
next = "";
} else if (isPlainText(ch)) {
next += ch;
}
}
return next;
}
export function normalizePtyMobileInput(
data: string,
currentLine: string,
replacementActive: boolean,
): { data: string; nextLine: string; normalized: boolean } {
if (replacementActive && isPlainText(data)) {
const replacementLine = replacementLineForMobileInput(currentLine, data);
if (replacementLine !== null) {
return {
data: DELETE.repeat(chars(currentLine).length) + replacementLine,
nextLine: replacementLine,
normalized: true,
};
}
}
return {
data,
nextLine: updatePtyInputLine(currentLine, data),
normalized: false,
};
}
+123
View File
@@ -0,0 +1,123 @@
import { describe, expect, it } from "vitest";
import {
shouldBlockPtyInput,
shouldReconnectPtyOnPageResume,
} from "./pty-reconnect";
describe("shouldReconnectPtyOnPageResume", () => {
it("reconnects a missing socket when the active page becomes visible", () => {
expect(
shouldReconnectPtyOnPageResume({
isActive: true,
visibilityState: "visible",
online: true,
socketReadyState: null,
ptyState: "reconnecting",
}),
).toBe(true);
});
it("reconnects closed or closing sockets on visible resume", () => {
for (const socketReadyState of [2, 3]) {
expect(
shouldReconnectPtyOnPageResume({
isActive: true,
visibilityState: "visible",
online: true,
socketReadyState,
ptyState: "reconnecting",
}),
).toBe(true);
}
});
it("does not reconnect an open socket on visible resume", () => {
expect(
shouldReconnectPtyOnPageResume({
isActive: true,
visibilityState: "visible",
online: true,
socketReadyState: 1,
ptyState: "open",
}),
).toBe(false);
});
it("reconnects a still-connecting socket when the page is already in reconnecting state", () => {
expect(
shouldReconnectPtyOnPageResume({
isActive: true,
visibilityState: "visible",
online: true,
socketReadyState: 0,
ptyState: "reconnecting",
}),
).toBe(true);
});
it("does not reconnect while the page is hidden", () => {
expect(
shouldReconnectPtyOnPageResume({
isActive: true,
visibilityState: "hidden",
online: true,
socketReadyState: 3,
ptyState: "reconnecting",
}),
).toBe(false);
});
it("defers reconnect while offline", () => {
expect(
shouldReconnectPtyOnPageResume({
isActive: true,
visibilityState: "visible",
online: false,
socketReadyState: 3,
ptyState: "reconnecting",
}),
).toBe(false);
});
it("does not fire a redundant reconnect while a connect is in flight (wsRef not yet assigned)", () => {
// The async socket-open IIFE has begun but not yet assigned wsRef, so
// socketReadyState reads null. Without the connectInFlight guard this
// would return true and double-connect.
expect(
shouldReconnectPtyOnPageResume({
isActive: true,
visibilityState: "visible",
online: true,
socketReadyState: null,
ptyState: "connecting",
connectInFlight: true,
}),
).toBe(false);
});
it("still reconnects an in-flight connect when the page already believes it is closed", () => {
// A stuck attempt the user is actively trying to recover (manual reconnect
// or a closed state) must not be suppressed by the in-flight guard.
expect(
shouldReconnectPtyOnPageResume({
isActive: true,
visibilityState: "visible",
online: true,
socketReadyState: null,
ptyState: "closed",
connectInFlight: true,
}),
).toBe(true);
});
});
describe("shouldBlockPtyInput", () => {
it("allows input only while the PTY socket is open", () => {
expect(shouldBlockPtyInput("open")).toBe(false);
expect(shouldBlockPtyInput("connecting")).toBe(true);
expect(shouldBlockPtyInput("reconnecting")).toBe(true);
expect(shouldBlockPtyInput("closed")).toBe(true);
expect(shouldBlockPtyInput("ended")).toBe(true);
});
});
+93
View File
@@ -0,0 +1,93 @@
export type PtyConnectionState =
| "connecting"
| "open"
| "reconnecting"
| "closed"
| "ended";
export const PTY_RECONNECT_INPUT_MESSAGE =
"Chat is reconnecting. Input will resume when connected.";
// Minimum gap (ms) between page-resume-triggered reconnect attempts, so a
// burst of visibilitychange/pageshow/focus/online events on tab-return
// collapses into a single reconnect.
export const PTY_RESUME_RECONNECT_THROTTLE_MS = 1000;
// If a socket sits in WS_CONNECTING past this budget it is treated as wedged
// (e.g. a half-open mobile socket after a radio handoff — the NS-591 case)
// and force-closed so `onclose` → scheduleReconnect can recover it.
export const PTY_CONNECTING_TIMEOUT_MS = 8000;
// The same budget for the phase *before* the socket exists: in gated mode a
// connect first awaits a fresh single-use ticket. That request produces no
// WebSocket, so a rejection or a hang is invisible to both `onclose` and the
// CONNECTING timer above — the tab would sit on "connecting" forever with no
// retry. Bound it so the failure routes into the ordinary backoff instead.
export const PTY_TICKET_TIMEOUT_MS = 8000;
// How long after a resumed socket opens we keep suppressing ANSI erase codes
// (`ESC[K` / `ESC[X`) from the PTY stream. Ink's two-pass virtual scroll emits
// them while replaying a long session; past that replay they are legitimate
// in-place redraws (spinners, progress bars, status lines) and must reach
// xterm or stale glyphs are left on screen. The replay of a 200+ message
// session takes ~10-20s, so this is deliberately generous — over-running the
// window only costs a few stale cells on a buffer that is about to be
// repainted, while under-running it re-opens the blank-viewport bug.
export const PTY_RESUME_SANITIZE_WINDOW_MS = 30000;
export interface PtyResumeReconnectInput {
isActive: boolean;
visibilityState?: DocumentVisibilityState;
online: boolean;
socketReadyState?: number | null;
ptyState: PtyConnectionState;
connectInFlight?: boolean;
}
const WS_CONNECTING = 0;
const WS_OPEN = 1;
const WS_CLOSING = 2;
const WS_CLOSED = 3;
export function shouldReconnectPtyOnPageResume({
isActive,
visibilityState,
online,
socketReadyState,
ptyState,
connectInFlight,
}: PtyResumeReconnectInput): boolean {
if (!isActive || !online || visibilityState === "hidden") {
return false;
}
if (ptyState === "ended") {
return false;
}
if (socketReadyState === WS_OPEN) {
return false;
}
// A connect is mid-flight (the async socket-open IIFE is awaiting its
// ticket URL and hasn't assigned wsRef yet, or the socket is still
// CONNECTING on a non-stuck attempt). Don't fire a redundant reconnect
// into that window unless the tab already believes it is reconnecting or
// closed and needs a fresh attempt.
if (
(connectInFlight || socketReadyState === WS_CONNECTING) &&
ptyState !== "reconnecting" &&
ptyState !== "closed"
) {
return false;
}
return (
socketReadyState === null ||
socketReadyState === undefined ||
socketReadyState === WS_CLOSING ||
socketReadyState === WS_CLOSED ||
ptyState === "reconnecting" ||
ptyState === "closed"
);
}
export function shouldBlockPtyInput(ptyState: PtyConnectionState): boolean {
return ptyState !== "open";
}
+104
View File
@@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import { PtyResumeSanitizer } from "./pty-resume-sanitizer";
import {
PTY_RESUME_LOADING_MAX_MS,
shouldFinishResumeHydrationOnChunk,
shouldShowResumeLoadingOverlay,
} from "./pty-resume-loading";
describe("shouldFinishResumeHydrationOnChunk", () => {
it("finishes on the first non-empty chunk", () => {
expect(shouldFinishResumeHydrationOnChunk("")).toBe(false);
expect(shouldFinishResumeHydrationOnChunk("hello")).toBe(true);
});
it("keeps a positive hard-cap timeout for wedged resumes", () => {
expect(PTY_RESUME_LOADING_MAX_MS).toBeGreaterThan(0);
});
});
describe("resume hydration gate over the real sanitizer", () => {
// Regression: the gate must key off the payload actually written to xterm,
// not the raw frame. The sanitizer collapses an erase-only / all-newline /
// partial-CSI resume frame to "", so a nonempty raw first frame would
// otherwise clear the wait notice while the terminal is still blank.
const ESC = String.fromCharCode(27);
const CRLF = String.fromCharCode(13, 10);
const VISIBLE = `Hello world${CRLF}`;
const controlOnlyFirstFrames: Array<[string, string]> = [
["erase-only", `${ESC}[2K`],
["all-newline", `${CRLF}${CRLF}`],
["partial-CSI", `${ESC}[`],
];
it.each(controlOnlyFirstFrames)(
"keeps hydrating on a %s first frame, then finishes on visible replay",
(_label, firstFrame) => {
const sanitizer = new PtyResumeSanitizer();
// Raw first frame is nonempty, but nothing is written to xterm...
expect(firstFrame.length).toBeGreaterThan(0);
const firstRendered = sanitizer.next(firstFrame);
expect(firstRendered).toBe("");
expect(shouldFinishResumeHydrationOnChunk(firstRendered)).toBe(false);
// ...so the notice only clears once real replay output arrives.
const secondRendered = sanitizer.next(VISIBLE);
expect(secondRendered.length).toBeGreaterThan(0);
expect(shouldFinishResumeHydrationOnChunk(secondRendered)).toBe(true);
},
);
});
describe("shouldShowResumeLoadingOverlay", () => {
it("shows while a resume target is connecting or open and still hydrating", () => {
expect(
shouldShowResumeLoadingOverlay({
hasResumeTarget: true,
ptyState: "connecting",
hydrating: true,
}),
).toBe(true);
expect(
shouldShowResumeLoadingOverlay({
hasResumeTarget: true,
ptyState: "open",
hydrating: true,
}),
).toBe(true);
});
it("hides when there is no resume target", () => {
expect(
shouldShowResumeLoadingOverlay({
hasResumeTarget: false,
ptyState: "connecting",
hydrating: true,
}),
).toBe(false);
});
it("hides once hydration finishes", () => {
expect(
shouldShowResumeLoadingOverlay({
hasResumeTarget: true,
ptyState: "open",
hydrating: false,
}),
).toBe(false);
});
it("defers to reconnect / closed / ended overlays", () => {
for (const ptyState of ["reconnecting", "closed", "ended"] as const) {
expect(
shouldShowResumeLoadingOverlay({
hasResumeTarget: true,
ptyState,
hydrating: true,
}),
).toBe(false);
}
});
});
+47
View File
@@ -0,0 +1,47 @@
import type { PtyConnectionState } from "@/lib/pty-reconnect";
/**
* Hard cap so a wedged resume (never gets PTY payload) cannot leave the
* wait notice up forever.
*/
export const PTY_RESUME_LOADING_MAX_MS = 30000;
export const PTY_RESUME_LOADING_MESSAGE =
"Please wait while the conversation loads…";
export interface ResumeLoadingOverlayInput {
hasResumeTarget: boolean;
ptyState: PtyConnectionState;
hydrating: boolean;
}
/**
* Show a wait notice only while a resumed chat is still blank. Once the
* first real PTY payload arrives the terminal has something to show, so
* the notice hides and history can stream in underneath.
*
* Reconnect / ended / closed states keep their own overlays and must not
* stack this one on top.
*/
export function shouldShowResumeLoadingOverlay({
hasResumeTarget,
ptyState,
hydrating,
}: ResumeLoadingOverlayInput): boolean {
if (!hasResumeTarget || !hydrating) {
return false;
}
if (
ptyState === "reconnecting" ||
ptyState === "closed" ||
ptyState === "ended"
) {
return false;
}
return ptyState === "connecting" || ptyState === "open";
}
/** First non-empty PTY chunk means the blank window is over. */
export function shouldFinishResumeHydrationOnChunk(chunkText: string): boolean {
return chunkText.length > 0;
}
+218
View File
@@ -0,0 +1,218 @@
import { describe, expect, it } from "vitest";
import { applyPtyFilters, PtyResumeSanitizer } from "./pty-resume-sanitizer";
/**
* Fixture note — why most cases use CRLF, not LF.
*
* `hermes_cli/pty_bridge.py` spawns the agent through
* `ptyprocess.PtyProcess.spawn()` and never puts the PTY into raw mode, so the
* line discipline runs with ONLCR: every LF the child writes reaches the
* master (and therefore xterm) as CRLF. Verified against a real PTY:
*
* child writes b"A" + b"\n"*5 + b"B" -> master reads b"A\r\n\r\n\r\n\r\n\r\nB"
*
* Tests that assert burst collapsing therefore use `\r\n`; an LF-only fixture
* would pass while the production path did nothing.
*/
const CRLF = "\r\n";
/** Split a string into fixed-size frames, mimicking chunked `os.read` output. */
function frames(s: string, size: number): string[] {
const out: string[] = [];
for (let i = 0; i < s.length; i += size) out.push(s.slice(i, i + size));
return out;
}
/** Feed every frame through a sanitizer and concatenate output + flush. */
function drain(sanitizer: PtyResumeSanitizer, chunks: string[]): string {
return chunks.map((c) => sanitizer.next(c)).join("") + sanitizer.flush();
}
describe("applyPtyFilters", () => {
it("passes through normal text unchanged", () => {
const input = "hello world\r\nfoo bar\r\n";
expect(applyPtyFilters(input)).toBe(input);
});
it("collapses pathological CRLF bursts (real PTY output)", () => {
const burst = "a" + CRLF.repeat(1000) + "b";
expect(applyPtyFilters(burst)).toBe("a\r\n\r\nb");
});
it("collapses pathological LF-only bursts (raw-mode PTY)", () => {
const burst = "a" + "\n".repeat(100) + "b";
expect(applyPtyFilters(burst)).toBe("a\r\n\r\nb");
});
it("leaves short blank-line runs untouched", () => {
const short = `a${CRLF}${CRLF}${CRLF}b`;
expect(applyPtyFilters(short)).toBe(short);
});
it("strips erase-line CSI sequences", () => {
expect(applyPtyFilters("hello\x1b[K world")).toBe("hello world");
expect(applyPtyFilters("foo\x1b[2Kbar")).toBe("foobar");
expect(applyPtyFilters("\x1b[K")).toBe("");
});
it("strips erase-char CSI sequences", () => {
expect(applyPtyFilters("abc\x1b[3Xdef")).toBe("abcdef");
expect(applyPtyFilters("\x1b[X")).toBe("");
});
it("leaves normal SGR sequences untouched", () => {
const input = "\x1b[31mred text\x1b[0m\r\n";
expect(applyPtyFilters(input)).toBe(input);
});
it("handles empty string", () => {
expect(applyPtyFilters("")).toBe("");
});
it("preserves erase codes when erase-stripping is disabled", () => {
// Post-resume in-place redraw must keep ESC[K or stale glyphs remain.
const spinner = "Loading 10%\r\x1b[KLoading 20%\r\x1b[KDone";
expect(applyPtyFilters(spinner, false)).toBe(spinner);
});
it("still collapses bursts when erase-stripping is disabled", () => {
const burst = "a" + CRLF.repeat(200) + "b";
expect(applyPtyFilters(burst, false)).toBe("a\r\n\r\nb");
});
});
describe("PtyResumeSanitizer — stateful frame handling", () => {
it("processes a complete chunk in one frame", () => {
const s = new PtyResumeSanitizer();
// The trailing newline run is held back in case it continues next frame.
expect(s.next("hello\r\n")).toBe("hello");
expect(s.flush()).toBe("\r\n");
});
it("buffers a trailing partial escape and resolves in next frame", () => {
const s = new PtyResumeSanitizer();
expect(s.next("hello\x1b[")).toBe("hello");
expect(s.next("2K world\r\n")).toBe(" world");
expect(s.flush()).toBe("\r\n");
});
it("buffers bare \\x1b and resolves on completion", () => {
const s = new PtyResumeSanitizer();
expect(s.next("before\x1b")).toBe("before");
expect(s.next("[Kafter")).toBe("after");
});
it("buffers \\x1b[\\d+ prefix and resolves", () => {
const s = new PtyResumeSanitizer();
expect(s.next("x\x1b[4")).toBe("x");
expect(s.next("2Ky")).toBe("y");
});
it("passes through when no partial escape is buffered", () => {
const s = new PtyResumeSanitizer();
expect(s.next("chunk1 ")).toBe("chunk1 ");
expect(s.next("chunk2 ")).toBe("chunk2 ");
});
it("drops a buffered partial escape on flush", () => {
// An unterminated CSI must never reach xterm: it would leave the parser
// in an "in-escape" state and swallow output after reconnect.
const s = new PtyResumeSanitizer();
s.next("trailing\x1b[");
expect(s.flush()).toBe("");
});
it("resets state across instances", () => {
const a = new PtyResumeSanitizer();
a.next("\x1b[");
const b = new PtyResumeSanitizer();
// "[K" without an ESC prefix is literal text, not a CSI code.
expect(b.next("[Khello")).toBe("[Khello");
expect(a.flush()).toBe("");
});
it("handles empty chunk without disturbing pending buffer", () => {
const s = new PtyResumeSanitizer();
s.next("before\x1b[");
expect(s.next("")).toBe("");
expect(s.next("2Kafter")).toBe("after");
});
it("handles consecutive split escapes across three frames", () => {
const s = new PtyResumeSanitizer();
expect(s.next("a\x1b")).toBe("a");
expect(s.next("[")).toBe("");
expect(s.next("2Kb")).toBe("b");
});
});
describe("PtyResumeSanitizer — cross-frame blank-line bursts", () => {
it("collapses a burst contained in a single frame", () => {
const s = new PtyResumeSanitizer();
expect(drain(s, ["start" + CRLF.repeat(60) + "end"])).toBe(
"start\r\n\r\nend",
);
});
it("collapses a burst split across 64KiB-scale frames", () => {
// bridge.read() does os.read(fd, 65536); a 3000-row burst spans frames.
const burst = "START" + CRLF.repeat(3000) + "END";
const s = new PtyResumeSanitizer();
const out = drain(s, frames(burst, 4096));
expect(out).toBe("START\r\n\r\nEND");
});
it("collapses a burst delivered as many small partial reads", () => {
const burst = "START" + CRLF.repeat(3000) + "END";
const s = new PtyResumeSanitizer();
const out = drain(s, frames(burst, 40));
expect(out).toBe("START\r\n\r\nEND");
});
it("collapses sub-threshold fragments that sum past the threshold", () => {
// 49 + 49 rows individually duck the 50 threshold but total 98 blank rows.
const s = new PtyResumeSanitizer();
const out = drain(s, [CRLF.repeat(49), CRLF.repeat(49)]);
expect(out).toBe("\r\n\r\n");
});
it("does not merge blank runs separated by real content", () => {
const s = new PtyResumeSanitizer();
const out = drain(s, [CRLF.repeat(40), "text", CRLF.repeat(40)]);
expect(out).toBe(`${CRLF.repeat(40)}text${CRLF.repeat(40)}`);
});
it("emits a buffered trailing newline run on flush", () => {
const s = new PtyResumeSanitizer();
expect(s.next("done" + CRLF)).toBe("done");
expect(s.flush()).toBe(CRLF);
});
});
describe("PtyResumeSanitizer — bounded erase suppression", () => {
it("suppresses erase codes while the resume window is open", () => {
const s = new PtyResumeSanitizer();
expect(s.isSuppressingErase).toBe(true);
expect(s.next("a\x1b[Kb")).toBe("ab");
});
it("stops suppressing erase codes once the window closes", () => {
const s = new PtyResumeSanitizer();
s.endEraseSuppression();
expect(s.isSuppressingErase).toBe(false);
expect(s.next("a\x1b[Kb")).toBe("a\x1b[Kb");
});
it("keeps collapsing bursts after the window closes", () => {
const s = new PtyResumeSanitizer();
s.endEraseSuppression();
expect(drain(s, ["x" + CRLF.repeat(80) + "y"])).toBe("x\r\n\r\ny");
});
it("preserves a post-resume spinner redraw verbatim", () => {
const s = new PtyResumeSanitizer();
s.endEraseSuppression();
const spinner = "Loading 10%\r\x1b[KLoading 20%\r\x1b[KDone";
expect(drain(s, [spinner])).toBe(spinner);
});
});
+128
View File
@@ -0,0 +1,128 @@
/**
* PTY resume output sanitizer — strips pathological ANSI sequences that
* Ink's two-pass virtual scroll emits during session resume.
*
* Three properties of the real pipeline drive this design:
*
* 1. **The PTY is in cooked mode (ONLCR).** `hermes_cli/pty_bridge.py` spawns
* via `ptyprocess.PtyProcess.spawn()` and never calls `setraw()`, so the
* line discipline rewrites every LF the child writes as CRLF. A burst
* therefore arrives as `\r\n\r\n\r\n…`, never as bare `\n\n\n…`, and any
* filter matching `\n{50,}` would never fire in production.
*
* 2. **Reads are chunked, not message-framed.** `bridge.read()` does
* `os.read(fd, 65536)` per drain tick and `pty_session.py` forwards each
* read as its own binary WebSocket frame. A CSI escape, a UTF-8 code
* point, *and* a long blank-line run can each straddle a frame boundary,
* so all three need trailing-state buffering to survive reassembly.
*
* 3. **Erase codes are only pathological during the resume replay.** Once the
* replay has settled, `ESC[K` / `ESC[X` are exactly how a TUI clears stale
* glyphs for spinners, progress bars, and status lines. Stripping them
* forever corrupts normal interactive output, so suppression is bounded to
* a short window after connect (see PTY_RESUME_SANITIZE_WINDOW_MS).
*/
/** A blank-line run: CRLF (real PTY, cooked mode) or bare LF (raw-mode PTY). */
const BLANK_LINE_BURST = /(?:\r?\n){50,}/g;
// eslint-disable-next-line no-control-regex -- intentional ESC byte in ANSI sequence parser
const ERASE_LINE = /\x1b\[\d*K/g;
// eslint-disable-next-line no-control-regex -- intentional ESC byte in ANSI sequence parser
const ERASE_CHAR = /\x1b\[\d*X/g;
/** Still-incomplete trailing escape: "\x1b", "\x1b[", "\x1b[\d*". */
// eslint-disable-next-line no-control-regex -- intentional ESC byte in ANSI sequence parser
const PARTIAL_ESC = /^\x1b(?:\[\d*)?$/;
/**
* A trailing run of newlines that may continue into the next frame. Held back
* so a burst split across frames still meets the collapse threshold instead of
* slipping through as sub-threshold fragments. A lone trailing `\r` is
* included: a frame boundary can fall between the CR and LF of a CRLF pair,
* and emitting the CR early would break one run into two shorter ones.
*
* Always matches (possibly empty, at end of input).
*/
const TRAILING_NEWLINES = /(?:\r?\n)*\r?$/;
/** Collapsed form of a pathological burst: one blank row, CRLF for xterm. */
const COLLAPSED_BURST = "\r\n\r\n";
/**
* Apply all suppression rules to a safely-completed string.
*
* @param stripErase When false, `ESC[K` / `ESC[X` are preserved. Blank-line
* burst collapsing still applies — a thousand-row burst is pathological
* whenever it appears, but erase codes are legitimate once resume settles.
* Exported for focused unit-testing of filter behaviour.
*/
export function applyPtyFilters(input: string, stripErase = true): string {
const collapsed = input.replace(BLANK_LINE_BURST, COLLAPSED_BURST);
if (!stripErase) return collapsed;
return collapsed.replace(ERASE_LINE, "").replace(ERASE_CHAR, "");
}
/** Stateful chunk processor that guards against cross-frame split sequences. */
export class PtyResumeSanitizer {
#pending = "";
#stripErase = true;
/**
* Stop stripping erase codes while continuing to collapse blank-line bursts.
* Called when the resume-replay window closes so that ordinary interactive
* redraws (spinners, progress bars, status lines) keep their `ESC[K`.
*/
endEraseSuppression(): void {
this.#stripErase = false;
}
/** True while erase-code stripping is still active. */
get isSuppressingErase(): boolean {
return this.#stripErase;
}
/** Feed one decoded WebSocket frame payload. Returns the sanitized output. */
next(chunk: string): string {
const combined = this.#pending + chunk;
if (combined === "") {
this.#pending = "";
return "";
}
// Hold back a trailing partial escape so a CSI split across frames is
// still recognised once its terminating byte arrives.
const lastEsc = combined.lastIndexOf("\x1b");
if (lastEsc !== -1 && PARTIAL_ESC.test(combined.slice(lastEsc))) {
this.#pending = combined.slice(lastEsc);
return applyPtyFilters(combined.slice(0, lastEsc), this.#stripErase);
}
// Hold back a trailing newline run so a burst spanning frames accumulates
// to the collapse threshold instead of leaking through in fragments.
// TRAILING_NEWLINES always matches; an empty match at end of input means
// the frame does not end mid-run and nothing needs to be held back.
const trailing = TRAILING_NEWLINES.exec(combined) as RegExpExecArray;
if (trailing.index === 0) {
// The whole frame is newlines — keep accumulating, emit nothing yet.
this.#pending = combined;
return "";
}
this.#pending = trailing[0];
return applyPtyFilters(combined.slice(0, trailing.index), this.#stripErase);
}
/**
* Drain buffered state at end of stream.
*
* A buffered *partial escape* is dropped rather than written: `#pending` only
* ever holds an incomplete sequence, and emitting one would leave xterm's
* parser in an "in-escape" state that swallows subsequent output after
* reconnect. A buffered *newline run* is safe and is emitted (collapsed).
*/
flush(): string {
const last = this.#pending;
this.#pending = "";
if (last === "" || last.includes("\x1b")) return "";
return applyPtyFilters(last, this.#stripErase);
}
}
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import {
isViewportPinnedToBottom,
parseResumeControlMessage,
shouldFollowPtyOutput,
} from "./pty-scroll";
describe("isViewportPinnedToBottom", () => {
it("is pinned when the viewport sits on the bottom row", () => {
// xterm reports viewportY === baseY when the newest output is on screen.
expect(isViewportPinnedToBottom({ viewportY: 120, baseY: 120 })).toBe(true);
});
it("releases the pin once the user scrolls up into the backlog", () => {
// Scrolling up drops viewportY below baseY — the user is reading history,
// so the resume replay must not yank them back down (#59591 follow-up).
expect(isViewportPinnedToBottom({ viewportY: 40, baseY: 120 })).toBe(false);
});
it("stays pinned if viewportY overshoots baseY while rows are trimmed", () => {
// scrollback eviction can momentarily push viewportY past baseY.
expect(isViewportPinnedToBottom({ viewportY: 121, baseY: 120 })).toBe(true);
});
it("treats a fresh 0x0 buffer as pinned", () => {
expect(isViewportPinnedToBottom({ viewportY: 0, baseY: 0 })).toBe(true);
});
});
describe("shouldFollowPtyOutput", () => {
it("follows replayed output while resuming and stuck to the bottom", () => {
// The core #59591 fix: scroll to bottom as each replay chunk commits.
expect(shouldFollowPtyOutput("sess-123", true)).toBe(true);
});
it("stops following once the user has scrolled up mid-replay", () => {
expect(shouldFollowPtyOutput("sess-123", false)).toBe(false);
});
it("does not follow a fresh (non-resume) session", () => {
// Fresh chats start empty; forcing scroll would fight normal cursor output.
expect(shouldFollowPtyOutput(null, true)).toBe(false);
});
it("does not follow a fresh session even when stickToBottom is true", () => {
expect(shouldFollowPtyOutput(null, false)).toBe(false);
});
it("treats an empty resume param as non-resume", () => {
expect(shouldFollowPtyOutput("", true)).toBe(false);
});
});
describe("parseResumeControlMessage", () => {
it("extracts the id from a resume control frame", () => {
// #93518: the implicit active-session fallback has no `?resume=` on the
// URL, so the server names the session it resolved in a control frame.
expect(
parseResumeControlMessage('{"type":"resume","id":"sess-123"}'),
).toBe("sess-123");
});
it("ignores plain ANSI banner text sent as a text frame", () => {
// pty_ws also sends "Chat unavailable: ..." error banners as text
// frames; those must keep rendering into the terminal, not get
// swallowed as a (mis-parsed) control message.
expect(
parseResumeControlMessage("\r\n\x1b[31mChat unavailable: x\x1b[0m\r\n"),
).toBeNull();
});
it("ignores JSON of the wrong shape", () => {
expect(parseResumeControlMessage('{"type":"other","id":"x"}')).toBeNull();
expect(parseResumeControlMessage('{"type":"resume"}')).toBeNull();
expect(parseResumeControlMessage('{"type":"resume","id":""}')).toBeNull();
expect(parseResumeControlMessage("null")).toBeNull();
expect(parseResumeControlMessage('"resume"')).toBeNull();
});
});
+78
View File
@@ -0,0 +1,78 @@
/**
* Dashboard chat resume-scroll helpers.
*
* When a chat session is resumed (`/chat?resume=<id>`) the PTY backend replays
* the entire scrollback over the WebSocket the instant it opens. xterm.js writes
* those bytes into its buffer but leaves the viewport wherever it was (e.g. the
* top of a fresh terminal), so the transcript looks truncated until something
* else forces a re-render. See #59591.
*
* The fix pins the viewport to the bottom *as each replayed chunk commits* (via
* xterm's `write` callback) instead of guessing with a fixed double-rAF delay,
* and releases that pin the moment the user scrolls up so their manual review of
* the backlog is never yanked back down.
*
* These two decisions are pulled out here as pure functions so they can be
* unit-tested without a live terminal; `ChatPage` wires them to the real xterm
* instance (see `term.onScroll` and `ws.onmessage`).
*/
/** The subset of xterm's active `IBuffer` these helpers need. */
export interface TerminalViewportPosition {
/** Row index of the top of the current viewport. */
viewportY: number;
/** Row index of the viewport top when scrolled fully to the bottom. */
baseY: number;
}
/**
* True when the viewport is scrolled to (or past) the bottom, i.e. the latest
* output is on screen. xterm reports `viewportY === baseY` at the bottom; the
* `>=` also covers the transient overshoot while scrollback rows are trimmed.
*/
export function isViewportPinnedToBottom(
buffer: TerminalViewportPosition,
): boolean {
return buffer.viewportY >= buffer.baseY;
}
/**
* Whether a freshly written PTY output chunk should scroll the terminal to the
* bottom afterwards. We only auto-follow while resuming a session (the replay
* case) and only while the user hasn't scrolled up to read the backlog. A fresh
* (non-resume) session returns `false` so normal cursor output is never fought.
*/
export function shouldFollowPtyOutput(
resumeParam: string | null,
stickToBottom: boolean,
): boolean {
return Boolean(resumeParam) && stickToBottom;
}
/**
* When `pty_ws` falls back to the per-channel active-session file (no
* `?resume=` on the URL), the server sends a one-off JSON text frame naming
* the session it resolved before any PTY replay bytes arrive, since the
* frontend has no other way to learn a replay is happening (#93518). PTY
* output itself always arrives as binary frames, so any text frame is a
* candidate; this returns the resume id on a match and `null` for anything
* else (including the plain ANSI error banners `pty_ws` still sends as text
* on failure, which must keep rendering into the terminal as before).
*/
export function parseResumeControlMessage(data: string): string | null {
try {
const parsed = JSON.parse(data);
if (
parsed &&
typeof parsed === "object" &&
parsed.type === "resume" &&
typeof parsed.id === "string" &&
parsed.id
) {
return parsed.id;
}
} catch {
/* not JSON — an ANSI banner or other plain-text frame */
}
return null;
}
+47
View File
@@ -0,0 +1,47 @@
import { describe, it, expect } from "vitest";
import {
EFFORT_OPTIONS,
VALID_EFFORTS,
normalizeEffort,
} from "./reasoning-effort";
describe("normalizeEffort", () => {
it("treats empty/unset as the Hermes default (medium)", () => {
expect(normalizeEffort("")).toBe("medium");
expect(normalizeEffort(null)).toBe("medium");
expect(normalizeEffort(undefined)).toBe("medium");
expect(normalizeEffort(" ")).toBe("medium");
});
it("passes through every valid effort level", () => {
for (const level of ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]) {
expect(normalizeEffort(level)).toBe(level);
}
});
it("is case- and whitespace-insensitive", () => {
expect(normalizeEffort("HIGH")).toBe("high");
expect(normalizeEffort(" XHigh ")).toBe("xhigh");
});
it("falls back to medium for unknown values", () => {
expect(normalizeEffort("turbo")).toBe("medium");
expect(normalizeEffort(42)).toBe("medium");
});
});
describe("EFFORT_OPTIONS", () => {
it("every option value is in VALID_EFFORTS (no orphan labels)", () => {
for (const opt of EFFORT_OPTIONS) {
expect(VALID_EFFORTS.has(opt.value)).toBe(true);
}
});
it("covers the real reasoning levels plus thinking-off", () => {
// Invariant against hermes_constants.VALID_REASONING_EFFORTS + 'none'.
const values = new Set(EFFORT_OPTIONS.map((o) => o.value));
for (const level of ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]) {
expect(values.has(level)).toBe(true);
}
});
});
+38
View File
@@ -0,0 +1,38 @@
/**
* Pure reasoning-effort helpers shared by the dashboard ReasoningPicker.
*
* Kept DOM-free so the node-environment vitest harness can cover the
* resolution logic without loading React or the UI kit.
*
* Values mirror hermes_constants.VALID_REASONING_EFFORTS plus `none`
* (thinking-off). An empty/unset config value means the Hermes default,
* which is `medium`.
*/
export interface EffortOption {
value: string;
label: string;
}
export const EFFORT_OPTIONS: ReadonlyArray<EffortOption> = [
{ value: "none", label: "Off (no thinking)" },
{ value: "minimal", label: "Minimal" },
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
{ value: "xhigh", label: "Extra High" },
{ value: "max", label: "Max" },
{ value: "ultra", label: "Ultra" },
];
export const VALID_EFFORTS: ReadonlySet<string> = new Set(
EFFORT_OPTIONS.map((o) => o.value),
);
/** Normalize a raw `agent.reasoning_effort` config value to a selectable
* option. Empty/unknown → `medium` (Hermes' default when unset). */
export function normalizeEffort(raw: unknown): string {
const value = String(raw ?? "").trim().toLowerCase();
if (!value) return "medium";
return VALID_EFFORTS.has(value) ? value : "medium";
}
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { resolvePageTitle } from "./resolve-page-title";
import type { Translations } from "@/i18n/types";
// Minimal translations stub — only the fields resolvePageTitle touches.
const t = {
app: {
webUi: "Web UI",
nav: {
analytics: "Analytics",
chat: "Chat",
config: "Config",
cron: "Cron",
documentation: "Documentation",
keys: "Keys",
logs: "Logs",
models: "Models",
profiles: "Profiles",
plugins: "Plugins",
sessions: "Sessions",
skills: "Skills",
},
},
} as unknown as Translations;
describe("resolvePageTitle", () => {
it("uses i18n nav keys for translated routes", () => {
expect(resolvePageTitle("/sessions", t, [])).toBe("Sessions");
expect(resolvePageTitle("/env", t, [])).toBe("Keys");
});
it("renders initialisms and literal labels correctly", () => {
// Regression: the naive capitalize fallback produced "Mcp".
expect(resolvePageTitle("/mcp", t, [])).toBe("MCP");
expect(resolvePageTitle("/system", t, [])).toBe("System");
expect(resolvePageTitle("/channels", t, [])).toBe("Channels");
expect(resolvePageTitle("/webhooks", t, [])).toBe("Webhooks");
expect(resolvePageTitle("/pairing", t, [])).toBe("Pairing");
expect(resolvePageTitle("/files", t, [])).toBe("Files");
});
it("prefers plugin tab labels", () => {
expect(
resolvePageTitle("/kanban", t, [{ path: "/kanban", label: "Kanban" }]),
).toBe("Kanban");
});
it("falls back to capitalized path segment for unknown routes", () => {
expect(resolvePageTitle("/whatever", t, [])).toBe("Whatever");
});
it("treats root as sessions and trailing slashes as equivalent", () => {
expect(resolvePageTitle("/", t, [])).toBe("Sessions");
expect(resolvePageTitle("/mcp/", t, [])).toBe("MCP");
});
});
+57
View File
@@ -0,0 +1,57 @@
import type { Translations } from "@/i18n/types";
const BUILTIN: Record<string, keyof Translations["app"]["nav"]> = {
"/chat": "chat",
"/sessions": "sessions",
"/analytics": "analytics",
"/models": "models",
"/logs": "logs",
"/cron": "cron",
"/skills": "skills",
"/plugins": "plugins",
"/profiles": "profiles",
"/config": "config",
"/env": "keys",
"/docs": "documentation",
};
// Built-in routes without an i18n nav key. Keep these in sync with the
// sidebar labels in App.tsx — the naive capitalize fallback below mangles
// initialisms ("/mcp" → "Mcp") and can't match multi-word labels.
const BUILTIN_LITERAL: Record<string, string> = {
"/files": "Files",
"/mcp": "MCP",
"/channels": "Channels",
"/webhooks": "Webhooks",
"/pairing": "Pairing",
"/system": "System",
};
export function resolvePageTitle(
pathname: string,
t: Translations,
pluginTabs: { path: string; label: string }[],
): string {
const normalized = pathname.replace(/\/$/, "") || "/";
if (normalized === "/") {
return t.app.nav.sessions;
}
const plugin = pluginTabs.find((p) => p.path === normalized);
if (plugin) {
return plugin.label;
}
const key = BUILTIN[normalized];
if (key) {
return t.app.nav[key];
}
const literal = BUILTIN_LITERAL[normalized];
if (literal) {
return literal;
}
// Derive title from pathname: "/profiles" → "Profiles"
const segment = normalized.slice(1);
if (segment) {
return segment.charAt(0).toUpperCase() + segment.slice(1);
}
return t.app.webUi;
}
+123
View File
@@ -0,0 +1,123 @@
import { describe, expect, it } from "vitest";
import {
buildScheduleString,
DEFAULT_SCHEDULE_STATE,
parseScheduleString,
} from "./schedule";
describe("parseScheduleString", () => {
it("parses recurring interval strings", () => {
expect(parseScheduleString("every 30m")).toMatchObject({
mode: "interval",
intervalValue: 30,
intervalUnit: "minutes",
});
expect(parseScheduleString("every 2h")).toMatchObject({
mode: "interval",
intervalValue: 2,
intervalUnit: "hours",
});
expect(parseScheduleString("every 1d")).toMatchObject({
mode: "interval",
intervalValue: 1,
intervalUnit: "days",
});
});
it("parses ISO timestamps into once mode", () => {
expect(parseScheduleString("2026-02-03T14:00:00")).toMatchObject({
mode: "once",
onceAt: "2026-02-03T14:00",
});
expect(parseScheduleString("2026-02-03T14:00")).toMatchObject({
mode: "once",
onceAt: "2026-02-03T14:00",
});
});
it("parses daily cron expressions", () => {
expect(parseScheduleString("0 9 * * *")).toMatchObject({
mode: "daily",
timeOfDay: "09:00",
});
});
it("parses weekly cron expressions", () => {
expect(parseScheduleString("30 14 * * 1,3,5")).toMatchObject({
mode: "weekly",
timeOfDay: "14:30",
weekdays: [1, 3, 5],
});
});
it("normalizes cron Sunday 7 into the builder's Sunday 0", () => {
expect(parseScheduleString("30 14 * * 1,7")).toMatchObject({
mode: "weekly",
timeOfDay: "14:30",
weekdays: [1, 0],
});
});
it("parses monthly cron expressions", () => {
expect(parseScheduleString("0 9 15 * *")).toMatchObject({
mode: "monthly",
timeOfDay: "09:00",
dayOfMonth: 15,
});
});
it("falls back to custom for unsupported schedule strings", () => {
expect(parseScheduleString("0 9 * * 1-5")).toMatchObject({
mode: "custom",
custom: "0 9 * * 1-5",
});
expect(parseScheduleString("@daily")).toMatchObject({
mode: "custom",
custom: "@daily",
});
expect(parseScheduleString("2026-02-03T14:00:00Z")).toMatchObject({
mode: "custom",
custom: "2026-02-03T14:00:00Z",
});
expect(parseScheduleString("2026-02-03T14:00:00+08:00")).toMatchObject({
mode: "custom",
custom: "2026-02-03T14:00:00+08:00",
});
expect(parseScheduleString("0 9 * * 1,8")).toMatchObject({
mode: "custom",
custom: "0 9 * * 1,8",
});
expect(parseScheduleString("0 9 1,15 * *")).toMatchObject({
mode: "custom",
custom: "0 9 1,15 * *",
});
});
it("returns the default state for empty input", () => {
expect(parseScheduleString("")).toEqual(DEFAULT_SCHEDULE_STATE);
});
});
describe("buildScheduleString round-trip", () => {
it("rebuilds the schedule string from parsed state", () => {
const cases: [string, string][] = [
["every 30m", "every 30m"],
["every 2h", "every 2h"],
["every 1d", "every 1d"],
["0 9 * * *", "0 9 * * *"],
["30 14 * * 1,3,5", "30 14 * * 1,3,5"],
["30 14 * * 1,7", "30 14 * * 0,1"],
["0 9 15 * *", "0 9 15 * *"],
["0 9 1,15 * *", "0 9 1,15 * *"],
["2026-02-03T14:00:00", "2026-02-03T14:00:00"],
["2026-02-03T14:00", "2026-02-03T14:00:00"],
["2026-02-03T14:00:00Z", "2026-02-03T14:00:00Z"],
["2026-02-03T14:00:00+08:00", "2026-02-03T14:00:00+08:00"],
];
for (const [input, expected] of cases) {
const state = parseScheduleString(input);
expect(buildScheduleString(state)).toBe(expected);
}
});
});
+465
View File
@@ -0,0 +1,465 @@
/**
* Schedule builder helpers for the cron page.
*
* The hermes-agent backend (cron/jobs.py::parse_schedule) accepts a
* surprisingly broad set of string formats:
*
* - Duration (one-shot): "30m", "2h", "1d"
* - Interval (recurring): "every 30m", "every 2h", "every 1d"
* - Cron expression (5-field): "0 9 * * *", "30 14 * * 1,3,5"
* - ISO timestamp (one-shot): "2026-02-03T14:00:00"
*
* Power users can hand-type any of those, but for everyone else the
* dashboard now offers a human-readable picker. This module is the
* pure logic layer behind that picker:
*
* - {@link buildScheduleString} turns the picker's structured state
* into one of the strings above.
* - {@link describeSchedule} goes the other way: takes the structured
* schedule shape the API returns (``CronJob.schedule``) and produces
* a human-readable sentence for the job list. It recognises common
* cron-expression shapes (daily/weekly/monthly) so users don't have
* to parse "30 14 * * 1,3,5" by eye.
*
* Kept dependency-free and locale-string-driven so it tree-shakes
* cleanly and is testable in isolation if we ever wire up vitest here.
*/
/** Picker modes — each renders a different set of inputs in the UI but
* all funnel through {@link buildScheduleString} to a backend-compatible
* string. ``custom`` is the escape hatch for power users who still want
* to type a raw cron expression. */
export type ScheduleMode =
| "interval"
| "daily"
| "weekly"
| "monthly"
| "once"
| "custom";
/** Unit used by interval mode. Backend parses ``m``/``h``/``d`` suffixes. */
export type IntervalUnit = "minutes" | "hours" | "days";
/** Cron weekday convention: Sunday = 0 .. Saturday = 6. Matches what
* croniter expects on the backend (no need to remap on submit). */
export const WEEKDAY_INDEXES = [0, 1, 2, 3, 4, 5, 6] as const;
export type Weekday = (typeof WEEKDAY_INDEXES)[number];
export interface ScheduleBuilderState {
/** Index of which "custom" radio is selected. */
mode: ScheduleMode;
/** Interval mode: positive integer, paired with ``intervalUnit``. */
intervalValue: number;
intervalUnit: IntervalUnit;
/** Daily/weekly/monthly mode: "HH:MM" 24h format from <input type=time>. */
timeOfDay: string;
/** Weekly mode: 0..6, Sunday-first. Empty means "every day", which is
* still valid — we send "*" for the day-of-week cron field. */
weekdays: Weekday[];
/** Monthly mode: 1..31 (no support for "last day of month" sugar — the
* croniter ``L`` extension isn't enabled in the parse_schedule regex). */
dayOfMonth: number;
/** Once mode: ``YYYY-MM-DDTHH:MM`` from <input type=datetime-local>. */
onceAt: string;
/** Custom mode: raw user-typed cron expression. Stored separately so
* flipping between modes doesn't erase the user's work. */
custom: string;
}
/** Default state — "every 30 minutes" is the most-common-cron-pattern
* starting point and avoids forcing the user to pick everything from
* scratch. */
export const DEFAULT_SCHEDULE_STATE: ScheduleBuilderState = {
mode: "interval",
intervalValue: 30,
intervalUnit: "minutes",
timeOfDay: "09:00",
weekdays: [1, 2, 3, 4, 5],
dayOfMonth: 1,
onceAt: "",
custom: "",
};
const UNIT_SUFFIX: Record<IntervalUnit, string> = {
minutes: "m",
hours: "h",
days: "d",
};
/** Build the schedule string from picker state. Returns ``""`` when the
* state is incomplete enough that the backend would 400 — the caller
* uses that to disable the Submit button.
*
* Why we lean on the broad parse_schedule grammar instead of always
* emitting cron expressions: interval syntax ("every 30m") survives a
* backend without ``croniter`` installed and renders more readably in
* the job list. We only emit raw cron when the picker truly needs the
* cron field expressiveness (specific weekdays, specific day-of-month). */
export function buildScheduleString(state: ScheduleBuilderState): string {
switch (state.mode) {
case "interval": {
const n = Math.floor(state.intervalValue);
if (!Number.isFinite(n) || n < 1) return "";
return `every ${n}${UNIT_SUFFIX[state.intervalUnit]}`;
}
case "daily": {
const parsed = parseTimeOfDay(state.timeOfDay);
if (!parsed) return "";
return `${parsed.minute} ${parsed.hour} * * *`;
}
case "weekly": {
const parsed = parseTimeOfDay(state.timeOfDay);
if (!parsed) return "";
// Empty weekday selection → "*" (every day) rather than a backend
// 400. The Daily mode is the cleaner choice for that, but if the
// user toggles all days off in Weekly mode we still emit a valid
// expression instead of breaking the submit.
const days =
state.weekdays.length === 0
? "*"
: [...state.weekdays].sort((a, b) => a - b).join(",");
return `${parsed.minute} ${parsed.hour} * * ${days}`;
}
case "monthly": {
const parsed = parseTimeOfDay(state.timeOfDay);
if (!parsed) return "";
const dom = Math.floor(state.dayOfMonth);
if (!Number.isFinite(dom) || dom < 1 || dom > 31) return "";
return `${parsed.minute} ${parsed.hour} ${dom} * *`;
}
case "once": {
const v = state.onceAt.trim();
if (!v) return "";
// <input type=datetime-local> already emits the
// "YYYY-MM-DDTHH:MM" shape that fromisoformat() accepts directly.
// Append ":00" so the backend's regex hits the "T" branch and
// the seconds component lines up with isoformat() output.
return v.length === 16 ? `${v}:00` : v;
}
case "custom":
return state.custom.trim();
}
}
/** Parse schedules emitted by buildScheduleString; unknown strings stay custom. */
export function parseScheduleString(
schedule: string,
): ScheduleBuilderState {
const trimmed = schedule.trim();
if (!trimmed) return { ...DEFAULT_SCHEDULE_STATE };
// ISO timestamp (one-shot).
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?$/.test(trimmed)) {
return {
...DEFAULT_SCHEDULE_STATE,
mode: "once",
onceAt: trimmed.slice(0, 16),
};
}
// Recurring interval.
const intervalMatch = /^every\s+(\d+)\s*([mhd])$/i.exec(trimmed);
if (intervalMatch) {
const value = Number.parseInt(intervalMatch[1], 10);
const suffix = intervalMatch[2].toLowerCase();
const unit: IntervalUnit =
suffix === "d" ? "days" : suffix === "h" ? "hours" : "minutes";
return {
...DEFAULT_SCHEDULE_STATE,
mode: "interval",
intervalValue: Number.isFinite(value) && value > 0 ? value : 1,
intervalUnit: unit,
};
}
// 5-field cron expression.
const parsedCron = parseSimpleCronExpression(trimmed);
if (parsedCron) {
if (parsedCron.mode === "daily") {
return { ...DEFAULT_SCHEDULE_STATE, mode: "daily", timeOfDay: parsedCron.time };
}
if (parsedCron.mode === "weekly") {
return {
...DEFAULT_SCHEDULE_STATE,
mode: "weekly",
timeOfDay: parsedCron.time,
weekdays: parsedCron.weekdays ?? [],
};
}
return {
...DEFAULT_SCHEDULE_STATE,
mode: "monthly",
timeOfDay: parsedCron.time,
dayOfMonth: parsedCron.dayOfMonth ?? 1,
};
}
// Fallback: preserve the raw string in custom mode.
return { ...DEFAULT_SCHEDULE_STATE, mode: "custom", custom: trimmed };
}
/**
* Shared helper: recognise the simple, well-shaped 5-field cron patterns
* that both the human-readable describer and the schedule builder care
* about. Returns a structured result or ``null`` when the expression has
* ranges, steps, per-month rules, or other complexity.
*/
function parseSimpleCronExpression(
expr: string,
): { mode: "daily" | "weekly" | "monthly"; time: string; weekdays?: Weekday[]; dayOfMonth?: number } | null {
const parts = expr.trim().split(/\s+/);
if (parts.length !== 5) return null;
const [minField, hourField, domField, monField, dowField] = parts;
if (monField !== "*") return null;
const isLiteralOrList = (f: string) => /^\d+(,\d+)*$|^\*$/.test(f);
if (
!isLiteralOrList(minField) ||
!isLiteralOrList(hourField) ||
!isLiteralOrList(domField) ||
!isLiteralOrList(dowField)
) {
return null;
}
if (minField === "*" || hourField === "*") return null;
const minutes = minField.split(",").map((n) => parseInt(n, 10));
const hours = hourField.split(",").map((n) => parseInt(n, 10));
if (minutes.length !== 1 || hours.length !== 1) return null;
if (
!Number.isFinite(minutes[0]) ||
!Number.isFinite(hours[0]) ||
hours[0] < 0 ||
hours[0] > 23 ||
minutes[0] < 0 ||
minutes[0] > 59
) {
return null;
}
const time = `${pad2(hours[0])}:${pad2(minutes[0])}`;
const domAll = domField === "*";
const dowAll = dowField === "*";
if (domAll && dowAll) {
return { mode: "daily", time };
}
if (domAll && !dowAll) {
const weekdays: Weekday[] = [];
for (const part of dowField.split(",")) {
const day = parseInt(part, 10);
if (!Number.isFinite(day) || day < 0 || day > 7) return null;
const normalized = (day === 7 ? 0 : day) as Weekday;
if (!weekdays.includes(normalized)) weekdays.push(normalized);
}
if (weekdays.length === 0) return null;
return { mode: "weekly", time, weekdays };
}
if (!domAll && dowAll) {
if (!/^\d+$/.test(domField)) return null;
const dom = parseInt(domField, 10);
if (!Number.isFinite(dom) || dom < 1 || dom > 31) return null;
return { mode: "monthly", time, dayOfMonth: dom };
}
return null;
}
function parseTimeOfDay(value: string): { hour: number; minute: number } | null {
if (!value || !/^\d{1,2}:\d{2}$/.test(value)) return null;
const [hh, mm] = value.split(":");
const hour = parseInt(hh, 10);
const minute = parseInt(mm, 10);
if (
!Number.isFinite(hour) ||
!Number.isFinite(minute) ||
hour < 0 ||
hour > 23 ||
minute < 0 ||
minute > 59
) {
return null;
}
return { hour, minute };
}
/** Translation surface the human-readable describer needs. Passing it
* in (instead of importing ``useI18n``) keeps the helper pure and
* testable; the CronPage threads ``t.cron.scheduleDescribe`` through. */
export interface ScheduleDescribeStrings {
/** Display when no schedule can be resolved (e.g. legacy/blank job). */
none: string;
/** "Every {n} minute(s)" — caller pluralises via {n}. */
everyMinutes: string;
everyHours: string;
everyDays: string;
/** "Daily at {time}" */
dailyAt: string;
/** "Weekly on {days} at {time}" */
weeklyAt: string;
/** "Monthly on the {day} at {time}" */
monthlyAt: string;
/** "Once at {time}" */
onceAt: string;
/** Weekday short names indexed 0..6 (Sunday-first). */
weekdaysShort: [string, string, string, string, string, string, string];
/** Ordinal suffix builder, e.g. "1st", "22nd". For locales that
* don't use English ordinals, just return ``String(day)``. */
ordinal: (day: number) => string;
}
/** Schedule shape stored on a ``CronJob`` row (see api.ts). */
export interface ScheduleLike {
kind?: string;
expr?: string;
minutes?: number;
run_at?: string;
display?: string;
}
/** Human-readable description of a stored schedule.
*
* Prefers a structured render over the raw ``display`` string so cron
* expressions like ``30 14 * * 1,3,5`` show up as "Weekly on Mon, Wed,
* Fri at 14:30" instead of the raw five-field gibberish. Falls back to
* ``display`` / ``expr`` / ``none`` in that order if we can't make sense
* of the schedule (e.g. exotic cron with ranges, step values, or @reboot
* macros that we'd misrepresent if we tried to "humanize"). */
export function describeSchedule(
schedule: ScheduleLike | undefined,
fallbackDisplay: string | undefined,
strings: ScheduleDescribeStrings,
): string {
if (!schedule) return fallbackDisplay || strings.none;
if (schedule.kind === "interval" && typeof schedule.minutes === "number") {
return describeInterval(schedule.minutes, strings);
}
if (schedule.kind === "once" && schedule.run_at) {
return strings.onceAt.replace(
"{time}",
formatIsoLocal(schedule.run_at, false),
);
}
if (schedule.kind === "cron" && schedule.expr) {
const cronDesc = describeCronExpression(schedule.expr, strings);
if (cronDesc) return cronDesc;
}
// Try the raw expression as a last attempt — for legacy jobs stored
// without ``kind``, the ``schedule_display`` field often *is* the cron
// expression.
if (fallbackDisplay) {
const cronDesc = describeCronExpression(fallbackDisplay, strings);
if (cronDesc) return cronDesc;
return fallbackDisplay;
}
if (schedule.display) return schedule.display;
if (schedule.expr) return schedule.expr;
return strings.none;
}
function describeInterval(
minutes: number,
strings: ScheduleDescribeStrings,
): string {
if (minutes <= 0) return strings.none;
if (minutes % 1440 === 0) {
return strings.everyDays.replace("{n}", String(minutes / 1440));
}
if (minutes % 60 === 0) {
return strings.everyHours.replace("{n}", String(minutes / 60));
}
return strings.everyMinutes.replace("{n}", String(minutes));
}
/** Recognise the common, well-shaped cron patterns and return a
* human sentence for them. Returns ``null`` when the expression has any
* ranges, steps, or other complexity that would be misleading to
* "humanize" — caller falls back to displaying the raw expression so
* the user sees what's actually scheduled.
*
* Strictly 5-field only: the backend ``parse_schedule`` also accepts the
* 6-field ``minute hour dom month dow year`` form, but humanising those
* by destructuring only the first five fields would silently drop the
* year and mislead the user (e.g. ``0 9 * * * 2099`` would read as
* "Daily at 09:00"). 6+ field expressions intentionally fall through to
* the raw-string fallback in {@link describeSchedule}. */
function describeCronExpression(
expr: string,
strings: ScheduleDescribeStrings,
): string | null {
const parsed = parseSimpleCronExpression(expr);
if (!parsed) return null;
if (parsed.mode === "daily") {
return strings.dailyAt.replace("{time}", parsed.time);
}
if (parsed.mode === "weekly") {
const labels = (parsed.weekdays ?? [])
.map((d) => strings.weekdaysShort[d])
.filter(Boolean)
.join(", ");
return strings.weeklyAt
.replace("{days}", labels)
.replace("{time}", parsed.time);
}
return strings.monthlyAt
.replace("{day}", strings.ordinal(parsed.dayOfMonth ?? 1))
.replace("{time}", parsed.time);
}
function pad2(n: number): string {
return n < 10 ? `0${n}` : String(n);
}
/** Format an ISO date for inline display. Drops the seconds + TZ
* suffix so the cron list stays compact. Falls back to the raw string
* if Date parsing fails. */
function formatIsoLocal(iso: string, includeSeconds: boolean): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const yyyy = d.getFullYear();
const mm = pad2(d.getMonth() + 1);
const dd = pad2(d.getDate());
const hh = pad2(d.getHours());
const mi = pad2(d.getMinutes());
if (includeSeconds) {
return `${yyyy}-${mm}-${dd} ${hh}:${mi}:${pad2(d.getSeconds())}`;
}
return `${yyyy}-${mm}-${dd} ${hh}:${mi}`;
}
/** Convenience: build an English ordinal suffix ("1st", "2nd", "23rd").
* Most non-English locales should just return ``String(day)`` from
* their ``ordinal`` override. */
export function englishOrdinal(day: number): string {
const d = Math.floor(day);
if (!Number.isFinite(d) || d < 1) return String(day);
const lastTwo = d % 100;
if (lastTwo >= 11 && lastTwo <= 13) return `${d}th`;
switch (d % 10) {
case 1:
return `${d}st`;
case 2:
return `${d}nd`;
case 3:
return `${d}rd`;
default:
return `${d}th`;
}
}
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { importSummary, parseImportSessions } from "./session-import";
describe("parseImportSessions", () => {
it("accepts a single exported session", () => {
expect(parseImportSessions('{"id":"session-1","messages":[]}')).toEqual([
{ id: "session-1", messages: [] },
]);
});
it("accepts arrays and wrapped session exports", () => {
const sessions = [{ id: "one" }, { id: "two" }];
expect(parseImportSessions(JSON.stringify(sessions))).toEqual(sessions);
expect(parseImportSessions(JSON.stringify({ sessions }))).toEqual(sessions);
});
it("accepts JSONL session exports", () => {
expect(parseImportSessions('{"id":"one"}\n\n{"id":"two"}\n')).toEqual([
{ id: "one" },
{ id: "two" },
]);
});
it("rejects empty files and non-object entries", () => {
expect(() => parseImportSessions(" \n")).toThrow("File is empty");
expect(() => parseImportSessions('[{"id":"one"},42]')).toThrow(
"Expected exported session JSON or JSONL",
);
});
});
describe("importSummary", () => {
it("includes skipped and detached counts only when present", () => {
expect(
importSummary({
ok: true,
imported: 2,
skipped: 1,
detached: 1,
imported_ids: ["one", "two"],
skipped_ids: ["existing"],
errors: [],
}),
).toBe("2 imported; 1 skipped; 1 detached from missing parents");
});
});
+46
View File
@@ -0,0 +1,46 @@
import type { SessionImportResponse } from "@/lib/api";
export type ImportableSession = Record<string, unknown>;
function normalizeImportSessions(value: unknown): ImportableSession[] {
const candidate =
value &&
typeof value === "object" &&
!Array.isArray(value) &&
Array.isArray((value as { sessions?: unknown }).sessions)
? (value as { sessions: unknown[] }).sessions
: Array.isArray(value)
? value
: [value];
const sessions = candidate.filter(
(item): item is ImportableSession =>
!!item && typeof item === "object" && !Array.isArray(item),
);
if (sessions.length !== candidate.length) {
throw new Error("Expected exported session JSON or JSONL");
}
return sessions;
}
export function parseImportSessions(text: string): ImportableSession[] {
const trimmed = text.trim();
if (!trimmed) throw new Error("File is empty");
try {
return normalizeImportSessions(JSON.parse(trimmed));
} catch (jsonError) {
const lines = trimmed.split(/\r?\n/).filter((line) => line.trim());
if (lines.length <= 1) throw jsonError;
return normalizeImportSessions(lines.map((line) => JSON.parse(line)));
}
}
export function importSummary(result: SessionImportResponse): string {
const parts = [`${result.imported} imported`];
if (result.skipped > 0) parts.push(`${result.skipped} skipped`);
if (result.detached > 0) {
parts.push(`${result.detached} detached from missing parents`);
}
return parts.join("; ");
}
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest'
import { formatSessionPruneResult } from './session-prune'
describe('formatSessionPruneResult', () => {
it('reports open sessions skipped by the prune safety guard', () => {
expect(formatSessionPruneResult({ removed: 0, skipped_open: 2 })).toBe(
'Pruned 0 sessions. Skipped 2 open sessions; prune only removes ended sessions.'
)
})
it('keeps the existing success message when nothing was skipped', () => {
expect(formatSessionPruneResult({ removed: 1, skipped_open: 0 })).toBe('Pruned 1 session')
})
})
+11
View File
@@ -0,0 +1,11 @@
interface SessionPruneResult {
removed: number
skipped_open: number
}
export function formatSessionPruneResult(result: SessionPruneResult): string {
const removed = `Pruned ${result.removed} session${result.removed === 1 ? '' : 's'}`
if (!result.skipped_open) return removed
return `${removed}. Skipped ${result.skipped_open} open session${result.skipped_open === 1 ? '' : 's'}; prune only removes ended sessions.`
}
+21
View File
@@ -0,0 +1,21 @@
import { describe, it, expect } from "vitest";
import { shouldRefreshSessions } from "./session-refresh";
describe("shouldRefreshSessions", () => {
it("returns false on the first poll (no baseline yet)", () => {
expect(shouldRefreshSessions(null, "s2")).toBe(false);
});
it("returns false when the current response has no sessions", () => {
expect(shouldRefreshSessions("s1", null)).toBe(false);
expect(shouldRefreshSessions(null, null)).toBe(false);
});
it("returns false when the newest session id is unchanged", () => {
expect(shouldRefreshSessions("s1", "s1")).toBe(false);
});
it("returns true when a new session appears at the head of the list", () => {
expect(shouldRefreshSessions("s1", "s2")).toBe(true);
});
});
+26
View File
@@ -0,0 +1,26 @@
/**
* Decide whether the paginated sessions list should be silently
* re-fetched after an overview poll.
*
* The dashboard's FastAPI server and a terminal CLI are separate
* processes that share the same SQLite session DB. There is no
* inter-process push channel, so the Sessions page polls the 50 newest
* sessions every few seconds (the "overview" poll). When that poll
* surfaces a session id at the head of the list that we have not seen
* before, a new session was created in another process and the
* paginated list is stale — refresh it.
*
* Returns false on the very first poll (no baseline yet) and when
* either id is null (empty DB / transient empty response), so we never
* trigger a spurious reload on mount or while the DB is empty.
*/
export function shouldRefreshSessions(
prevNewestId: string | null,
currentNewestId: string | null,
): boolean {
return (
prevNewestId !== null &&
currentNewestId !== null &&
prevNewestId !== currentNewestId
);
}
+163
View File
@@ -0,0 +1,163 @@
/**
* Slash command execution pipeline for the web chat.
*
* Mirrors the Ink TUI's createSlashHandler.ts:
*
* 1. Parse the command into `name` + `arg`.
* 2. Try `slash.exec` — covers every registry-backed command the terminal
* UI knows about (/help, /resume, /compact, /model, …). Output is
* rendered into the transcript.
* 3. If `slash.exec` errors (command rejected, unknown, or needs client
* behaviour), fall back to `command.dispatch` which returns a typed
* directive: `exec` | `plugin` | `alias` | `skill` | `send`.
* 4. Each directive is dispatched to the appropriate callback.
*
* Keeping the pipeline here (instead of inline in ChatPage) lets future
* clients (SwiftUI, Android) implement the same logic by reading the same
* contract.
*/
import type { GatewayClient } from "@/lib/gatewayClient";
export interface SlashExecResponse {
output?: string;
warning?: string;
}
export type CommandDispatchResponse =
| { type: "exec" | "plugin"; output?: string }
| { type: "alias"; target: string }
| { type: "skill"; name: string; message?: string }
| { type: "send"; message: string };
export interface SlashExecCallbacks {
/** Render a transcript system message. */
sys(text: string): void;
/** Submit a user message to the agent (prompt.submit). */
send(message: string): Promise<void> | void;
}
export interface SlashExecOptions {
/** Raw command including the leading slash (e.g. "/model opus-4.6"). */
command: string;
/** Session id. If empty the call is still issued — some commands are session-less. */
sessionId: string;
gw: GatewayClient;
callbacks: SlashExecCallbacks;
}
export type SlashExecResult = "done" | "sent" | "error";
/**
* Run a slash command. Returns the terminal state so callers can decide
* whether to clear the composer, queue retries, etc.
*/
export async function executeSlash({
command,
sessionId,
gw,
callbacks: { sys, send },
}: SlashExecOptions): Promise<SlashExecResult> {
const { name, arg } = parseSlash(command);
if (!name) {
sys("empty slash command");
return "error";
}
// Primary dispatcher.
try {
const r = await gw.request<SlashExecResponse>("slash.exec", {
command: command.replace(/^\/+/, ""),
session_id: sessionId,
});
const body = r?.output || `/${name}: no output`;
sys(r?.warning ? `warning: ${r.warning}\n${body}` : body);
return "done";
} catch {
/* fall through to command.dispatch */
}
try {
const d = parseCommandDispatch(
await gw.request<unknown>("command.dispatch", {
name,
arg,
session_id: sessionId,
}),
);
if (!d) {
sys("error: invalid response: command.dispatch");
return "error";
}
switch (d.type) {
case "exec":
case "plugin":
sys(d.output ?? "(no output)");
return "done";
case "alias":
return executeSlash({
command: `/${d.target}${arg ? ` ${arg}` : ""}`,
sessionId,
gw,
callbacks: { sys, send },
});
case "skill":
case "send": {
const msg = d.message?.trim() ?? "";
if (!msg) {
sys(
`/${name}: ${d.type === "skill" ? "skill payload missing message" : "empty message"}`,
);
return "error";
}
if (d.type === "skill") sys(`⚡ loading skill: ${d.name}`);
await send(msg);
return "sent";
}
}
} catch (err) {
sys(`error: ${err instanceof Error ? err.message : String(err)}`);
return "error";
}
}
export function parseSlash(command: string): { name: string; arg: string } {
const m = command.replace(/^\/+/, "").match(/^(\S+)\s*(.*)$/);
return m ? { name: m[1], arg: m[2].trim() } : { name: "", arg: "" };
}
function parseCommandDispatch(raw: unknown): CommandDispatchResponse | null {
if (!raw || typeof raw !== "object") return null;
const r = raw as Record<string, unknown>;
const str = (v: unknown) => (typeof v === "string" ? v : undefined);
switch (r.type) {
case "exec":
case "plugin":
return { type: r.type, output: str(r.output) };
case "alias":
return typeof r.target === "string"
? { type: "alias", target: r.target }
: null;
case "skill":
return typeof r.name === "string"
? { type: "skill", name: r.name, message: str(r.message) }
: null;
case "send":
return typeof r.message === "string"
? { type: "send", message: r.message }
: null;
default:
return null;
}
}
+35
View File
@@ -0,0 +1,35 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/** Mondwest font only — use on layout shells; do not force normal-case here or `text-display` chrome (Segmented, badges) stops uppercasing. */
export const themedFont = "font-mondwest";
/** Mondwest body copy — sentence-case themed text (not uppercase chrome). */
export const themedBody = "font-mondwest normal-case";
/** Mondwest brand chrome — uppercase section headers and nav labels. */
export const themedChrome = "font-mondwest text-display";
/** Relative time from a Unix epoch timestamp (seconds). */
export function timeAgo(ts: number): string {
const delta = Date.now() / 1000 - ts;
if (delta < 60) return "just now";
if (delta < 3600) return `${Math.floor(delta / 60)}m ago`;
if (delta < 86400) return `${Math.floor(delta / 3600)}h ago`;
if (delta < 172800) return "yesterday";
return `${Math.floor(delta / 86400)}d ago`;
}
/** Relative time from an ISO-8601 timestamp string. */
export function isoTimeAgo(iso: string): string {
const delta = (Date.now() - new Date(iso).getTime()) / 1000;
if (delta < 0 || Number.isNaN(delta)) return "unknown";
if (delta < 60) return "just now";
if (delta < 3600) return `${Math.floor(delta / 60)}m ago`;
if (delta < 86400) return `${Math.floor(delta / 3600)}h ago`;
return `${Math.floor(delta / 86400)}d ago`;
}