Add dashboard Hermes console UI
This commit is contained in:
parent
4493bba901
commit
f7d90edd8b
4 changed files with 769 additions and 3 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
538
web/src/components/HermesConsoleModal.tsx
Normal file
538
web/src/components/HermesConsoleModal.tsx
Normal file
|
|
@ -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<HTMLDivElement | null>(null);
|
||||
const termRef = useRef<XtermTerminal | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const lineRef = useRef("");
|
||||
const promptRef = useRef("hermes> ");
|
||||
const inputPromptRef = useRef("hermes> ");
|
||||
const historyRef = useRef<string[]>([]);
|
||||
const historyIndexRef = useRef<number | null>(null);
|
||||
const activeCommandRef = useRef(false);
|
||||
const pendingCommandRef = useRef<string | null>(null);
|
||||
const hasReadyFrameRef = useRef(false);
|
||||
const [connectionState, setConnectionState] =
|
||||
useState<ConnectionState>("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<string, unknown>) => {
|
||||
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(
|
||||
<div
|
||||
ref={modalRef}
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-3 sm:p-4"
|
||||
onClick={(event) => event.target === event.currentTarget && onClose()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="hermes-console-title"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
themedBody,
|
||||
"relative flex h-[min(82dvh,760px)] w-full max-w-5xl flex-col border border-border bg-card shadow-2xl",
|
||||
)}
|
||||
>
|
||||
<header className="flex min-h-14 items-center gap-3 border-b border-border px-4 py-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center border border-border bg-background/60 text-primary">
|
||||
<Terminal className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2
|
||||
id="hermes-console-title"
|
||||
className="font-mondwest text-display text-base tracking-wider"
|
||||
>
|
||||
Hermes Console
|
||||
</h2>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge tone={statusTone}>{connectionState}</Badge>
|
||||
<span className="font-mono">{consoleContext}</span>
|
||||
<span className="font-mono">{consoleProfile}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
aria-label="Close console"
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</header>
|
||||
<div className="min-h-0 flex-1 bg-black">
|
||||
<div
|
||||
ref={hostRef}
|
||||
className="h-full min-h-0 w-full overflow-hidden p-2 [&_.xterm]:h-full [&_.xterm-viewport]:!bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string | null>(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}
|
||||
/>
|
||||
<HermesConsoleModal
|
||||
open={consoleOpen}
|
||||
onClose={() => setConsoleOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Create-hook modal */}
|
||||
{hookModalOpen && (
|
||||
|
|
@ -1162,6 +1168,9 @@ export default function SystemPage() {
|
|||
</H2>
|
||||
<Card>
|
||||
<CardContent className="flex flex-wrap gap-2 py-4">
|
||||
<Button size="sm" ghost prefix={<Terminal className="h-3.5 w-3.5" />} onClick={() => setConsoleOpen(true)}>
|
||||
Open console
|
||||
</Button>
|
||||
<Button size="sm" ghost prefix={<Stethoscope className="h-3.5 w-3.5" />} onClick={() => runOp(api.runDoctor, "Doctor")}>
|
||||
Run doctor
|
||||
</Button>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue