diff --git a/hermes_cli/console_engine.py b/hermes_cli/console_engine.py index e89ed2bc2..00e26300a 100644 --- a/hermes_cli/console_engine.py +++ b/hermes_cli/console_engine.py @@ -13,6 +13,7 @@ import importlib import difflib import io import json +import re import shlex import sys from dataclasses import dataclass, replace @@ -72,6 +73,51 @@ def _capture_output(fn: Callable[[], object]) -> str: return text.rstrip() +_ANSI_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") + + +def _strip_ansi(text: str) -> str: + return _ANSI_RE.sub("", text) + + +def _is_status_footer_rule(line: str) -> bool: + stripped = _strip_ansi(line).strip() + if len(stripped) < 8: + return False + normalized = stripped.replace("\u2500", "-") + return set(normalized) <= {"-"} + + +def _strip_console_status_footer(text: str) -> str: + lines = text.splitlines() + while lines and not _strip_ansi(lines[-1]).strip(): + lines.pop() + if len(lines) < 2: + return text.rstrip() + + last = _strip_ansi(lines[-1]).strip() + prev = _strip_ansi(lines[-2]).strip() + if not ( + prev.startswith("Run 'hermes doctor'") + and last.startswith("Run 'hermes setup'") + ): + return text.rstrip() + + lines = lines[:-2] + while lines and not _strip_ansi(lines[-1]).strip(): + lines.pop() + if lines and _is_status_footer_rule(lines[-1]): + lines.pop() + return "\n".join(lines).rstrip() + + +def _table_summary(summary: str, *, limit: int = 76) -> str: + summary = " ".join(summary.split()) + if len(summary) <= limit: + return summary + return f"{summary[: limit - 3].rstrip()}..." + + def _split_line(line: str) -> list[str]: try: return shlex.split(line, comments=False, posix=True) @@ -199,6 +245,112 @@ def _parser_root() -> tuple[_ArgumentParser, argparse._SubParsersAction]: return parser, subparsers +def _subparser_actions(parser: argparse.ArgumentParser) -> list[argparse._SubParsersAction]: + return [ + action + for action in parser._actions + if isinstance(action, argparse._SubParsersAction) + ] + + +def _choice_help(action: argparse._SubParsersAction, name: str) -> str: + for choice in action._choices_actions: + if getattr(choice, "dest", None) == name or getattr(choice, "metavar", None) == name: + help_text = getattr(choice, "help", None) + if help_text and help_text is not argparse.SUPPRESS: + return str(help_text) + return "" + + +def _clean_summary(text: str | None) -> str: + if not text: + return "" + if text is argparse.SUPPRESS: + return "" + summary = " ".join(str(text).split()) + if not summary: + return "" + if summary.startswith("Run `hermes "): + return "" + return summary + + +def _summaries_from_parser(parser: argparse.ArgumentParser) -> dict[tuple[str, ...], str]: + summaries: dict[tuple[str, ...], str] = {} + + def walk(current: argparse.ArgumentParser, path: tuple[str, ...]) -> None: + for action in _subparser_actions(current): + for name, child in action.choices.items(): + child_path = (*path, name) + summary = _clean_summary(_choice_help(action, name)) or _clean_summary( + child.description + ) + if summary: + summaries.setdefault(child_path, summary) + walk(child, child_path) + + walk(parser, ()) + return summaries + + +def _noop_console_command(_args: argparse.Namespace) -> None: + return None + + +def _extracted_summaries( + module_name: str, + builder_name: str, + main_handler_name: str, +) -> dict[tuple[str, ...], str]: + try: + parser, subparsers = _parser_root() + module = importlib.import_module(module_name) + builder = getattr(module, builder_name) + builder(subparsers, **{main_handler_name: _noop_console_command}) + return _summaries_from_parser(parser) + except Exception: + return {} + + +def _registered_summaries( + root: str, + module_name: str, + register_name: str, +) -> dict[tuple[str, ...], str]: + try: + parser, subparsers = _parser_root() + module = importlib.import_module(module_name) + top_parser = subparsers.add_parser(root) + register = getattr(module, register_name) + register(top_parser) + return _summaries_from_parser(parser) + except Exception: + return {} + + +def _builder_summaries( + module_name: str, + builder_name: str, +) -> dict[tuple[str, ...], str]: + try: + parser, subparsers = _parser_root() + module = importlib.import_module(module_name) + getattr(module, builder_name)(subparsers) + return _summaries_from_parser(parser) + except Exception: + return {} + + +def _adder_summaries(module_name: str, add_name: str) -> dict[tuple[str, ...], str]: + try: + parser, subparsers = _parser_root() + module = importlib.import_module(module_name) + getattr(module, add_name)(subparsers) + return _summaries_from_parser(parser) + except Exception: + return {} + + def _invoke_namespace(args: argparse.Namespace) -> object: func = getattr(args, "func", None) if not callable(func): @@ -399,6 +551,7 @@ def _register_command_family( mutating: Iterable[Sequence[str]] = (), hosted: Iterable[Sequence[str]] = (), summary: str = "", + summaries: dict[tuple[str, ...], str] | None = None, confirmation: str = "", ) -> None: mutating_paths = {tuple(path) for path in mutating} @@ -407,10 +560,11 @@ def _register_command_family( child_key = tuple(child_path) full_path = (root, *tuple(child_path)) usage = " ".join(full_path) + command_summary = summary or (summaries or {}).get(full_path) or f"Run `hermes {usage}`." engine.register( full_path, usage, - summary or f"Run `hermes {usage}`.", + command_summary, handler_factory(tuple(child_path)), mutating=child_key in mutating_paths, confirmation=confirmation or f"Run `hermes {usage}`?", @@ -485,7 +639,7 @@ class HermesConsoleEngine: if self.context not in command.contexts: continue marker = " *" if command.mutating else " " - lines.append(f"{marker} {command.usage:<32} {command.summary}") + lines.append(f"{marker} {command.usage:<32} {_table_summary(command.summary)}") lines.extend( [ "", @@ -790,11 +944,13 @@ class HermesConsoleEngine: } for root, (module, builder, main_handler, paths, mutating) in extracted.items(): + summaries = _extracted_summaries(module, builder, main_handler) _register_command_family( self, root=root, paths=paths, mutating=mutating, + summaries=summaries, handler_factory=lambda fixed, root=root, module=module, builder=builder, main_handler=main_handler: _extracted_handler( root, fixed, @@ -866,6 +1022,7 @@ class HermesConsoleEngine: self, root="portal", paths=portal_paths, + summaries=_adder_summaries("hermes_cli.portal_cli", "add_parser"), handler_factory=lambda fixed: _adder_handler( "portal", fixed, @@ -890,6 +1047,7 @@ class HermesConsoleEngine: ("restore",), ("bind-board",), ], + summaries=_builder_summaries("hermes_cli.projects_cmd", "build_parser"), mutating=[ ("create",), ("add-folder",), @@ -946,6 +1104,7 @@ class HermesConsoleEngine: ("assignments",), ("context",), ], + summaries=_builder_summaries("hermes_cli.kanban", "build_parser"), mutating=[ ("init",), ("boards", "create"), @@ -1033,11 +1192,13 @@ class HermesConsoleEngine: ), } for root, (module, register, handler_name, paths, mutating) in registered.items(): + summaries = _registered_summaries(root, module, register) _register_command_family( self, root=root, paths=paths, mutating=mutating, + summaries=summaries, handler_factory=lambda fixed, root=root, module=module, register=register, handler_name=handler_name: _registered_handler( root, fixed, @@ -1349,7 +1510,8 @@ def _status(_engine: HermesConsoleEngine, args: list[str]) -> str: from hermes_cli.status import show_status - return _capture_output(lambda: show_status(SimpleNamespace(all=False, deep=False))) + output = _capture_output(lambda: show_status(SimpleNamespace(all=False, deep=False))) + return _strip_console_status_footer(output) def _doctor(_engine: HermesConsoleEngine, args: list[str]) -> str: diff --git a/tests/hermes_cli/test_console_engine.py b/tests/hermes_cli/test_console_engine.py index 9f9a835e1..59056d2b2 100644 --- a/tests/hermes_cli/test_console_engine.py +++ b/tests/hermes_cli/test_console_engine.py @@ -243,6 +243,63 @@ def test_console_parses_bare_and_hermes_prefixed_commands(_isolate_hermes_home): assert bare.output.endswith("config.yaml") +def test_console_status_hides_cli_next_step_footer( + monkeypatch: pytest.MonkeyPatch, + _isolate_hermes_home, +): + import hermes_cli.status as status_mod + + def fake_show_status(_args): + print("◆ Sessions") + print("Active: 3 session(s)") + print() + rule = "\u2500" * 60 + print(f"\x1b[2m{rule}\x1b[0m") + print("\x1b[2m Run 'hermes doctor' for detailed diagnostics\x1b[0m") + print("\x1b[2m Run 'hermes setup' to configure\x1b[0m") + print() + + monkeypatch.setattr(status_mod, "show_status", fake_show_status) + + result = HermesConsoleEngine().execute("status") + + assert result.status == "ok" + assert "Sessions" in result.output + assert "Active: 3 session(s)" in result.output + assert "hermes doctor" not in result.output + assert "hermes setup" not in result.output + assert "\u2500" not in result.output + + +def test_console_help_uses_cli_subcommand_summaries(): + help_text = HermesConsoleEngine().help_text() + + assert "skills list" in help_text + assert "List installed skills" in help_text + assert "Show all tools and their enabled/disabled status" in help_text + assert "Remove an MCP server" in help_text + assert "Check pet setup + terminal graphics support" in help_text + assert "Run `hermes skills list`" not in help_text + assert "Run `hermes tools list`" not in help_text + + +def test_console_help_table_keeps_long_summaries_compact(): + help_text = HermesConsoleEngine().help_text() + + slack_line = next( + line for line in help_text.splitlines() if line.strip().startswith("slack manifest") + ) + + assert len(slack_line) <= 112 + assert slack_line.endswith("...") + + +def test_console_help_for_command_uses_cli_summary(): + help_text = HermesConsoleEngine().help_text("skills list") + + assert help_text == "skills list\nList installed skills" + + def test_console_registry_covers_non_admin_cli_surface(): registered = set(HermesConsoleEngine().commands) diff --git a/web/src/components/HermesConsoleModal.tsx b/web/src/components/HermesConsoleModal.tsx new file mode 100644 index 000000000..fd63b38b8 --- /dev/null +++ b/web/src/components/HermesConsoleModal.tsx @@ -0,0 +1,538 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { FitAddon } from "@xterm/addon-fit"; +import { Unicode11Addon } from "@xterm/addon-unicode11"; +import { WebLinksAddon } from "@xterm/addon-web-links"; +import { Terminal as XtermTerminal } from "@xterm/xterm"; +import "@xterm/xterm/css/xterm.css"; +import { Terminal, X } from "lucide-react"; +import { Badge } from "@nous-research/ui/ui/components/badge"; +import { Button } from "@nous-research/ui/ui/components/button"; +import { useModalBehavior } from "@/hooks/useModalBehavior"; +import { useProfileScope } from "@/contexts/useProfileScope"; +import { api } from "@/lib/api"; +import { cn, themedBody } from "@/lib/utils"; +import { useTheme } from "@/themes"; + +type ConsoleFrame = + | { + type: "ready"; + context?: string; + profile?: string; + prompt?: string; + } + | { + type: "output"; + data?: string; + stream?: string; + } + | { + type: "error"; + message?: string; + } + | { + type: "confirm_required"; + command?: string; + message?: string; + prompt?: string; + } + | { + type: "complete"; + status?: string; + prompt?: string; + } + | { + type: "clear"; + } + | { + type: "pong"; + }; + +type ConnectionState = "connecting" | "ready" | "running" | "closed" | "error"; + +interface HermesConsoleModalProps { + open: boolean; + onClose: () => void; +} + +function buildTerminalTheme(background: string, foreground: string) { + return { + background, + foreground, + cursor: foreground, + cursorAccent: background, + selectionBackground: "rgba(255, 255, 255, 0.25)", + black: "#000000", + red: "#ff5f67", + green: "#5fffb0", + yellow: "#ffd166", + blue: "#7aa2ff", + magenta: "#d597ff", + cyan: "#58e6ff", + white: foreground, + brightBlack: "#666666", + brightRed: "#ff8b90", + brightGreen: "#8dffc8", + brightYellow: "#ffe08a", + brightBlue: "#9dbaff", + brightMagenta: "#e4b7ff", + brightCyan: "#8ef0ff", + brightWhite: "#ffffff", + }; +} + +function normalizeTerminalText(text: string): string { + return text.replace(/\r?\n/g, "\r\n"); +} + +function writeLine(term: XtermTerminal, text = ""): void { + term.write(`${normalizeTerminalText(text)}\r\n`); +} + +function writeBlock(term: XtermTerminal, text: string): void { + const normalized = normalizeTerminalText(text); + term.write(normalized.endsWith("\r\n") ? normalized : `${normalized}\r\n`); +} + +function isPrintable(data: string): boolean { + return data >= " " || data === "\t"; +} + +export function HermesConsoleModal({ open, onClose }: HermesConsoleModalProps) { + const modalRef = useModalBehavior({ open, onClose }); + const hostRef = useRef(null); + const termRef = useRef(null); + const wsRef = useRef(null); + const lineRef = useRef(""); + const promptRef = useRef("hermes> "); + const inputPromptRef = useRef("hermes> "); + const historyRef = useRef([]); + const historyIndexRef = useRef(null); + const activeCommandRef = useRef(false); + const pendingCommandRef = useRef(null); + const hasReadyFrameRef = useRef(false); + const [connectionState, setConnectionState] = + useState("connecting"); + const [consoleContext, setConsoleContext] = useState("pending"); + const [consoleProfile, setConsoleProfile] = useState("current"); + const { profile } = useProfileScope(); + const { theme } = useTheme(); + + const redrawInput = useCallback((line = lineRef.current) => { + const term = termRef.current; + if (!term) return; + lineRef.current = line; + term.write(`\r\x1b[2K${inputPromptRef.current}${line}`); + }, []); + + const showPrompt = useCallback(() => { + const term = termRef.current; + if (!term) return; + lineRef.current = ""; + historyIndexRef.current = null; + inputPromptRef.current = promptRef.current; + term.write(inputPromptRef.current); + }, []); + + const sendFrame = useCallback((payload: Record) => { + const ws = wsRef.current; + if (!ws || ws.readyState !== WebSocket.OPEN) return false; + ws.send(JSON.stringify(payload)); + return true; + }, []); + + const cancelCommand = useCallback(() => { + pendingCommandRef.current = null; + activeCommandRef.current = false; + sendFrame({ type: "cancel" }); + }, [sendFrame]); + + const submitLine = useCallback( + (rawLine: string) => { + const term = termRef.current; + if (!term) return; + const line = rawLine.trim(); + term.write("\r\n"); + lineRef.current = ""; + historyIndexRef.current = null; + + const pending = pendingCommandRef.current; + if (pending) { + const answer = line.toLowerCase(); + if (answer === "y" || answer === "yes") { + pendingCommandRef.current = null; + activeCommandRef.current = true; + setConnectionState("running"); + sendFrame({ type: "confirm", command: pending }); + return; + } + cancelCommand(); + return; + } + + if (!line) { + showPrompt(); + return; + } + + historyRef.current = [...historyRef.current, line].slice(-200); + activeCommandRef.current = true; + setConnectionState("running"); + if (!sendFrame({ type: "input", line })) { + activeCommandRef.current = false; + writeLine(term, "\x1b[31mConsole is not connected.\x1b[0m"); + showPrompt(); + } + }, + [cancelCommand, sendFrame, showPrompt], + ); + + const recallHistory = useCallback( + (direction: -1 | 1) => { + const history = historyRef.current; + if (!history.length) return; + const current = historyIndexRef.current; + if (current === null) { + if (direction > 0) return; + historyIndexRef.current = history.length - 1; + } else { + const next = current + direction; + if (next < 0) historyIndexRef.current = 0; + else if (next >= history.length) { + historyIndexRef.current = null; + redrawInput(""); + return; + } else { + historyIndexRef.current = next; + } + } + const idx = historyIndexRef.current; + redrawInput(idx === null ? "" : history[idx] ?? ""); + }, + [redrawInput], + ); + + const handleInputData = useCallback( + (data: string) => { + const term = termRef.current; + if (!term) return; + + if (data === "\x1b[A") { + recallHistory(-1); + return; + } + if (data === "\x1b[B") { + recallHistory(1); + return; + } + + for (const ch of data) { + if (ch === "\u0003") { + term.write("^C\r\n"); + if (activeCommandRef.current || pendingCommandRef.current) { + cancelCommand(); + } else { + showPrompt(); + } + continue; + } + if (ch === "\u000c") { + term.clear(); + showPrompt(); + continue; + } + if (activeCommandRef.current) { + term.write("\x07"); + continue; + } + if (ch === "\r" || ch === "\n") { + submitLine(lineRef.current); + continue; + } + if (ch === "\u007f" || ch === "\b") { + if (lineRef.current.length > 0) { + lineRef.current = lineRef.current.slice(0, -1); + term.write("\b \b"); + } + continue; + } + if (ch === "\x1b") { + continue; + } + if (isPrintable(ch)) { + lineRef.current += ch; + term.write(ch); + } + } + }, + [cancelCommand, recallHistory, showPrompt, submitLine], + ); + + const handleFrame = useCallback( + (frame: ConsoleFrame) => { + const term = termRef.current; + if (!term) return; + + if (frame.type === "ready") { + const nextPrompt = frame.prompt || "hermes> "; + promptRef.current = nextPrompt; + inputPromptRef.current = nextPrompt; + hasReadyFrameRef.current = true; + setConsoleContext(frame.context || "local"); + setConsoleProfile(frame.profile || "current"); + activeCommandRef.current = false; + setConnectionState("ready"); + term.clear(); + showPrompt(); + return; + } + + if (frame.type === "output") { + if (frame.data) writeBlock(term, frame.data); + return; + } + + if (frame.type === "error") { + writeLine(term, `\x1b[31m${frame.message || "Command failed."}\x1b[0m`); + return; + } + + if (frame.type === "confirm_required") { + pendingCommandRef.current = frame.command || ""; + activeCommandRef.current = false; + setConnectionState("ready"); + if (frame.message) { + writeLine(term, `\x1b[33m${frame.message}\x1b[0m`); + } + inputPromptRef.current = "Confirm? [y/N] "; + lineRef.current = ""; + term.write(inputPromptRef.current); + return; + } + + if (frame.type === "complete") { + activeCommandRef.current = false; + if (frame.prompt) promptRef.current = frame.prompt; + if (frame.status === "confirm_required") return; + if (frame.status === "exit") { + setConnectionState("closed"); + wsRef.current?.close(); + return; + } + if (frame.status === "timeout") { + writeLine(term, "\x1b[31mCommand timed out.\x1b[0m"); + } + if (frame.status === "cancelled") { + writeLine(term, "\x1b[33mCancelled.\x1b[0m"); + } + pendingCommandRef.current = null; + setConnectionState("ready"); + showPrompt(); + return; + } + + if (frame.type === "clear") { + term.clear(); + showPrompt(); + } + }, + [showPrompt], + ); + + useEffect(() => { + if (!open) return; + const host = hostRef.current; + if (!host) return; + + let cancelled = false; + let resizeFrame = 0; + const term = new XtermTerminal({ + allowProposedApi: true, + cursorBlink: true, + fontFamily: + "'JetBrains Mono', 'Cascadia Mono', 'Fira Code', 'MesloLGS NF', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace", + fontSize: 13, + lineHeight: 1.25, + letterSpacing: 0, + macOptionIsMeta: true, + scrollback: 3000, + theme: buildTerminalTheme( + theme.terminalBackground ?? "#000000", + theme.terminalForeground ?? "#f0e6d2", + ), + }); + termRef.current = term; + + const fit = new FitAddon(); + term.loadAddon(fit); + const unicode11 = new Unicode11Addon(); + term.loadAddon(unicode11); + term.unicode.activeVersion = "11"; + term.loadAddon(new WebLinksAddon()); + term.open(host); + term.focus(); + + const fitTerminal = () => { + if (!host.isConnected || host.clientWidth <= 0 || host.clientHeight <= 0) { + return; + } + try { + fit.fit(); + } catch { + /* fit can fail while the modal is closing */ + } + }; + const scheduleFit = () => { + if (resizeFrame) return; + resizeFrame = requestAnimationFrame(() => { + resizeFrame = 0; + fitTerminal(); + }); + }; + const ro = new ResizeObserver(scheduleFit); + ro.observe(host); + scheduleFit(); + + const dataDisposable = term.onData(handleInputData); + setConnectionState("connecting"); + setConsoleContext("pending"); + setConsoleProfile(profile || "current"); + hasReadyFrameRef.current = false; + writeLine(term, "\x1b[2mConnecting to Hermes Console...\x1b[0m"); + + void (async () => { + try { + const params = profile ? { profile } : undefined; + const url = await api.buildWsUrl("/api/console", params); + if (cancelled) return; + const ws = new WebSocket(url); + wsRef.current = ws; + + ws.onopen = () => { + setConnectionState("connecting"); + }; + + ws.onmessage = (ev) => { + try { + const frame = JSON.parse(String(ev.data)) as ConsoleFrame; + handleFrame(frame); + } catch { + writeLine(term, "\x1b[31mMalformed console frame.\x1b[0m"); + } + }; + + ws.onerror = () => { + setConnectionState("error"); + writeLine(term, "\x1b[31mConsole websocket error.\x1b[0m"); + }; + + ws.onclose = (ev) => { + wsRef.current = null; + activeCommandRef.current = false; + pendingCommandRef.current = null; + if (cancelled) return; + setConnectionState(ev.code === 1000 ? "closed" : "error"); + const reason = ev.reason ? ` ${ev.reason}` : ""; + const message = + ev.code === 1006 && !hasReadyFrameRef.current + ? "Console connection failed before the server handshake. Check that this dashboard is connected to a backend with /api/console." + : `Console closed (${ev.code}).${reason}`; + writeLine(term, `\x1b[31m${message}\x1b[0m`); + }; + } catch (err) { + if (cancelled) return; + setConnectionState("error"); + writeLine(term, `\x1b[31mConsole unavailable: ${err}\x1b[0m`); + } + })(); + + return () => { + cancelled = true; + dataDisposable.dispose(); + ro.disconnect(); + if (resizeFrame) cancelAnimationFrame(resizeFrame); + wsRef.current?.close(); + wsRef.current = null; + term.dispose(); + termRef.current = null; + lineRef.current = ""; + pendingCommandRef.current = null; + activeCommandRef.current = false; + hasReadyFrameRef.current = false; + }; + }, [handleFrame, handleInputData, open, profile, theme]); + + useEffect(() => { + if (!open) return; + const term = termRef.current; + if (!term) return; + term.options.theme = buildTerminalTheme( + theme.terminalBackground ?? "#000000", + theme.terminalForeground ?? "#f0e6d2", + ); + }, [open, theme]); + + if (!open) return null; + + const statusTone = + connectionState === "ready" + ? "success" + : connectionState === "running" + ? "warning" + : connectionState === "connecting" + ? "secondary" + : "destructive"; + + return createPortal( +
event.target === event.currentTarget && onClose()} + role="dialog" + aria-modal="true" + aria-labelledby="hermes-console-title" + > +
+
+
+ +
+
+

+ Hermes Console +

+
+ {connectionState} + {consoleContext} + {consoleProfile} +
+
+ +
+
+
+
+
+
, + document.body, + ); +} diff --git a/web/src/pages/SystemPage.tsx b/web/src/pages/SystemPage.tsx index 043933abe..82aed6b2b 100644 --- a/web/src/pages/SystemPage.tsx +++ b/web/src/pages/SystemPage.tsx @@ -42,6 +42,7 @@ import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete"; import { ConfirmDialog } from "@nous-research/ui/ui/components/confirm-dialog"; import { useModalBehavior } from "@/hooks/useModalBehavior"; import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog"; +import { HermesConsoleModal } from "@/components/HermesConsoleModal"; import { cn, themedBody } from "@/lib/utils"; import { api } from "@/lib/api"; import type { @@ -186,6 +187,7 @@ export default function SystemPage() { const [loading, setLoading] = useState(true); const [activeAction, setActiveAction] = useState(null); + const [consoleOpen, setConsoleOpen] = useState(false); // Add-credential form. const [credProvider, setCredProvider] = useState("openrouter"); @@ -680,6 +682,10 @@ export default function SystemPage() { description="Remove this hook from config and revoke its consent? It stops firing on the next restart." loading={hookDelete.isDeleting} /> + setConsoleOpen(false)} + /> {/* Create-hook modal */} {hookModalOpen && ( @@ -1162,6 +1168,9 @@ export default function SystemPage() { +