diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 5f56fdb9c4..ebd13f54b9 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -898,6 +898,34 @@ class SlashCommandCompleter(Completer): except Exception: pass + @staticmethod + def _personality_completions(sub_text: str, sub_lower: str): + """Yield completions for /personality from configured personalities.""" + try: + from hermes_cli.config import load_config + personalities = load_config().get("agent", {}).get("personalities", {}) + if "none".startswith(sub_lower) and "none" != sub_lower: + yield Completion( + "none", + start_position=-len(sub_text), + display="none", + display_meta="clear personality overlay", + ) + for name, prompt in personalities.items(): + if name.startswith(sub_lower) and name != sub_lower: + if isinstance(prompt, dict): + meta = prompt.get("description") or prompt.get("system_prompt", "")[:50] + else: + meta = str(prompt)[:50] + yield Completion( + name, + start_position=-len(sub_text), + display=name, + display_meta=meta, + ) + except Exception: + pass + def _model_completions(self, sub_text: str, sub_lower: str): """Yield completions for /model from config aliases + built-in aliases.""" seen = set() @@ -960,6 +988,9 @@ class SlashCommandCompleter(Completer): if base_cmd == "/skin": yield from self._skin_completions(sub_text, sub_lower) return + if base_cmd == "/personality": + yield from self._personality_completions(sub_text, sub_lower) + return # Static subcommand completions if " " not in sub_text and base_cmd in SUBCOMMANDS and self._command_allowed(base_cmd): diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 8d58df25c7..1121211d9c 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -502,10 +502,35 @@ def _wire_callbacks(sid: str): set_secret_capture_callback(secret_cb) +def _resolve_personality_prompt(cfg: dict) -> str: + """Resolve the active personality into a system prompt string.""" + name = (cfg.get("display", {}).get("personality", "") or "").strip().lower() + if not name or name in ("default", "none", "neutral"): + return "" + try: + from hermes_cli.config import load_config as _load_full_cfg + personalities = _load_full_cfg().get("agent", {}).get("personalities", {}) + except Exception: + personalities = cfg.get("agent", {}).get("personalities", {}) + pval = personalities.get(name) + if pval is None: + return "" + if isinstance(pval, dict): + parts = [pval.get("system_prompt", "")] + if pval.get("tone"): + parts.append(f'Tone: {pval["tone"]}') + if pval.get("style"): + parts.append(f'Style: {pval["style"]}') + return "\n".join(p for p in parts if p) + return str(pval) + + def _make_agent(sid: str, key: str, session_id: str | None = None): from run_agent import AIAgent cfg = _load_cfg() system_prompt = cfg.get("agent", {}).get("system_prompt", "") or "" + if not system_prompt: + system_prompt = _resolve_personality_prompt(cfg) return AIAgent( model=_resolve_model(), quiet_mode=True, @@ -1218,16 +1243,36 @@ def _(rid, params: dict) -> dict: else: cfg["custom_prompt"] = value nv = value + _save_cfg(cfg) elif key == "personality": - cfg.setdefault("display", {})["personality"] = value if value not in ("none", "default", "neutral") else "" + pname = value if value not in ("none", "default", "neutral") else "" + _write_config_key("display.personality", pname) + cfg = _load_cfg() + new_prompt = _resolve_personality_prompt(cfg) + _write_config_key("agent.system_prompt", new_prompt) nv = value + sid_key = params.get("session_id", "") + if session: + try: + new_agent = _make_agent(sid_key, session["session_key"], session_id=session["session_key"]) + session["agent"] = new_agent + with session["history_lock"]: + session["history"] = [] + session["history_version"] = int(session.get("history_version", 0)) + 1 + except Exception: + if session.get("agent"): + agent = session["agent"] + agent.ephemeral_system_prompt = new_prompt or None + agent._cached_system_prompt = None else: - cfg.setdefault("display", {})[key] = value + _write_config_key(f"display.{key}", value) nv = value - _save_cfg(cfg) - if key == "skin": - _emit("skin.changed", "", resolve_skin()) - return _ok(rid, {"key": key, "value": nv}) + if key == "skin": + _emit("skin.changed", "", resolve_skin()) + resp = {"key": key, "value": nv} + if key == "personality": + resp["cleared"] = True + return _ok(rid, resp) except Exception as e: return _err(rid, 5001, str(e)) @@ -1255,6 +1300,8 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"prompt": _load_cfg().get("custom_prompt", "")}) if key == "skin": return _ok(rid, {"value": _load_cfg().get("display", {}).get("skin", "default")}) + if key == "personality": + return _ok(rid, {"value": _load_cfg().get("display", {}).get("personality", "default")}) if key == "mtime": cfg_path = _hermes_home / "config.yaml" try: diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 35f1448a15..ee317662af 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -1037,9 +1037,13 @@ export function App({ gw }: { gw: GatewayClient }) { return } - if (completions.length && input && historyIdx === null && (key.upArrow || key.downArrow)) { + if (completions.length && input && (key.upArrow || key.downArrow)) { setCompIdx(i => (key.upArrow ? (i - 1 + completions.length) % completions.length : (i + 1) % completions.length)) + if (historyIdx !== null) { + setHistoryIdx(null) + } + return } @@ -1839,13 +1843,16 @@ export function App({ gw }: { gw: GatewayClient }) { case 'personality': if (arg) { - rpc('config.set', { key: 'personality', value: arg }).then((r: any) => - sys(`personality: ${r.value || 'default'}`) - ) + rpc('config.set', { session_id: sid, key: 'personality', value: arg }).then((r: any) => { + if (r?.cleared) { + setMessages([]) + setHistoryItems([]) + } + + sys(`personality → ${r?.value}`) + }) } else { - gw.request('slash.exec', { command: 'personality', session_id: sid }) - .then((r: any) => panel('Personality', [{ text: r?.output || '(no output)' }])) - .catch(() => sys('personality command failed')) + rpc('config.get', { key: 'personality' }).then((r: any) => sys(`personality: ${r?.value || 'default'}`)) } return true @@ -2273,6 +2280,11 @@ export function App({ gw }: { gw: GatewayClient }) { const submit = useCallback( (value: string) => { + if (completions.length && completions[compIdx]) { + value = value.slice(0, compReplace) + completions[compIdx].text + setInput(value) + } + if (!value.trim() && !inputBuf.length) { const now = Date.now() const dbl = now - lastEmptyAt.current < 450 @@ -2330,7 +2342,7 @@ export function App({ gw }: { gw: GatewayClient }) { dispatchSubmission([...inputBuf, value].join('\n')) }, - [dequeue, dispatchSubmission, inputBuf, sid] + [compIdx, compReplace, completions, dequeue, dispatchSubmission, inputBuf, sid] ) // ── Derived ────────────────────────────────────────────────────── diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index ff4f08b00b..ec87ec4f31 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -9,7 +9,7 @@ type InkExt = typeof Ink & { } const ink = Ink as unknown as InkExt -const { Box, Text, useStdin, useInput, stringWidth, useDeclaredCursor, useTerminalFocus } = ink +const { Box, Text, useInput, stringWidth, useDeclaredCursor, useTerminalFocus } = ink // ── ANSI escapes ───────────────────────────────────────────────────── @@ -18,7 +18,6 @@ const INV = `${ESC}[7m` const INV_OFF = `${ESC}[27m` const DIM = `${ESC}[2m` const DIM_OFF = `${ESC}[22m` -const FWD_DEL_RE = new RegExp(`${ESC}\\[3(?:[~$^]|;)`) const PRINTABLE = /^[ -~\u00a0-\uffff]+$/ const BRACKET_PASTE = new RegExp(`${ESC}?\\[20[01]~`, 'g') @@ -122,31 +121,6 @@ function renderWithCursor(value: string, cursor: number) { return done ? out : out + invert(' ') } -// ── Forward-delete detection hook ──────────────────────────────────── - -function useFwdDelete(active: boolean) { - const ref = useRef(false) - const { inputEmitter: ee } = useStdin() - - useEffect(() => { - if (!active) { - return - } - - const h = (d: string) => { - ref.current = FWD_DEL_RE.test(d) - } - - ee.prependListener('input', h) - - return () => { - ee.removeListener('input', h) - } - }, [active, ee]) - - return ref -} - // ── Types ──────────────────────────────────────────────────────────── export interface PasteEvent { @@ -171,7 +145,6 @@ interface Props { export function TextInput({ columns = 80, value, onChange, onPaste, onSubmit, placeholder = '', focus = true }: Props) { const [cur, setCur] = useState(value.length) - const fwdDel = useFwdDelete(focus) const termFocus = useTerminalFocus() const curRef = useRef(cur) @@ -364,7 +337,7 @@ export function TextInput({ columns = 80, value, onChange, onPaste, onSubmit, pl } // Deletion - else if ((k.backspace || k.delete) && !fwdDel.current && c > 0) { + else if (k.backspace && c > 0) { if (mod) { const t = wordLeft(v, c) v = v.slice(0, t) + v.slice(c) @@ -373,7 +346,7 @@ export function TextInput({ columns = 80, value, onChange, onPaste, onSubmit, pl v = v.slice(0, c - 1) + v.slice(c) c-- } - } else if (k.delete && fwdDel.current && c < v.length) { + } else if (k.delete && c < v.length) { if (mod) { const t = wordRight(v, c) v = v.slice(0, c) + v.slice(t)