/**
* useSlashCommands — slash command handler extracted from ChatApp
*
* All consumers should import from ChatApp (re-export facade).
*/
import { useCallback } from "react";
import { execSync } from "child_process";
import type Anthropic from "@anthropic-ai/sdk";
import {
setModel, getModel, getModelShortName,
loadClaudeMd, getSessionTokens, resetSessionState,
saveSession, loadSession, listSessions,
addMemory, removeMemory, listMemories,
setPermissionMode, getPermissionMode, type PermissionMode,
getServerStatus, mcpClientManager,
} from "../../services/agent-loop.js";
import { setConversationId } from "../../services/telemetry.js";
import { getAllServerToolDefinitions, resetServerToolClient } from "../../services/server-tools.js";
import { LOCAL_TOOL_DEFINITIONS, loadTodos, setTodoSessionId } from "../../services/local-tools.js";
import { loadAgentDefinitions } from "../../services/agent-definitions.js";
import { SLASH_COMMANDS } from "../ChatInput.js";
import { MODEL_OPTIONS } from "../ModelSelector.js";
import { symbols } from "../../shared/Theme.js";
import { loadKeybindings } from "../../services/keybinding-manager.js";
import { loadConfig, updateConfig } from "../../services/config-store.js";
import { getStoresForUser, getValidToken, selectStore, type StoreInfo } from "../../services/auth-service.js";
import { getServerToolCount } from "../../services/agent-loop.js";
import type { ChatMessage } from "../MessageList.js";
export interface SlashCommandDeps {
exit: () => void;
toolsExpanded: boolean;
serverToolsAvailable: number;
sessionId: string | null;
thinkingEnabled: boolean;
conversationRef: React.MutableRefObject<Anthropic.MessageParam[]>;
// State setters
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>;
setStreamingText: React.Dispatch<React.SetStateAction<string>>;
setActiveTools: React.Dispatch<React.SetStateAction<any[]>>;
setTeamState: React.Dispatch<React.SetStateAction<any>>;
setStoreList: React.Dispatch<React.SetStateAction<StoreInfo[]>>;
setStoreSelectMode: React.Dispatch<React.SetStateAction<boolean>>;
setModelSelectMode: React.Dispatch<React.SetStateAction<boolean>>;
setCurrentModel: React.Dispatch<React.SetStateAction<string>>;
setSessionId: React.Dispatch<React.SetStateAction<string | null>>;
setThinkingEnabled: React.Dispatch<React.SetStateAction<boolean>>;
setUserLabel: React.Dispatch<React.SetStateAction<string>>;
setServerToolsAvailable: React.Dispatch<React.SetStateAction<number>>;
// Rewind
setShowRewind: React.Dispatch<React.SetStateAction<boolean>>;
rewindCheckpointCount: number;
// Constants
PKG_NAME: string;
PKG_VERSION: string;
}
export function useSlashCommands(deps: SlashCommandDeps) {
const {
exit, toolsExpanded, serverToolsAvailable, sessionId, thinkingEnabled,
conversationRef,
setMessages, setStreamingText, setActiveTools, setTeamState,
setStoreList, setStoreSelectMode, setModelSelectMode, setCurrentModel,
setSessionId, setThinkingEnabled, setUserLabel, setServerToolsAvailable,
setShowRewind, rewindCheckpointCount,
PKG_NAME, PKG_VERSION,
} = deps;
const handleCommand = useCallback(async (command: string) => {
const parts = command.trim().split(/\s+/);
const cmd = parts[0];
const args = parts.slice(1).join(" ");
switch (cmd) {
case "/help": {
const helpLines = SLASH_COMMANDS
.map((c) => ` ${c.name.padEnd(14)}${c.description}`)
.join("\n");
const kb = loadKeybindings();
setMessages((prev) => [...prev, {
role: "assistant",
text: helpLines + `\n\n ${kb.exit} exit ${kb.cancel_stream} cancel ${kb.toggle_expand} expand tools`,
}]);
break;
}
case "/clear":
setMessages([]);
setStreamingText("");
setActiveTools([]);
setTeamState(null);
conversationRef.current = [];
resetSessionState();
break;
case "/exit":
exit();
break;
case "/status": {
const config = loadConfig();
const localCount = LOCAL_TOOL_DEFINITIONS.length;
const toolsLine = serverToolsAvailable > 0
? ` tools ${localCount} local + ${serverToolsAvailable} server`
: ` tools ${localCount} local`;
const claudeMd = loadClaudeMd();
const tokens = getSessionTokens();
const tokenLine = tokens.input > 0
? ` tokens ${(tokens.input / 1000).toFixed(1)}K in / ${(tokens.output / 1000).toFixed(1)}K out`
: ` tokens (no usage yet)`;
const lines = [
` version v${PKG_VERSION}`,
` user ${config.email || "—"}`,
` store ${config.store_name || "—"}`,
` model ${getModelShortName()} (${getModel()})`,
` output 16384 max tokens`,
toolsLine,
tokenLine,
` session ${sessionId || "(unsaved)"}`,
` CLAUDE.md ${claudeMd ? claudeMd.path : "not found"}`,
` mode ${getPermissionMode()}`,
` thinking ${thinkingEnabled ? "on" : "off"} (^T)`,
` context ${conversationRef.current.length} messages`,
` expand ${toolsExpanded ? "on" : "off"} (^E)`,
];
setMessages((prev) => [...prev, { role: "assistant", text: lines.join("\n") }]);
break;
}
case "/mcp": {
getServerStatus().then((status) => {
const lines: string[] = [];
if (status.connected) {
lines.push(` Server tools:`);
lines.push(` ● Connected (${status.authMethod === "service_role" ? "service role" : "user JWT"})`);
lines.push(` store ${status.storeName || status.storeId || "—"}`);
lines.push(` tools ${status.toolCount} active`);
lines.push("");
const serverDefs = getAllServerToolDefinitions();
for (const t of serverDefs) {
lines.push(` ${t.name.padEnd(20)} ${(t.description || "").slice(0, 45)}`);
}
} else {
lines.push(` Server tools:`);
lines.push(` ○ Disconnected — run: whale login`);
}
const mcpStatus = mcpClientManager.getStatus();
if (mcpStatus.length > 0) {
lines.push("");
lines.push(` External MCP servers:`);
for (const s of mcpStatus) {
lines.push(` ● ${s.name} (${s.toolCount} tool${s.toolCount !== 1 ? "s" : ""})`);
}
}
setMessages((prev) => [...prev, { role: "assistant", text: lines.join("\n") }]);
});
break;
}
case "/store": {
getValidToken().then(async (token) => {
if (!token) {
setMessages((prev) => [...prev, { role: "assistant", text: " Not logged in. Run: whale login" }]);
return;
}
const config = loadConfig();
const stores = await getStoresForUser(token, config.user_id || "");
if (stores.length === 0) {
setMessages((prev) => [...prev, { role: "assistant", text: " No stores found for this account." }]);
} else if (stores.length === 1) {
setMessages((prev) => [...prev, { role: "assistant", text: ` Only one store: ${stores[0].name}` }]);
} else {
setStoreList(stores);
setStoreSelectMode(true);
}
});
break;
}
case "/update": {
setMessages((prev) => [...prev, { role: "assistant", text: ` Checking for updates...` }]);
try {
const latest = execSync(`npm view ${PKG_NAME} version 2>/dev/null`, { encoding: "utf-8" }).trim();
if (latest === PKG_VERSION) {
setMessages((prev) => [...prev, { role: "assistant", text: ` ${symbols.check} Already on latest v${PKG_VERSION}` }]);
} else {
setMessages((prev) => [...prev, { role: "assistant", text: ` v${PKG_VERSION} → v${latest} Installing...` }]);
try {
execSync(`npm install -g ${PKG_NAME}@latest 2>&1`, { encoding: "utf-8", timeout: 30000 });
setMessages((prev) => [...prev, { role: "assistant", text: ` ${symbols.check} Updated to v${latest}\n Restart whale to use the new version.` }]);
} catch {
setMessages((prev) => [...prev, { role: "assistant", text: ` ${symbols.cross} Install failed. Try manually:\n npm install -g ${PKG_NAME}@latest` }]);
}
}
} catch {
setMessages((prev) => [...prev, { role: "assistant", text: ` ${symbols.cross} Could not check npm. Are you online?` }]);
}
break;
}
case "/model": {
const match = args && MODEL_OPTIONS.find((m) => m.value === args);
if (match) {
setModel(match.value);
setCurrentModel(match.value);
setMessages((prev) => [...prev, {
role: "assistant",
text: ` ${symbols.check} Model: ${match.label} (${match.modelId})`,
}]);
} else {
setModelSelectMode(true);
}
break;
}
case "/compact": {
setMessages((prev) => [...prev, {
role: "assistant",
text: ` Context management is now handled server-side by the Anthropic API. Compaction fires automatically when context exceeds 150K tokens.`,
}]);
break;
}
case "/save": {
if (conversationRef.current.length === 0) {
setMessages((prev) => [...prev, { role: "assistant", text: " Nothing to save." }]);
} else {
const id = saveSession(conversationRef.current, sessionId || undefined);
setSessionId(id);
setTodoSessionId(id);
setMessages((prev) => [...prev, {
role: "assistant",
text: ` ${symbols.check} Session saved: ${id}`,
}]);
}
break;
}
case "/sessions": {
const sessions = listSessions();
if (sessions.length === 0) {
setMessages((prev) => [...prev, { role: "assistant", text: " No saved sessions." }]);
} else {
const lines = sessions.map((s, i) =>
` ${String(i + 1).padStart(2)}. ${s.title.slice(0, 40).padEnd(42)} ${s.messageCount} msgs ${s.updatedAt.slice(0, 10)}`
);
setMessages((prev) => [...prev, {
role: "assistant",
text: ` Saved sessions:\n${lines.join("\n")}\n\n Use /resume to load a session.`,
}]);
}
break;
}
case "/resume": {
const sessions = listSessions();
if (sessions.length === 0) {
setMessages((prev) => [...prev, { role: "assistant", text: " No saved sessions." }]);
} else {
const latest = sessions[0];
const loaded = loadSession(latest.id);
if (loaded) {
conversationRef.current = loaded.messages;
setSessionId(latest.id);
setConversationId(latest.id);
setTodoSessionId(latest.id);
loadTodos(latest.id);
if (loaded.meta.model) setModel(loaded.meta.model);
setMessages((prev) => [...prev, {
role: "assistant",
text: ` ${symbols.check} Resumed: ${latest.title}\n ${latest.messageCount} messages, model: ${getModelShortName()}`,
}]);
} else {
setMessages((prev) => [...prev, { role: "assistant", text: " Failed to load session." }]);
}
}
break;
}
case "/agents": {
const builtIn = ["explore", "plan", "general-purpose", "research"];
const custom = loadAgentDefinitions();
const lines: string[] = [];
lines.push(" Built-in:");
for (const a of builtIn) lines.push(` ${a.padEnd(20)} (built-in)`);
if (custom.length > 0) {
lines.push("");
lines.push(" Custom:");
for (const a of custom) {
lines.push(` ${a.name.padEnd(20)} ${a.description || `(${a.source})`}`);
}
} else {
lines.push("");
lines.push(" No custom agents. Add .md files to .whale/agents/ or ~/.swagmanager/agents/");
}
setMessages((prev) => [...prev, { role: "assistant", text: lines.join("\n") }]);
break;
}
case "/remember": {
if (!args) {
setMessages((prev) => [...prev, { role: "assistant", text: " Usage: /remember <fact to remember>" }]);
} else {
const result = addMemory(args);
setMessages((prev) => [...prev, {
role: "assistant",
text: ` ${result.success ? symbols.check : symbols.cross} ${result.message}`,
}]);
}
break;
}
case "/forget": {
if (!args) {
setMessages((prev) => [...prev, { role: "assistant", text: " Usage: /forget <pattern to match>" }]);
} else {
const result = removeMemory(args);
setMessages((prev) => [...prev, {
role: "assistant",
text: ` ${result.success ? symbols.check : symbols.cross} ${result.message}`,
}]);
}
break;
}
case "/memory": {
const memories = listMemories();
if (memories.length === 0) {
setMessages((prev) => [...prev, { role: "assistant", text: " No memories stored. Use /remember <fact> to add one." }]);
} else {
const lines = memories.map((m, i) => ` ${i + 1}. ${m}`);
setMessages((prev) => [...prev, {
role: "assistant",
text: ` ${memories.length} remembered fact${memories.length !== 1 ? "s" : ""}:\n${lines.join("\n")}`,
}]);
}
break;
}
case "/mode": {
const modeDesc: Record<PermissionMode, string> = {
default: "all tools, normal operation",
plan: "read-only tools only (no writes, no commands)",
yolo: "all tools, no confirmation",
};
const modes: PermissionMode[] = ["default", "plan", "yolo"];
if (args && modes.includes(args as PermissionMode)) {
setPermissionMode(args as PermissionMode);
setMessages((prev) => [...prev, {
role: "assistant",
text: ` ${symbols.check} Mode: ${args} (${modeDesc[args as PermissionMode]})`,
}]);
} else {
const current = getPermissionMode();
const nextIdx = (modes.indexOf(current) + 1) % modes.length;
const next = modes[nextIdx];
setPermissionMode(next);
setMessages((prev) => [...prev, {
role: "assistant",
text: ` ${symbols.check} Mode: ${next} (${modeDesc[next]})`,
}]);
}
break;
}
case "/thinking": {
setThinkingEnabled(prev => {
const next = !prev;
try { updateConfig({ thinking_enabled: next }); } catch { /* best effort */ }
setMessages(msgs => [...msgs, {
role: "assistant",
text: ` Thinking: ${next ? "on" : "off"}`,
}]);
return next;
});
break;
}
case "/init": {
const { runInitInline } = await import("../../commands/init.js");
const result = await runInitInline();
setMessages(prev => [...prev, { role: "assistant", text: result }]);
break;
}
case "/rewind": {
if (rewindCheckpointCount === 0) {
setMessages((prev) => [...prev, { role: "assistant", text: " Nothing to rewind to." }]);
} else {
setShowRewind(true);
}
break;
}
case "/tools": {
const lines: string[] = [];
lines.push(` Local (${LOCAL_TOOL_DEFINITIONS.length})`);
for (const t of LOCAL_TOOL_DEFINITIONS) {
lines.push(` ${t.name.padEnd(20)} ${t.description.slice(0, 48)}`);
}
lines.push("");
if (serverToolsAvailable > 0) {
lines.push(` Server (${serverToolsAvailable})`);
const serverDefs = getAllServerToolDefinitions();
for (const t of serverDefs) {
lines.push(` ${t.name.padEnd(20)} ${(t.description || "").slice(0, 48)}`);
}
} else {
lines.push(" Server (unavailable — /mcp for details)");
}
setMessages((prev) => [...prev, { role: "assistant", text: lines.join("\n") }]);
break;
}
}
}, [exit, toolsExpanded, serverToolsAvailable, sessionId, thinkingEnabled, rewindCheckpointCount]);
// Store select handlers
const handleStoreSelect = useCallback((store: StoreInfo) => {
selectStore(store.id, store.name);
resetServerToolClient();
setStoreSelectMode(false);
setStoreList([]);
setUserLabel(store.name);
setMessages((prev) => [...prev, {
role: "assistant",
text: ` ${symbols.check} Switched to ${store.name}`,
}]);
getServerToolCount().then((count) => setServerToolsAvailable(count));
}, []);
const handleStoreCancel = useCallback(() => {
setStoreSelectMode(false);
setStoreList([]);
}, []);
return { handleCommand, handleStoreSelect, handleStoreCancel };
}