Merge branch 'feat/ink-refactor' of github.com:NousResearch/hermes-agent into feat/ink-refactor

This commit is contained in:
Brooklyn Nicholson 2026-04-12 13:19:07 -05:00
commit 690d62a6d1
4 changed files with 107 additions and 44 deletions

View file

@ -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):

View file

@ -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:

View file

@ -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 ──────────────────────────────────────────────────────

View file

@ -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)