feat(ui): add chat infrastructure modules (slice 1/3 of dashboard-v2) (#41497)

* feat(ui): add chat infrastructure modules (slice 1 of dashboard-v2)

New self-contained chat modules extracted from dashboard-v2-structure:

- chat/slash-commands.ts: slash command definitions and completions
- chat/slash-command-executor.ts: execute slash commands via gateway RPC
- chat/slash-command-executor.node.test.ts: test coverage
- chat/speech.ts: speech-to-text (STT) support
- chat/input-history.ts: per-session input history navigation
- chat/pinned-messages.ts: pinned message management
- chat/deleted-messages.ts: deleted message tracking
- chat/export.ts: shared exportChatMarkdown helper
- chat-export.ts: re-export shim for backwards compat

Gateway fix:
- Restore usage/cost stripping in chat.history sanitization
- Add test coverage for sanitization behavior

These modules are additive and tree-shaken — no existing code
imports them yet. They will be wired in subsequent slices.

* Update ui/src/ui/chat/export.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* fix(ui): address review feedback on chat infra slice

- export.ts: handle array content blocks (Claude API format) instead
  of silently exporting empty strings
- slash-command-executor.ts: restrict /kill all to current session's
  subagent subtree instead of all sessions globally
- slash-command-executor.ts: only count truly aborted runs (check
  aborted !== false) in /kill summary

* fix: scope /kill <id> to current session subtree and preserve usage.cost in chat.history

- Restrict /kill <id> matching to only subagents belonging to the current
  session's agent subtree (P1 review feedback)
- Preserve nested usage.cost in chat.history sanitization so cost badges
  remain available (P2 review feedback)

* fix(ui): tighten slash kill scoping

* fix(ui): support legacy slash kill scopes

* fix(ci): repair pr branch checks

* Gateway: harden chat abort and export

* UI: align slash commands with session tree scope

* UI: resolve session aliases for slash command lookups

* Update .gitignore

* Cron: use shared nested lane resolver

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
Val Alexander
2026-03-12 02:48:58 -05:00
committed by GitHub
parent ed0ec57a7b
commit c5ea6134d0
19 changed files with 2057 additions and 16 deletions

View File

@@ -186,6 +186,8 @@ export type GatewayTailscaleConfig = {
};
export type GatewayRemoteConfig = {
/** Whether remote gateway surfaces are enabled. Default: true when absent. */
enabled?: boolean;
/** Remote Gateway WebSocket URL (ws:// or wss://). */
url?: string;
/** Transport for macOS remote connections (ssh tunnel or direct WS). */

View File

@@ -6,6 +6,8 @@ export type ChatAbortControllerEntry = {
sessionKey: string;
startedAtMs: number;
expiresAtMs: number;
ownerConnId?: string;
ownerDeviceId?: string;
};
export function isChatStopCommandText(text: string): boolean {

View File

@@ -0,0 +1,147 @@
import { describe, expect, it, vi } from "vitest";
import { chatHandlers } from "./chat.js";
function createActiveRun(sessionKey: string, owner?: { connId?: string; deviceId?: string }) {
const now = Date.now();
return {
controller: new AbortController(),
sessionId: `${sessionKey}-session`,
sessionKey,
startedAtMs: now,
expiresAtMs: now + 30_000,
ownerConnId: owner?.connId,
ownerDeviceId: owner?.deviceId,
};
}
function createContext(overrides: Record<string, unknown> = {}) {
return {
chatAbortControllers: new Map(),
chatRunBuffers: new Map(),
chatDeltaSentAt: new Map(),
chatAbortedRuns: new Map<string, number>(),
removeChatRun: vi
.fn()
.mockImplementation((run: string) => ({ sessionKey: "main", clientRunId: run })),
agentRunSeq: new Map<string, number>(),
broadcast: vi.fn(),
nodeSendToSession: vi.fn(),
logGateway: { warn: vi.fn() },
...overrides,
};
}
async function invokeChatAbort(params: {
context: ReturnType<typeof createContext>;
request: { sessionKey: string; runId?: string };
client?: {
connId?: string;
connect?: {
device?: { id?: string };
scopes?: string[];
};
} | null;
}) {
const respond = vi.fn();
await chatHandlers["chat.abort"]({
params: params.request,
respond: respond as never,
context: params.context as never,
req: {} as never,
client: (params.client ?? null) as never,
isWebchatConnect: () => false,
});
return respond;
}
describe("chat.abort authorization", () => {
it("rejects explicit run aborts from other clients", async () => {
const context = createContext({
chatAbortControllers: new Map([
["run-1", createActiveRun("main", { connId: "conn-owner", deviceId: "dev-owner" })],
]),
});
const respond = await invokeChatAbort({
context,
request: { sessionKey: "main", runId: "run-1" },
client: {
connId: "conn-other",
connect: { device: { id: "dev-other" }, scopes: ["operator.write"] },
},
});
const [ok, payload, error] = respond.mock.calls.at(-1) ?? [];
expect(ok).toBe(false);
expect(payload).toBeUndefined();
expect(error).toMatchObject({ code: "INVALID_REQUEST", message: "unauthorized" });
expect(context.chatAbortControllers.has("run-1")).toBe(true);
});
it("allows the same paired device to abort after reconnecting", async () => {
const context = createContext({
chatAbortControllers: new Map([
["run-1", createActiveRun("main", { connId: "conn-old", deviceId: "dev-1" })],
]),
});
const respond = await invokeChatAbort({
context,
request: { sessionKey: "main", runId: "run-1" },
client: {
connId: "conn-new",
connect: { device: { id: "dev-1" }, scopes: ["operator.write"] },
},
});
const [ok, payload] = respond.mock.calls.at(-1) ?? [];
expect(ok).toBe(true);
expect(payload).toMatchObject({ aborted: true, runIds: ["run-1"] });
expect(context.chatAbortControllers.has("run-1")).toBe(false);
});
it("only aborts session-scoped runs owned by the requester", async () => {
const context = createContext({
chatAbortControllers: new Map([
["run-mine", createActiveRun("main", { deviceId: "dev-1" })],
["run-other", createActiveRun("main", { deviceId: "dev-2" })],
]),
});
const respond = await invokeChatAbort({
context,
request: { sessionKey: "main" },
client: {
connId: "conn-1",
connect: { device: { id: "dev-1" }, scopes: ["operator.write"] },
},
});
const [ok, payload] = respond.mock.calls.at(-1) ?? [];
expect(ok).toBe(true);
expect(payload).toMatchObject({ aborted: true, runIds: ["run-mine"] });
expect(context.chatAbortControllers.has("run-mine")).toBe(false);
expect(context.chatAbortControllers.has("run-other")).toBe(true);
});
it("allows operator.admin clients to bypass owner checks", async () => {
const context = createContext({
chatAbortControllers: new Map([
["run-1", createActiveRun("main", { connId: "conn-owner", deviceId: "dev-owner" })],
]),
});
const respond = await invokeChatAbort({
context,
request: { sessionKey: "main", runId: "run-1" },
client: {
connId: "conn-admin",
connect: { device: { id: "dev-admin" }, scopes: ["operator.admin"] },
},
});
const [ok, payload] = respond.mock.calls.at(-1) ?? [];
expect(ok).toBe(true);
expect(payload).toMatchObject({ aborted: true, runIds: ["run-1"] });
});
});

View File

@@ -25,7 +25,6 @@ import {
} from "../../utils/message-channel.js";
import {
abortChatRunById,
abortChatRunsForSessionKey,
type ChatAbortControllerEntry,
type ChatAbortOps,
isChatStopCommandText,
@@ -33,6 +32,7 @@ import {
} from "../chat-abort.js";
import { type ChatImageContent, parseMessageWithAttachments } from "../chat-attachments.js";
import { stripEnvelopeFromMessage, stripEnvelopeFromMessages } from "../chat-sanitize.js";
import { ADMIN_SCOPE } from "../method-scopes.js";
import {
GATEWAY_CLIENT_CAPS,
GATEWAY_CLIENT_MODES,
@@ -83,6 +83,12 @@ type AbortedPartialSnapshot = {
abortOrigin: AbortOrigin;
};
type ChatAbortRequester = {
connId?: string;
deviceId?: string;
isAdmin: boolean;
};
const CHAT_HISTORY_TEXT_MAX_CHARS = 12_000;
const CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES = 128 * 1024;
const CHAT_HISTORY_OVERSIZED_PLACEHOLDER = "[chat.history omitted: message too large]";
@@ -314,6 +320,68 @@ function sanitizeChatHistoryContentBlock(block: unknown): { block: unknown; chan
return { block: changed ? entry : block, changed };
}
/**
* Validate that a value is a finite number, returning undefined otherwise.
*/
function toFiniteNumber(x: unknown): number | undefined {
return typeof x === "number" && Number.isFinite(x) ? x : undefined;
}
/**
* Sanitize usage metadata to ensure only finite numeric fields are included.
* Prevents UI crashes from malformed transcript JSON.
*/
function sanitizeUsage(raw: unknown): Record<string, number> | undefined {
if (!raw || typeof raw !== "object") {
return undefined;
}
const u = raw as Record<string, unknown>;
const out: Record<string, number> = {};
// Whitelist known usage fields and validate they're finite numbers
const knownFields = [
"input",
"output",
"totalTokens",
"inputTokens",
"outputTokens",
"cacheRead",
"cacheWrite",
"cache_read_input_tokens",
"cache_creation_input_tokens",
];
for (const k of knownFields) {
const n = toFiniteNumber(u[k]);
if (n !== undefined) {
out[k] = n;
}
}
// Preserve nested usage.cost when present
if ("cost" in u && u.cost != null && typeof u.cost === "object") {
const sanitizedCost = sanitizeCost(u.cost);
if (sanitizedCost) {
(out as Record<string, unknown>).cost = sanitizedCost;
}
}
return Object.keys(out).length > 0 ? out : undefined;
}
/**
* Sanitize cost metadata to ensure only finite numeric fields are included.
* Prevents UI crashes from calling .toFixed() on non-numbers.
*/
function sanitizeCost(raw: unknown): { total?: number } | undefined {
if (!raw || typeof raw !== "object") {
return undefined;
}
const c = raw as Record<string, unknown>;
const total = toFiniteNumber(c.total);
return total !== undefined ? { total } : undefined;
}
function sanitizeChatHistoryMessage(message: unknown): { message: unknown; changed: boolean } {
if (!message || typeof message !== "object") {
return { message, changed: false };
@@ -325,13 +393,38 @@ function sanitizeChatHistoryMessage(message: unknown): { message: unknown; chang
delete entry.details;
changed = true;
}
if ("usage" in entry) {
delete entry.usage;
changed = true;
}
if ("cost" in entry) {
delete entry.cost;
changed = true;
// Keep usage/cost so the chat UI can render per-message token and cost badges.
// Only retain usage/cost on assistant messages and validate numeric fields to prevent UI crashes.
if (entry.role !== "assistant") {
if ("usage" in entry) {
delete entry.usage;
changed = true;
}
if ("cost" in entry) {
delete entry.cost;
changed = true;
}
} else {
// Validate and sanitize usage/cost for assistant messages
if ("usage" in entry) {
const sanitized = sanitizeUsage(entry.usage);
if (sanitized) {
entry.usage = sanitized;
} else {
delete entry.usage;
}
changed = true;
}
if ("cost" in entry) {
const sanitized = sanitizeCost(entry.cost);
if (sanitized) {
entry.cost = sanitized;
} else {
delete entry.cost;
}
changed = true;
}
}
if (typeof entry.content === "string") {
@@ -597,12 +690,12 @@ function appendAssistantTranscriptMessage(params: {
function collectSessionAbortPartials(params: {
chatAbortControllers: Map<string, ChatAbortControllerEntry>;
chatRunBuffers: Map<string, string>;
sessionKey: string;
runIds: ReadonlySet<string>;
abortOrigin: AbortOrigin;
}): AbortedPartialSnapshot[] {
const out: AbortedPartialSnapshot[] = [];
for (const [runId, active] of params.chatAbortControllers) {
if (active.sessionKey !== params.sessionKey) {
if (!params.runIds.has(runId)) {
continue;
}
const text = params.chatRunBuffers.get(runId);
@@ -664,23 +757,104 @@ function createChatAbortOps(context: GatewayRequestContext): ChatAbortOps {
};
}
function normalizeOptionalText(value?: string | null): string | undefined {
const trimmed = value?.trim();
return trimmed || undefined;
}
function resolveChatAbortRequester(
client: GatewayRequestHandlerOptions["client"],
): ChatAbortRequester {
const scopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : [];
return {
connId: normalizeOptionalText(client?.connId),
deviceId: normalizeOptionalText(client?.connect?.device?.id),
isAdmin: scopes.includes(ADMIN_SCOPE),
};
}
function canRequesterAbortChatRun(
entry: ChatAbortControllerEntry,
requester: ChatAbortRequester,
): boolean {
if (requester.isAdmin) {
return true;
}
const ownerDeviceId = normalizeOptionalText(entry.ownerDeviceId);
const ownerConnId = normalizeOptionalText(entry.ownerConnId);
if (!ownerDeviceId && !ownerConnId) {
return true;
}
if (ownerDeviceId && requester.deviceId && ownerDeviceId === requester.deviceId) {
return true;
}
if (ownerConnId && requester.connId && ownerConnId === requester.connId) {
return true;
}
return false;
}
function resolveAuthorizedRunIdsForSession(params: {
chatAbortControllers: Map<string, ChatAbortControllerEntry>;
sessionKey: string;
requester: ChatAbortRequester;
}) {
const authorizedRunIds: string[] = [];
let matchedSessionRuns = 0;
for (const [runId, active] of params.chatAbortControllers) {
if (active.sessionKey !== params.sessionKey) {
continue;
}
matchedSessionRuns += 1;
if (canRequesterAbortChatRun(active, params.requester)) {
authorizedRunIds.push(runId);
}
}
return {
matchedSessionRuns,
authorizedRunIds,
};
}
function abortChatRunsForSessionKeyWithPartials(params: {
context: GatewayRequestContext;
ops: ChatAbortOps;
sessionKey: string;
abortOrigin: AbortOrigin;
stopReason?: string;
requester: ChatAbortRequester;
}) {
const { matchedSessionRuns, authorizedRunIds } = resolveAuthorizedRunIdsForSession({
chatAbortControllers: params.context.chatAbortControllers,
sessionKey: params.sessionKey,
requester: params.requester,
});
if (authorizedRunIds.length === 0) {
return {
aborted: false,
runIds: [],
unauthorized: matchedSessionRuns > 0,
};
}
const authorizedRunIdSet = new Set(authorizedRunIds);
const snapshots = collectSessionAbortPartials({
chatAbortControllers: params.context.chatAbortControllers,
chatRunBuffers: params.context.chatRunBuffers,
sessionKey: params.sessionKey,
runIds: authorizedRunIdSet,
abortOrigin: params.abortOrigin,
});
const res = abortChatRunsForSessionKey(params.ops, {
sessionKey: params.sessionKey,
stopReason: params.stopReason,
});
const runIds: string[] = [];
for (const runId of authorizedRunIds) {
const res = abortChatRunById(params.ops, {
runId,
sessionKey: params.sessionKey,
stopReason: params.stopReason,
});
if (res.aborted) {
runIds.push(runId);
}
}
const res = { aborted: runIds.length > 0, runIds, unauthorized: false };
if (res.aborted) {
persistAbortedPartials({
context: params.context,
@@ -802,7 +976,7 @@ export const chatHandlers: GatewayRequestHandlers = {
verboseLevel,
});
},
"chat.abort": ({ params, respond, context }) => {
"chat.abort": ({ params, respond, context, client }) => {
if (!validateChatAbortParams(params)) {
respond(
false,
@@ -820,6 +994,7 @@ export const chatHandlers: GatewayRequestHandlers = {
};
const ops = createChatAbortOps(context);
const requester = resolveChatAbortRequester(client);
if (!runId) {
const res = abortChatRunsForSessionKeyWithPartials({
@@ -828,7 +1003,12 @@ export const chatHandlers: GatewayRequestHandlers = {
sessionKey: rawSessionKey,
abortOrigin: "rpc",
stopReason: "rpc",
requester,
});
if (res.unauthorized) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unauthorized"));
return;
}
respond(true, { ok: true, aborted: res.aborted, runIds: res.runIds });
return;
}
@@ -846,6 +1026,10 @@ export const chatHandlers: GatewayRequestHandlers = {
);
return;
}
if (!canRequesterAbortChatRun(active, requester)) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unauthorized"));
return;
}
const partialText = context.chatRunBuffers.get(runId);
const res = abortChatRunById(ops, {
@@ -987,7 +1171,12 @@ export const chatHandlers: GatewayRequestHandlers = {
sessionKey: rawSessionKey,
abortOrigin: "stop-command",
stopReason: "stop",
requester: resolveChatAbortRequester(client),
});
if (res.unauthorized) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unauthorized"));
return;
}
respond(true, { ok: true, aborted: res.aborted, runIds: res.runIds });
return;
}
@@ -1017,6 +1206,8 @@ export const chatHandlers: GatewayRequestHandlers = {
sessionKey: rawSessionKey,
startedAtMs: now,
expiresAtMs: resolveChatRunExpiresAtMs({ now, timeoutMs }),
ownerConnId: normalizeOptionalText(client?.connId),
ownerDeviceId: normalizeOptionalText(client?.connect?.device?.id),
});
const ackPayload = {
runId: clientRunId,

View File

@@ -273,6 +273,37 @@ describe("gateway server chat", () => {
});
});
test("chat.history preserves usage and cost metadata for assistant messages", async () => {
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
await connectOk(ws);
const sessionDir = await createSessionDir();
await writeMainSessionStore();
await writeMainSessionTranscript(sessionDir, [
JSON.stringify({
message: {
role: "assistant",
timestamp: Date.now(),
content: [{ type: "text", text: "hello" }],
usage: { input: 12, output: 5, totalTokens: 17 },
cost: { total: 0.0123 },
details: { debug: true },
},
}),
]);
const messages = await fetchHistoryMessages(ws);
expect(messages).toHaveLength(1);
expect(messages[0]).toMatchObject({
role: "assistant",
usage: { input: 12, output: 5, totalTokens: 17 },
cost: { total: 0.0123 },
});
expect(messages[0]).not.toHaveProperty("details");
});
});
test("chat.history strips inline directives from displayed message text", async () => {
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
await connectOk(ws);

View File

@@ -810,6 +810,7 @@ export function listSessionsFromStore(params: {
const model = resolvedModel.model ?? DEFAULT_MODEL;
return {
key,
spawnedBy: entry?.spawnedBy,
entry,
kind: classifySessionKey(key, entry),
label: entry?.label,

View File

@@ -15,6 +15,7 @@ export type GatewaySessionsDefaults = {
export type GatewaySessionRow = {
key: string;
spawnedBy?: string;
kind: "direct" | "group" | "global" | "unknown";
label?: string;
displayName?: string;

1
ui/src/ui/chat-export.ts Normal file
View File

@@ -0,0 +1 @@
export { exportChatMarkdown } from "./chat/export.ts";

View File

@@ -0,0 +1,49 @@
const PREFIX = "openclaw:deleted:";
export class DeletedMessages {
private key: string;
private _keys = new Set<string>();
constructor(sessionKey: string) {
this.key = PREFIX + sessionKey;
this.load();
}
has(key: string): boolean {
return this._keys.has(key);
}
delete(key: string): void {
this._keys.add(key);
this.save();
}
restore(key: string): void {
this._keys.delete(key);
this.save();
}
clear(): void {
this._keys.clear();
this.save();
}
private load(): void {
try {
const raw = localStorage.getItem(this.key);
if (!raw) {
return;
}
const arr = JSON.parse(raw);
if (Array.isArray(arr)) {
this._keys = new Set(arr.filter((s) => typeof s === "string"));
}
} catch {
// ignore
}
}
private save(): void {
localStorage.setItem(this.key, JSON.stringify([...this._keys]));
}
}

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { buildChatExportFilename, buildChatMarkdown, sanitizeFilenameComponent } from "./export.ts";
describe("chat export hardening", () => {
it("escapes raw HTML in exported markdown content and labels", () => {
const markdown = buildChatMarkdown(
[
{
role: "assistant",
content: "<img src=x onerror=alert(1)><script>alert(2)</script>",
timestamp: Date.UTC(2026, 2, 11, 12, 0, 0),
},
],
"Bot </script><script>alert(3)</script>",
);
expect(markdown).toContain(
"# Chat with Bot &lt;/script&gt;&lt;script&gt;alert(3)&lt;/script&gt;",
);
expect(markdown).toContain(
"## Bot &lt;/script&gt;&lt;script&gt;alert(3)&lt;/script&gt; (2026-03-11T12:00:00.000Z)",
);
expect(markdown).toContain(
"&lt;img src=x onerror=alert(1)&gt;&lt;script&gt;alert(2)&lt;/script&gt;",
);
expect(markdown).not.toContain("<script>");
expect(markdown).not.toContain("<img");
});
it("sanitizes the downloaded filename component", () => {
expect(sanitizeFilenameComponent("../NUL\t<script>alert(1)</script>")).toBe(
"NUL scriptalert1-script",
);
expect(buildChatExportFilename("../NUL\t<script>alert(1)</script>", 123)).toBe(
"chat-NUL scriptalert1-script-123.md",
);
});
});

68
ui/src/ui/chat/export.ts Normal file
View File

@@ -0,0 +1,68 @@
/**
* Export chat history as markdown file.
*/
export function escapeHtmlInMarkdown(text: string): string {
return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}
export function normalizeSingleLineLabel(label: string, fallback = "Assistant"): string {
const normalized = label.replace(/[\r\n\t]+/g, " ").trim();
return normalized || fallback;
}
export function sanitizeFilenameComponent(input: string): string {
const normalized = normalizeSingleLineLabel(input, "assistant").normalize("NFKC");
const sanitized = normalized
.replace(/[\\/]/g, "-")
.replace(/[^a-zA-Z0-9 _.-]/g, "")
.replace(/\s+/g, " ")
.replace(/-+/g, "-")
.trim()
.replace(/^[.-]+/, "")
.slice(0, 50);
return sanitized || "assistant";
}
export function buildChatMarkdown(messages: unknown[], assistantNameRaw: string): string | null {
const assistantName = escapeHtmlInMarkdown(normalizeSingleLineLabel(assistantNameRaw));
const history = Array.isArray(messages) ? messages : [];
if (history.length === 0) {
return null;
}
const lines: string[] = [`# Chat with ${assistantName}`, ""];
for (const msg of history) {
const m = msg as Record<string, unknown>;
const role = m.role === "user" ? "You" : m.role === "assistant" ? assistantName : "Tool";
const content = escapeHtmlInMarkdown(
typeof m.content === "string"
? m.content
: Array.isArray(m.content)
? (m.content as Array<{ type?: string; text?: string }>)
.filter((b) => b?.type === "text" && typeof b.text === "string")
.map((b) => b.text)
.join("")
: "",
);
const ts = typeof m.timestamp === "number" ? new Date(m.timestamp).toISOString() : "";
lines.push(`## ${role}${ts ? ` (${ts})` : ""}`, "", content, "");
}
return lines.join("\n");
}
export function buildChatExportFilename(assistantNameRaw: string, now = Date.now()): string {
return `chat-${sanitizeFilenameComponent(assistantNameRaw)}-${now}.md`;
}
export function exportChatMarkdown(messages: unknown[], assistantName: string): void {
const markdown = buildChatMarkdown(messages, assistantName);
if (!markdown) {
return;
}
const blob = new Blob([markdown], { type: "text/markdown" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = buildChatExportFilename(assistantName);
link.click();
URL.revokeObjectURL(url);
}

View File

@@ -0,0 +1,49 @@
const MAX = 50;
export class InputHistory {
private items: string[] = [];
private cursor = -1;
push(text: string): void {
const trimmed = text.trim();
if (!trimmed) {
return;
}
if (this.items[this.items.length - 1] === trimmed) {
return;
}
this.items.push(trimmed);
if (this.items.length > MAX) {
this.items.shift();
}
this.cursor = -1;
}
up(): string | null {
if (this.items.length === 0) {
return null;
}
if (this.cursor < 0) {
this.cursor = this.items.length - 1;
} else if (this.cursor > 0) {
this.cursor--;
}
return this.items[this.cursor] ?? null;
}
down(): string | null {
if (this.cursor < 0) {
return null;
}
this.cursor++;
if (this.cursor >= this.items.length) {
this.cursor = -1;
return null;
}
return this.items[this.cursor] ?? null;
}
reset(): void {
this.cursor = -1;
}
}

View File

@@ -0,0 +1,61 @@
const PREFIX = "openclaw:pinned:";
export class PinnedMessages {
private key: string;
private _indices = new Set<number>();
constructor(sessionKey: string) {
this.key = PREFIX + sessionKey;
this.load();
}
get indices(): Set<number> {
return this._indices;
}
has(index: number): boolean {
return this._indices.has(index);
}
pin(index: number): void {
this._indices.add(index);
this.save();
}
unpin(index: number): void {
this._indices.delete(index);
this.save();
}
toggle(index: number): void {
if (this._indices.has(index)) {
this.unpin(index);
} else {
this.pin(index);
}
}
clear(): void {
this._indices.clear();
this.save();
}
private load(): void {
try {
const raw = localStorage.getItem(this.key);
if (!raw) {
return;
}
const arr = JSON.parse(raw);
if (Array.isArray(arr)) {
this._indices = new Set(arr.filter((n) => typeof n === "number"));
}
} catch {
// ignore
}
}
private save(): void {
localStorage.setItem(this.key, JSON.stringify([...this._indices]));
}
}

View File

@@ -0,0 +1,381 @@
import { describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../gateway.ts";
import type { GatewaySessionRow } from "../types.ts";
import { executeSlashCommand } from "./slash-command-executor.ts";
function row(key: string, overrides?: Partial<GatewaySessionRow>): GatewaySessionRow {
return {
key,
spawnedBy: overrides?.spawnedBy,
kind: "direct",
updatedAt: null,
...overrides,
};
}
describe("executeSlashCommand /kill", () => {
it("aborts every sub-agent session for /kill all", async () => {
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "sessions.list") {
return {
sessions: [
row("main"),
row("agent:main:subagent:one", { spawnedBy: "main" }),
row("agent:main:subagent:parent", { spawnedBy: "main" }),
row("agent:main:subagent:parent:subagent:child", {
spawnedBy: "agent:main:subagent:parent",
}),
row("agent:other:main"),
],
};
}
if (method === "chat.abort") {
return { ok: true, aborted: true };
}
throw new Error(`unexpected method: ${method}`);
});
const result = await executeSlashCommand(
{ request } as unknown as GatewayBrowserClient,
"agent:main:main",
"kill",
"all",
);
expect(result.content).toBe("Aborted 3 sub-agent sessions.");
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {});
expect(request).toHaveBeenNthCalledWith(2, "chat.abort", {
sessionKey: "agent:main:subagent:one",
});
expect(request).toHaveBeenNthCalledWith(3, "chat.abort", {
sessionKey: "agent:main:subagent:parent",
});
expect(request).toHaveBeenNthCalledWith(4, "chat.abort", {
sessionKey: "agent:main:subagent:parent:subagent:child",
});
});
it("aborts matching sub-agent sessions for /kill <agentId>", async () => {
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "sessions.list") {
return {
sessions: [
row("agent:main:subagent:one", { spawnedBy: "agent:main:main" }),
row("agent:main:subagent:two", { spawnedBy: "agent:main:main" }),
row("agent:other:subagent:three", { spawnedBy: "agent:other:main" }),
],
};
}
if (method === "chat.abort") {
return { ok: true, aborted: true };
}
throw new Error(`unexpected method: ${method}`);
});
const result = await executeSlashCommand(
{ request } as unknown as GatewayBrowserClient,
"agent:main:main",
"kill",
"main",
);
expect(result.content).toBe("Aborted 2 matching sub-agent sessions for `main`.");
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {});
expect(request).toHaveBeenNthCalledWith(2, "chat.abort", {
sessionKey: "agent:main:subagent:one",
});
expect(request).toHaveBeenNthCalledWith(3, "chat.abort", {
sessionKey: "agent:main:subagent:two",
});
});
it("does not exact-match a session key outside the current subagent subtree", async () => {
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "sessions.list") {
return {
sessions: [
row("agent:main:subagent:parent", { spawnedBy: "agent:main:main" }),
row("agent:main:subagent:parent:subagent:child", {
spawnedBy: "agent:main:subagent:parent",
}),
row("agent:main:subagent:sibling", { spawnedBy: "agent:main:main" }),
],
};
}
if (method === "chat.abort") {
return { ok: true, aborted: true };
}
throw new Error(`unexpected method: ${method}`);
});
const result = await executeSlashCommand(
{ request } as unknown as GatewayBrowserClient,
"agent:main:subagent:parent",
"kill",
"agent:main:subagent:sibling",
);
expect(result.content).toBe(
"No matching sub-agent sessions found for `agent:main:subagent:sibling`.",
);
expect(request).toHaveBeenCalledTimes(1);
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {});
});
it("returns a no-op summary when matching sessions have no active runs", async () => {
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "sessions.list") {
return {
sessions: [
row("agent:main:subagent:one", { spawnedBy: "agent:main:main" }),
row("agent:main:subagent:two", { spawnedBy: "agent:main:main" }),
],
};
}
if (method === "chat.abort") {
return { ok: true, aborted: false };
}
throw new Error(`unexpected method: ${method}`);
});
const result = await executeSlashCommand(
{ request } as unknown as GatewayBrowserClient,
"agent:main:main",
"kill",
"all",
);
expect(result.content).toBe("No active sub-agent runs to abort.");
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {});
expect(request).toHaveBeenNthCalledWith(2, "chat.abort", {
sessionKey: "agent:main:subagent:one",
});
expect(request).toHaveBeenNthCalledWith(3, "chat.abort", {
sessionKey: "agent:main:subagent:two",
});
});
it("treats the legacy main session key as the default agent scope", async () => {
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "sessions.list") {
return {
sessions: [
row("main"),
row("agent:main:subagent:one", { spawnedBy: "agent:main:main" }),
row("agent:main:subagent:two", { spawnedBy: "agent:main:main" }),
row("agent:other:subagent:three", { spawnedBy: "agent:other:main" }),
],
};
}
if (method === "chat.abort") {
return { ok: true, aborted: true };
}
throw new Error(`unexpected method: ${method}`);
});
const result = await executeSlashCommand(
{ request } as unknown as GatewayBrowserClient,
"main",
"kill",
"all",
);
expect(result.content).toBe("Aborted 2 sub-agent sessions.");
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {});
expect(request).toHaveBeenNthCalledWith(2, "chat.abort", {
sessionKey: "agent:main:subagent:one",
});
expect(request).toHaveBeenNthCalledWith(3, "chat.abort", {
sessionKey: "agent:main:subagent:two",
});
});
it("does not abort unrelated same-agent subagents from another root session", async () => {
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "sessions.list") {
return {
sessions: [
row("agent:main:main"),
row("agent:main:subagent:mine", { spawnedBy: "agent:main:main" }),
row("agent:main:subagent:mine:subagent:child", {
spawnedBy: "agent:main:subagent:mine",
}),
row("agent:main:subagent:other-root", {
spawnedBy: "agent:main:discord:dm:alice",
}),
],
};
}
if (method === "chat.abort") {
return { ok: true, aborted: true };
}
throw new Error(`unexpected method: ${method}`);
});
const result = await executeSlashCommand(
{ request } as unknown as GatewayBrowserClient,
"agent:main:main",
"kill",
"all",
);
expect(result.content).toBe("Aborted 2 sub-agent sessions.");
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {});
expect(request).toHaveBeenNthCalledWith(2, "chat.abort", {
sessionKey: "agent:main:subagent:mine",
});
expect(request).toHaveBeenNthCalledWith(3, "chat.abort", {
sessionKey: "agent:main:subagent:mine:subagent:child",
});
});
});
describe("executeSlashCommand directives", () => {
it("resolves the legacy main alias for bare /model", async () => {
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "sessions.list") {
return {
defaults: { model: "default-model" },
sessions: [
row("agent:main:main", {
model: "gpt-4.1-mini",
}),
],
};
}
if (method === "models.list") {
return {
models: [{ id: "gpt-4.1-mini" }, { id: "gpt-4.1" }],
};
}
throw new Error(`unexpected method: ${method}`);
});
const result = await executeSlashCommand(
{ request } as unknown as GatewayBrowserClient,
"main",
"model",
"",
);
expect(result.content).toBe(
"**Current model:** `gpt-4.1-mini`\n**Available:** `gpt-4.1-mini`, `gpt-4.1`",
);
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {});
expect(request).toHaveBeenNthCalledWith(2, "models.list", {});
});
it("resolves the legacy main alias for /usage", async () => {
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "sessions.list") {
return {
sessions: [
row("agent:main:main", {
model: "gpt-4.1-mini",
inputTokens: 1200,
outputTokens: 300,
totalTokens: 1500,
contextTokens: 4000,
}),
],
};
}
throw new Error(`unexpected method: ${method}`);
});
const result = await executeSlashCommand(
{ request } as unknown as GatewayBrowserClient,
"main",
"usage",
"",
);
expect(result.content).toBe(
"**Session Usage**\nInput: **1.2k** tokens\nOutput: **300** tokens\nTotal: **1.5k** tokens\nContext: **30%** of 4k\nModel: `gpt-4.1-mini`",
);
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {});
});
it("reports the current thinking level for bare /think", async () => {
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "sessions.list") {
return {
sessions: [
row("agent:main:main", {
modelProvider: "openai",
model: "gpt-4.1-mini",
}),
],
};
}
if (method === "models.list") {
return {
models: [{ id: "gpt-4.1-mini", provider: "openai", reasoning: true }],
};
}
throw new Error(`unexpected method: ${method}`);
});
const result = await executeSlashCommand(
{ request } as unknown as GatewayBrowserClient,
"agent:main:main",
"think",
"",
);
expect(result.content).toBe(
"Current thinking level: low.\nOptions: off, minimal, low, medium, high, adaptive.",
);
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {});
expect(request).toHaveBeenNthCalledWith(2, "models.list", {});
});
it("accepts minimal and xhigh thinking levels", async () => {
const request = vi.fn().mockResolvedValueOnce({ ok: true }).mockResolvedValueOnce({ ok: true });
const minimal = await executeSlashCommand(
{ request } as unknown as GatewayBrowserClient,
"agent:main:main",
"think",
"minimal",
);
const xhigh = await executeSlashCommand(
{ request } as unknown as GatewayBrowserClient,
"agent:main:main",
"think",
"xhigh",
);
expect(minimal.content).toBe("Thinking level set to **minimal**.");
expect(xhigh.content).toBe("Thinking level set to **xhigh**.");
expect(request).toHaveBeenNthCalledWith(1, "sessions.patch", {
key: "agent:main:main",
thinkingLevel: "minimal",
});
expect(request).toHaveBeenNthCalledWith(2, "sessions.patch", {
key: "agent:main:main",
thinkingLevel: "xhigh",
});
});
it("reports the current verbose level for bare /verbose", async () => {
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "sessions.list") {
return {
sessions: [row("agent:main:main", { verboseLevel: "full" })],
};
}
throw new Error(`unexpected method: ${method}`);
});
const result = await executeSlashCommand(
{ request } as unknown as GatewayBrowserClient,
"agent:main:main",
"verbose",
"",
);
expect(result.content).toBe("Current verbose level: full.\nOptions: on, full, off.");
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {});
});
});

View File

@@ -0,0 +1,545 @@
/**
* Client-side execution engine for slash commands.
* Calls gateway RPC methods and returns formatted results.
*/
import type { ModelCatalogEntry } from "../../../../src/agents/model-catalog.js";
import { resolveThinkingDefault } from "../../../../src/agents/model-selection.js";
import {
formatThinkingLevels,
normalizeThinkLevel,
normalizeVerboseLevel,
} from "../../../../src/auto-reply/thinking.js";
import type { HealthSummary } from "../../../../src/commands/health.js";
import type { OpenClawConfig } from "../../../../src/config/config.js";
import {
DEFAULT_AGENT_ID,
DEFAULT_MAIN_KEY,
isSubagentSessionKey,
parseAgentSessionKey,
} from "../../../../src/routing/session-key.js";
import type { GatewayBrowserClient } from "../gateway.ts";
import type { AgentsListResult, GatewaySessionRow, SessionsListResult } from "../types.ts";
import { SLASH_COMMANDS } from "./slash-commands.ts";
export type SlashCommandResult = {
/** Markdown-formatted result to display in chat. */
content: string;
/** Side-effect action the caller should perform after displaying the result. */
action?:
| "refresh"
| "export"
| "new-session"
| "reset"
| "stop"
| "clear"
| "toggle-focus"
| "navigate-usage";
};
export async function executeSlashCommand(
client: GatewayBrowserClient,
sessionKey: string,
commandName: string,
args: string,
): Promise<SlashCommandResult> {
switch (commandName) {
case "help":
return executeHelp();
case "status":
return await executeStatus(client);
case "new":
return { content: "Starting new session...", action: "new-session" };
case "reset":
return { content: "Resetting session...", action: "reset" };
case "stop":
return { content: "Stopping current run...", action: "stop" };
case "clear":
return { content: "Chat history cleared.", action: "clear" };
case "focus":
return { content: "Toggled focus mode.", action: "toggle-focus" };
case "compact":
return await executeCompact(client, sessionKey);
case "model":
return await executeModel(client, sessionKey, args);
case "think":
return await executeThink(client, sessionKey, args);
case "verbose":
return await executeVerbose(client, sessionKey, args);
case "export":
return { content: "Exporting session...", action: "export" };
case "usage":
return await executeUsage(client, sessionKey);
case "agents":
return await executeAgents(client);
case "kill":
return await executeKill(client, sessionKey, args);
default:
return { content: `Unknown command: \`/${commandName}\`` };
}
}
// ── Command Implementations ──
function executeHelp(): SlashCommandResult {
const lines = ["**Available Commands**\n"];
let currentCategory = "";
for (const cmd of SLASH_COMMANDS) {
const cat = cmd.category ?? "session";
if (cat !== currentCategory) {
currentCategory = cat;
lines.push(`**${cat.charAt(0).toUpperCase() + cat.slice(1)}**`);
}
const argStr = cmd.args ? ` ${cmd.args}` : "";
const local = cmd.executeLocal ? "" : " *(agent)*";
lines.push(`\`/${cmd.name}${argStr}\`${cmd.description}${local}`);
}
lines.push("\nType `/` to open the command menu.");
return { content: lines.join("\n") };
}
async function executeStatus(client: GatewayBrowserClient): Promise<SlashCommandResult> {
try {
const health = await client.request<HealthSummary>("health", {});
const status = health.ok ? "Healthy" : "Degraded";
const agentCount = health.agents?.length ?? 0;
const sessionCount = health.sessions?.count ?? 0;
const lines = [
`**System Status:** ${status}`,
`**Agents:** ${agentCount}`,
`**Sessions:** ${sessionCount}`,
`**Default Agent:** ${health.defaultAgentId || "none"}`,
];
if (health.durationMs) {
lines.push(`**Response:** ${health.durationMs}ms`);
}
return { content: lines.join("\n") };
} catch (err) {
return { content: `Failed to fetch status: ${String(err)}` };
}
}
async function executeCompact(
client: GatewayBrowserClient,
sessionKey: string,
): Promise<SlashCommandResult> {
try {
await client.request("sessions.compact", { key: sessionKey });
return { content: "Context compacted successfully.", action: "refresh" };
} catch (err) {
return { content: `Compaction failed: ${String(err)}` };
}
}
async function executeModel(
client: GatewayBrowserClient,
sessionKey: string,
args: string,
): Promise<SlashCommandResult> {
if (!args) {
try {
const [sessions, models] = await Promise.all([
client.request<SessionsListResult>("sessions.list", {}),
client.request<{ models: ModelCatalogEntry[] }>("models.list", {}),
]);
const session = resolveCurrentSession(sessions, sessionKey);
const model = session?.model || sessions?.defaults?.model || "default";
const available = models?.models?.map((m: ModelCatalogEntry) => m.id) ?? [];
const lines = [`**Current model:** \`${model}\``];
if (available.length > 0) {
lines.push(
`**Available:** ${available
.slice(0, 10)
.map((m: string) => `\`${m}\``)
.join(", ")}${available.length > 10 ? ` +${available.length - 10} more` : ""}`,
);
}
return { content: lines.join("\n") };
} catch (err) {
return { content: `Failed to get model info: ${String(err)}` };
}
}
try {
await client.request("sessions.patch", { key: sessionKey, model: args.trim() });
return { content: `Model set to \`${args.trim()}\`.`, action: "refresh" };
} catch (err) {
return { content: `Failed to set model: ${String(err)}` };
}
}
async function executeThink(
client: GatewayBrowserClient,
sessionKey: string,
args: string,
): Promise<SlashCommandResult> {
const rawLevel = args.trim();
if (!rawLevel) {
try {
const { session, models } = await loadThinkingCommandState(client, sessionKey);
return {
content: formatDirectiveOptions(
`Current thinking level: ${resolveCurrentThinkingLevel(session, models)}.`,
formatThinkingLevels(session?.modelProvider, session?.model),
),
};
} catch (err) {
return { content: `Failed to get thinking level: ${String(err)}` };
}
}
const level = normalizeThinkLevel(rawLevel);
if (!level) {
try {
const session = await loadCurrentSession(client, sessionKey);
return {
content: `Unrecognized thinking level "${rawLevel}". Valid levels: ${formatThinkingLevels(session?.modelProvider, session?.model)}.`,
};
} catch (err) {
return { content: `Failed to validate thinking level: ${String(err)}` };
}
}
try {
await client.request("sessions.patch", { key: sessionKey, thinkingLevel: level });
return {
content: `Thinking level set to **${level}**.`,
action: "refresh",
};
} catch (err) {
return { content: `Failed to set thinking level: ${String(err)}` };
}
}
async function executeVerbose(
client: GatewayBrowserClient,
sessionKey: string,
args: string,
): Promise<SlashCommandResult> {
const rawLevel = args.trim();
if (!rawLevel) {
try {
const session = await loadCurrentSession(client, sessionKey);
return {
content: formatDirectiveOptions(
`Current verbose level: ${normalizeVerboseLevel(session?.verboseLevel) ?? "off"}.`,
"on, full, off",
),
};
} catch (err) {
return { content: `Failed to get verbose level: ${String(err)}` };
}
}
const level = normalizeVerboseLevel(rawLevel);
if (!level) {
return {
content: `Unrecognized verbose level "${rawLevel}". Valid levels: off, on, full.`,
};
}
try {
await client.request("sessions.patch", { key: sessionKey, verboseLevel: level });
return {
content: `Verbose mode set to **${level}**.`,
action: "refresh",
};
} catch (err) {
return { content: `Failed to set verbose mode: ${String(err)}` };
}
}
async function executeUsage(
client: GatewayBrowserClient,
sessionKey: string,
): Promise<SlashCommandResult> {
try {
const sessions = await client.request<SessionsListResult>("sessions.list", {});
const session = resolveCurrentSession(sessions, sessionKey);
if (!session) {
return { content: "No active session." };
}
const input = session.inputTokens ?? 0;
const output = session.outputTokens ?? 0;
const total = session.totalTokens ?? input + output;
const ctx = session.contextTokens ?? 0;
const pct = ctx > 0 ? Math.round((input / ctx) * 100) : null;
const lines = [
"**Session Usage**",
`Input: **${fmtTokens(input)}** tokens`,
`Output: **${fmtTokens(output)}** tokens`,
`Total: **${fmtTokens(total)}** tokens`,
];
if (pct !== null) {
lines.push(`Context: **${pct}%** of ${fmtTokens(ctx)}`);
}
if (session.model) {
lines.push(`Model: \`${session.model}\``);
}
return { content: lines.join("\n") };
} catch (err) {
return { content: `Failed to get usage: ${String(err)}` };
}
}
async function executeAgents(client: GatewayBrowserClient): Promise<SlashCommandResult> {
try {
const result = await client.request<AgentsListResult>("agents.list", {});
const agents = result?.agents ?? [];
if (agents.length === 0) {
return { content: "No agents configured." };
}
const lines = [`**Agents** (${agents.length})\n`];
for (const agent of agents) {
const isDefault = agent.id === result?.defaultId;
const name = agent.identity?.name || agent.name || agent.id;
const marker = isDefault ? " *(default)*" : "";
lines.push(`- \`${agent.id}\`${name}${marker}`);
}
return { content: lines.join("\n") };
} catch (err) {
return { content: `Failed to list agents: ${String(err)}` };
}
}
async function executeKill(
client: GatewayBrowserClient,
sessionKey: string,
args: string,
): Promise<SlashCommandResult> {
const target = args.trim();
if (!target) {
return { content: "Usage: `/kill <id|all>`" };
}
try {
const sessions = await client.request<SessionsListResult>("sessions.list", {});
const matched = resolveKillTargets(sessions?.sessions ?? [], sessionKey, target);
if (matched.length === 0) {
return {
content:
target.toLowerCase() === "all"
? "No active sub-agent sessions found."
: `No matching sub-agent sessions found for \`${target}\`.`,
};
}
const results = await Promise.allSettled(
matched.map((key) =>
client.request<{ aborted?: boolean }>("chat.abort", { sessionKey: key }),
),
);
const rejected = results.filter((entry) => entry.status === "rejected");
const successCount = results.filter(
(entry) =>
entry.status === "fulfilled" && (entry.value as { aborted?: boolean })?.aborted !== false,
).length;
if (successCount === 0) {
if (rejected.length === 0) {
return {
content:
target.toLowerCase() === "all"
? "No active sub-agent runs to abort."
: `No active runs matched \`${target}\`.`,
};
}
throw rejected[0]?.reason ?? new Error("abort failed");
}
if (target.toLowerCase() === "all") {
return {
content:
successCount === matched.length
? `Aborted ${successCount} sub-agent session${successCount === 1 ? "" : "s"}.`
: `Aborted ${successCount} of ${matched.length} sub-agent sessions.`,
};
}
return {
content:
successCount === matched.length
? `Aborted ${successCount} matching sub-agent session${successCount === 1 ? "" : "s"} for \`${target}\`.`
: `Aborted ${successCount} of ${matched.length} matching sub-agent sessions for \`${target}\`.`,
};
} catch (err) {
return { content: `Failed to abort: ${String(err)}` };
}
}
function resolveKillTargets(
sessions: GatewaySessionRow[],
currentSessionKey: string,
target: string,
): string[] {
const normalizedTarget = target.trim().toLowerCase();
if (!normalizedTarget) {
return [];
}
const keys = new Set<string>();
const normalizedCurrentSessionKey = currentSessionKey.trim().toLowerCase();
const currentParsed = parseAgentSessionKey(normalizedCurrentSessionKey);
const currentAgentId =
currentParsed?.agentId ??
(normalizedCurrentSessionKey === DEFAULT_MAIN_KEY ? DEFAULT_AGENT_ID : undefined);
const sessionIndex = buildSessionIndex(sessions);
for (const session of sessions) {
const key = session?.key?.trim();
if (!key || !isSubagentSessionKey(key)) {
continue;
}
const normalizedKey = key.toLowerCase();
const parsed = parseAgentSessionKey(normalizedKey);
const belongsToCurrentSession = isWithinCurrentSessionSubtree(
normalizedKey,
normalizedCurrentSessionKey,
sessionIndex,
currentAgentId,
parsed?.agentId,
);
const isMatch =
(normalizedTarget === "all" && belongsToCurrentSession) ||
(belongsToCurrentSession && normalizedKey === normalizedTarget) ||
(belongsToCurrentSession &&
((parsed?.agentId ?? "") === normalizedTarget ||
normalizedKey.endsWith(`:subagent:${normalizedTarget}`) ||
normalizedKey === `subagent:${normalizedTarget}`));
if (isMatch) {
keys.add(key);
}
}
return [...keys];
}
function isWithinCurrentSessionSubtree(
candidateSessionKey: string,
currentSessionKey: string,
sessionIndex: Map<string, GatewaySessionRow>,
currentAgentId: string | undefined,
candidateAgentId: string | undefined,
): boolean {
if (!currentAgentId || candidateAgentId !== currentAgentId) {
return false;
}
const currentAliases = resolveEquivalentSessionKeys(currentSessionKey, currentAgentId);
const seen = new Set<string>();
let parentSessionKey = normalizeSessionKey(sessionIndex.get(candidateSessionKey)?.spawnedBy);
while (parentSessionKey && !seen.has(parentSessionKey)) {
if (currentAliases.has(parentSessionKey)) {
return true;
}
seen.add(parentSessionKey);
parentSessionKey = normalizeSessionKey(sessionIndex.get(parentSessionKey)?.spawnedBy);
}
// Older gateways may not include spawnedBy on session rows yet; keep prefix
// matching for nested subagent sessions as a compatibility fallback.
return isSubagentSessionKey(currentSessionKey)
? candidateSessionKey.startsWith(`${currentSessionKey}:subagent:`)
: false;
}
function buildSessionIndex(sessions: GatewaySessionRow[]): Map<string, GatewaySessionRow> {
const index = new Map<string, GatewaySessionRow>();
for (const session of sessions) {
const normalizedKey = normalizeSessionKey(session?.key);
if (!normalizedKey) {
continue;
}
index.set(normalizedKey, session);
}
return index;
}
function normalizeSessionKey(key?: string | null): string | undefined {
const normalized = key?.trim().toLowerCase();
return normalized || undefined;
}
function resolveEquivalentSessionKeys(
currentSessionKey: string,
currentAgentId: string | undefined,
): Set<string> {
const keys = new Set<string>([currentSessionKey]);
if (currentAgentId === DEFAULT_AGENT_ID) {
const canonicalDefaultMain = `agent:${DEFAULT_AGENT_ID}:main`;
if (currentSessionKey === DEFAULT_MAIN_KEY) {
keys.add(canonicalDefaultMain);
} else if (currentSessionKey === canonicalDefaultMain) {
keys.add(DEFAULT_MAIN_KEY);
}
}
return keys;
}
function formatDirectiveOptions(text: string, options: string): string {
return `${text}\nOptions: ${options}.`;
}
async function loadCurrentSession(
client: GatewayBrowserClient,
sessionKey: string,
): Promise<GatewaySessionRow | undefined> {
const sessions = await client.request<SessionsListResult>("sessions.list", {});
return resolveCurrentSession(sessions, sessionKey);
}
function resolveCurrentSession(
sessions: SessionsListResult | undefined,
sessionKey: string,
): GatewaySessionRow | undefined {
const normalizedSessionKey = normalizeSessionKey(sessionKey);
const currentAgentId =
parseAgentSessionKey(normalizedSessionKey ?? "")?.agentId ??
(normalizedSessionKey === DEFAULT_MAIN_KEY ? DEFAULT_AGENT_ID : undefined);
const aliases = normalizedSessionKey
? resolveEquivalentSessionKeys(normalizedSessionKey, currentAgentId)
: new Set<string>();
return sessions?.sessions?.find((session: GatewaySessionRow) => {
const key = normalizeSessionKey(session.key);
return key ? aliases.has(key) : false;
});
}
async function loadThinkingCommandState(client: GatewayBrowserClient, sessionKey: string) {
const [sessions, models] = await Promise.all([
client.request<SessionsListResult>("sessions.list", {}),
client.request<{ models: ModelCatalogEntry[] }>("models.list", {}),
]);
return {
session: resolveCurrentSession(sessions, sessionKey),
models: models?.models ?? [],
};
}
function resolveCurrentThinkingLevel(
session: GatewaySessionRow | undefined,
models: ModelCatalogEntry[],
): string {
const persisted = normalizeThinkLevel(session?.thinkingLevel);
if (persisted) {
return persisted;
}
if (!session?.modelProvider || !session.model) {
return "off";
}
return resolveThinkingDefault({
cfg: {} as OpenClawConfig,
provider: session.modelProvider,
model: session.model,
catalog: models,
});
}
function fmtTokens(n: number): string {
if (n >= 1_000_000) {
return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
}
if (n >= 1_000) {
return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
}
return String(n);
}

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { parseSlashCommand } from "./slash-commands.ts";
describe("parseSlashCommand", () => {
it("parses commands with an optional colon separator", () => {
expect(parseSlashCommand("/think: high")).toMatchObject({
command: { name: "think" },
args: "high",
});
expect(parseSlashCommand("/think:high")).toMatchObject({
command: { name: "think" },
args: "high",
});
expect(parseSlashCommand("/help:")).toMatchObject({
command: { name: "help" },
args: "",
});
});
it("still parses space-delimited commands", () => {
expect(parseSlashCommand("/verbose full")).toMatchObject({
command: { name: "verbose" },
args: "full",
});
});
});

View File

@@ -0,0 +1,222 @@
import type { IconName } from "../icons.ts";
export type SlashCommandCategory = "session" | "model" | "agents" | "tools";
export type SlashCommandDef = {
name: string;
description: string;
args?: string;
icon?: IconName;
category?: SlashCommandCategory;
/** When true, the command is executed client-side via RPC instead of sent to the agent. */
executeLocal?: boolean;
/** Fixed argument choices for inline hints. */
argOptions?: string[];
/** Keyboard shortcut hint shown in the menu (display only). */
shortcut?: string;
};
export const SLASH_COMMANDS: SlashCommandDef[] = [
// ── Session ──
{
name: "new",
description: "Start a new session",
icon: "circle",
category: "session",
executeLocal: true,
},
{
name: "reset",
description: "Reset current session",
icon: "loader",
category: "session",
executeLocal: true,
},
{
name: "compact",
description: "Compact session context",
icon: "loader",
category: "session",
executeLocal: true,
},
{
name: "stop",
description: "Stop current run",
icon: "x",
category: "session",
executeLocal: true,
},
{
name: "clear",
description: "Clear chat history",
icon: "x",
category: "session",
executeLocal: true,
},
{
name: "focus",
description: "Toggle focus mode",
icon: "search",
category: "session",
executeLocal: true,
},
// ── Model ──
{
name: "model",
description: "Show or set model",
args: "<name>",
icon: "brain",
category: "model",
executeLocal: true,
},
{
name: "think",
description: "Set thinking level",
args: "<level>",
icon: "brain",
category: "model",
executeLocal: true,
argOptions: ["off", "minimal", "low", "medium", "high", "xhigh", "adaptive"],
},
{
name: "verbose",
description: "Toggle verbose mode",
args: "<on|off|full>",
icon: "fileCode",
category: "model",
executeLocal: true,
argOptions: ["on", "off", "full"],
},
// ── Tools ──
{
name: "help",
description: "Show available commands",
icon: "book",
category: "tools",
executeLocal: true,
},
{
name: "status",
description: "Show system status",
icon: "barChart",
category: "tools",
executeLocal: true,
},
{
name: "export",
description: "Export session to Markdown",
icon: "arrowDown",
category: "tools",
executeLocal: true,
},
{
name: "usage",
description: "Show token usage",
icon: "barChart",
category: "tools",
executeLocal: true,
},
// ── Agents ──
{
name: "agents",
description: "List agents",
icon: "monitor",
category: "agents",
executeLocal: true,
},
{
name: "kill",
description: "Abort sub-agents",
args: "<id|all>",
icon: "x",
category: "agents",
executeLocal: true,
},
{
name: "skill",
description: "Run a skill",
args: "<name>",
icon: "zap",
category: "tools",
},
{
name: "steer",
description: "Steer a sub-agent",
args: "<id> <msg>",
icon: "zap",
category: "agents",
},
];
const CATEGORY_ORDER: SlashCommandCategory[] = ["session", "model", "tools", "agents"];
export const CATEGORY_LABELS: Record<SlashCommandCategory, string> = {
session: "Session",
model: "Model",
agents: "Agents",
tools: "Tools",
};
export function getSlashCommandCompletions(filter: string): SlashCommandDef[] {
const lower = filter.toLowerCase();
const commands = lower
? SLASH_COMMANDS.filter(
(cmd) => cmd.name.startsWith(lower) || cmd.description.toLowerCase().includes(lower),
)
: SLASH_COMMANDS;
return commands.toSorted((a, b) => {
const ai = CATEGORY_ORDER.indexOf(a.category ?? "session");
const bi = CATEGORY_ORDER.indexOf(b.category ?? "session");
if (ai !== bi) {
return ai - bi;
}
// Exact prefix matches first
if (lower) {
const aExact = a.name.startsWith(lower) ? 0 : 1;
const bExact = b.name.startsWith(lower) ? 0 : 1;
if (aExact !== bExact) {
return aExact - bExact;
}
}
return 0;
});
}
export type ParsedSlashCommand = {
command: SlashCommandDef;
args: string;
};
/**
* Parse a message as a slash command. Returns null if it doesn't match.
* Supports `/command`, `/command args...`, and `/command: args...`.
*/
export function parseSlashCommand(text: string): ParsedSlashCommand | null {
const trimmed = text.trim();
if (!trimmed.startsWith("/")) {
return null;
}
const body = trimmed.slice(1);
const firstSeparator = body.search(/[\s:]/u);
const name = firstSeparator === -1 ? body : body.slice(0, firstSeparator);
let remainder = firstSeparator === -1 ? "" : body.slice(firstSeparator).trimStart();
if (remainder.startsWith(":")) {
remainder = remainder.slice(1).trimStart();
}
const args = remainder.trim();
if (!name) {
return null;
}
const command = SLASH_COMMANDS.find((cmd) => cmd.name === name.toLowerCase());
if (!command) {
return null;
}
return { command, args };
}

225
ui/src/ui/chat/speech.ts Normal file
View File

@@ -0,0 +1,225 @@
/**
* Browser-native speech services: STT via SpeechRecognition, TTS via SpeechSynthesis.
* Falls back gracefully when APIs are unavailable.
*/
// ─── STT (Speech-to-Text) ───
type SpeechRecognitionEvent = Event & {
results: SpeechRecognitionResultList;
resultIndex: number;
};
type SpeechRecognitionErrorEvent = Event & {
error: string;
message?: string;
};
interface SpeechRecognitionInstance extends EventTarget {
continuous: boolean;
interimResults: boolean;
lang: string;
start(): void;
stop(): void;
abort(): void;
onresult: ((event: SpeechRecognitionEvent) => void) | null;
onerror: ((event: SpeechRecognitionErrorEvent) => void) | null;
onend: (() => void) | null;
onstart: (() => void) | null;
}
type SpeechRecognitionCtor = new () => SpeechRecognitionInstance;
function getSpeechRecognitionCtor(): SpeechRecognitionCtor | null {
const w = globalThis as Record<string, unknown>;
return (w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null) as SpeechRecognitionCtor | null;
}
export function isSttSupported(): boolean {
return getSpeechRecognitionCtor() !== null;
}
export type SttCallbacks = {
onTranscript: (text: string, isFinal: boolean) => void;
onStart?: () => void;
onEnd?: () => void;
onError?: (error: string) => void;
};
let activeRecognition: SpeechRecognitionInstance | null = null;
export function startStt(callbacks: SttCallbacks): boolean {
const Ctor = getSpeechRecognitionCtor();
if (!Ctor) {
callbacks.onError?.("Speech recognition is not supported in this browser");
return false;
}
stopStt();
const recognition = new Ctor();
recognition.continuous = true;
recognition.interimResults = true;
recognition.lang = navigator.language || "en-US";
recognition.addEventListener("start", () => callbacks.onStart?.());
recognition.addEventListener("result", (event) => {
const speechEvent = event as unknown as SpeechRecognitionEvent;
let interimTranscript = "";
let finalTranscript = "";
for (let i = speechEvent.resultIndex; i < speechEvent.results.length; i++) {
const result = speechEvent.results[i];
if (!result?.[0]) {
continue;
}
const transcript = result[0].transcript;
if (result.isFinal) {
finalTranscript += transcript;
} else {
interimTranscript += transcript;
}
}
if (finalTranscript) {
callbacks.onTranscript(finalTranscript, true);
} else if (interimTranscript) {
callbacks.onTranscript(interimTranscript, false);
}
});
recognition.addEventListener("error", (event) => {
const speechEvent = event as unknown as SpeechRecognitionErrorEvent;
if (speechEvent.error === "aborted" || speechEvent.error === "no-speech") {
return;
}
callbacks.onError?.(speechEvent.error);
});
recognition.addEventListener("end", () => {
if (activeRecognition === recognition) {
activeRecognition = null;
}
callbacks.onEnd?.();
});
activeRecognition = recognition;
recognition.start();
return true;
}
export function stopStt(): void {
if (activeRecognition) {
const r = activeRecognition;
activeRecognition = null;
try {
r.stop();
} catch {
// already stopped
}
}
}
export function isSttActive(): boolean {
return activeRecognition !== null;
}
// ─── TTS (Text-to-Speech) ───
export function isTtsSupported(): boolean {
return "speechSynthesis" in globalThis;
}
let currentUtterance: SpeechSynthesisUtterance | null = null;
export function speakText(
text: string,
opts?: {
onStart?: () => void;
onEnd?: () => void;
onError?: (error: string) => void;
},
): boolean {
if (!isTtsSupported()) {
opts?.onError?.("Speech synthesis is not supported in this browser");
return false;
}
stopTts();
const cleaned = stripMarkdown(text);
if (!cleaned.trim()) {
return false;
}
const utterance = new SpeechSynthesisUtterance(cleaned);
utterance.rate = 1.0;
utterance.pitch = 1.0;
utterance.addEventListener("start", () => opts?.onStart?.());
utterance.addEventListener("end", () => {
if (currentUtterance === utterance) {
currentUtterance = null;
}
opts?.onEnd?.();
});
utterance.addEventListener("error", (e) => {
if (currentUtterance === utterance) {
currentUtterance = null;
}
if (e.error === "canceled" || e.error === "interrupted") {
return;
}
opts?.onError?.(e.error);
});
currentUtterance = utterance;
speechSynthesis.speak(utterance);
return true;
}
export function stopTts(): void {
if (currentUtterance) {
currentUtterance = null;
}
if (isTtsSupported()) {
speechSynthesis.cancel();
}
}
export function isTtsSpeaking(): boolean {
return isTtsSupported() && speechSynthesis.speaking;
}
/** Strip common markdown syntax for cleaner speech output. */
function stripMarkdown(text: string): string {
return (
text
// code blocks
.replace(/```[\s\S]*?```/g, "")
// inline code
.replace(/`[^`]+`/g, "")
// images
.replace(/!\[.*?\]\(.*?\)/g, "")
// links → keep text
.replace(/\[([^\]]+)\]\(.*?\)/g, "$1")
// headings
.replace(/^#{1,6}\s+/gm, "")
// bold/italic
.replace(/\*{1,3}(.*?)\*{1,3}/g, "$1")
.replace(/_{1,3}(.*?)_{1,3}/g, "$1")
// blockquotes
.replace(/^>\s?/gm, "")
// horizontal rules
.replace(/^[-*_]{3,}\s*$/gm, "")
// list markers
.replace(/^\s*[-*+]\s+/gm, "")
.replace(/^\s*\d+\.\s+/gm, "")
// HTML tags
.replace(/<[^>]+>/g, "")
// collapse whitespace
.replace(/\n{3,}/g, "\n\n")
.trim()
);
}

View File

@@ -395,6 +395,7 @@ export type AgentsFilesSetResult = {
export type GatewaySessionRow = {
key: string;
spawnedBy?: string;
kind: "direct" | "group" | "global" | "unknown";
label?: string;
displayName?: string;