feat(cli): set terminal tab title to "<persona>: <session title>" - #10013
feat(cli): set terminal tab title to "<persona>: <session title>"#10013tsemana wants to merge 5 commits into
Conversation
069b9e2 to
631cd9b
Compare
Emit OSC 0 (tab/window title) and OSC 7 (working directory) from interactive chat sessions so terminals like Warp, iTerm2, Kitty, WezTerm, and Ghostty display a useful, agent-aware label per tab — matching the format other agentic CLIs (Claude Code, Codex) use. Format: "<Persona>: <session title>" — e.g. "Helm: Plan Hermes agent console with Superset UI features". Persona is resolved from a config key (agent.persona_name), then the SOUL.md "Name:" line, then the first markdown heading, then "Hermes". Session title comes from the existing SessionDB.get_session_title(...) call. Behaviour: - No-op when stdout/stderr aren't TTYs (pipes, captured output) - No-op when TERM is unset/dumb - No-op when HERMES_DISABLE_TAB_TITLE=1 (set automatically in ACP mode) - Restores cleared title on normal interpreter exit (atexit) - Sanitises control characters and truncates to 120 chars - Wrapped in try/except at every call site so chat is never broken by emission failures ACP mode is explicitly disabled in acp_adapter/__main__.py because the controlling editor (Zed/VS Code/Warp ACP client) draws its own chrome and stdout is reserved for JSON-RPC frames. Tests: 16 unit tests in tests/cli/test_terminal_title.py covering sanitisation, formatting, persona resolution, and the disable paths.
The previous commit hooked terminal_title.update_for_session at three spots in cli.py (session init, deferred pending-title apply, /title rename) but missed the most common code path: the background daemon thread in agent/title_generator.py that auto-generates a descriptive title from the first user/assistant exchange. Without this hook, a fresh session's tab shows just '<persona>' (no title) until the user happens to do something that re-fires another hooked path. Now the tab refreshes the moment the auto-title lands in SQLite. The new call is a no-op in non-CLI contexts (gateway, ACP server, web dashboard) thanks to the module's TTY / TERM / HERMES_DISABLE_TAB_TITLE guards.
631cd9b to
32a6692
Compare
teknium1
left a comment
There was a problem hiding this comment.
Thanks for the terminal-title work. Current main already has a related TUI implementation: ui-tui/src/app/useMainApp.ts:584 emits an OSC 0 title containing the live session title, model, and cwd; this was added by d33965396.
Problems
hermes_cli/terminal_title.py:204does not implement the advertisedagent.persona_namebehavior.update_for_session()callsresolve_persona_name()withoutconfig_value; the helper only honors that setting when it is supplied by its caller.- The PR also adds the unrelated LifeOS context engine (
plugins/context_engine/lifeos/__init__.py, 949 lines) and changesrun_agent.pyto inject prefetched context each turn. Please keep a terminal-title PR scoped to terminal-title behavior. hermes_cli/terminal_title.py:52opens/dev/ttybefore any stdio-TTY check, so redirected stdout/stderr can still produce OSC output through a controlling terminal, contrary to the stated no-op contract.
Suggested changes
- Split the LifeOS/context-engine commits into a separate contribution.
- Wire
agent.persona_namethrough the config loader and add an end-to-end emission test. - Define and test the redirected-stdio behavior before retaining the
/dev/ttypath.
Automated hermes-sweeper review.
| def update_for_session(session_id: str, | ||
| persona_override: Optional[str] = None, | ||
| title_override: Optional[str] = None) -> None: | ||
| """Resolve persona + title (with optional overrides) and emit.""" |
There was a problem hiding this comment.
update_for_session() never loads or passes agent.persona_name, so this always skips the first advertised resolution source. Please retrieve the configured value here (or at a single shared call site) and add an integration test that proves it reaches the OSC payload.
| Prefers ``/dev/tty`` so the escape lands on the real terminal even when | ||
| stdout/stderr are redirected. Falls back to stderr if it's a tty. | ||
| """ | ||
| if os.environ.get("HERMES_DISABLE_TAB_TITLE"): |
There was a problem hiding this comment.
This opens the controlling terminal even when stdout and stderr are pipes, so the implementation does not satisfy the stated no-op behavior for captured/piped output. Gate this on the intended interactive condition, or explicitly document and test the controlling-TTY behavior.
Summary
Emit OSC 0 (window/tab title) and OSC 7 (working directory) from interactive
hermes chatsessions so terminals like Warp, iTerm2, Kitty, WezTerm, Ghostty, and tmux display a useful, agent-aware label per tab — matching the format other agentic CLIs (Claude Code, Codex) use in their tabs.Format:
<Persona>: <session title>Example:
Helm: Plan Hermes agent console with Superset UI featuresThe persona name is resolved from (in order):
agent.persona_nameconfig keyName:line in~/.hermes/SOUL.md# headingin SOUL.md"Hermes"The session title comes from the existing
SessionDB.get_session_title(session_id).What changed
hermes_cli/terminal_title.py— OSC emission with sanitisation, truncation, persona resolution, and atexit-restore.cli.pyat three spots (each guarded bytry/except, so a failure here never breaks chat):self.session_id = …in the chat session ctor — initial emitset_session_title(...)apply — refresh once title generated/titlerename success — refreshacp_adapter/__main__.pysetsHERMES_DISABLE_TAB_TITLE=1because the controlling editor draws its own chrome and stdout is reserved for JSON-RPC.tests/cli/test_terminal_title.py.Safety
The module is a silent no-op when:
TERMis unset ordumbHERMES_DISABLE_TAB_TITLE=1(set automatically in ACP mode)It also:
/dev/tty(preferred) orsys.stderrso output never lands in captured stdoutEvery call site in
cli.pyis wrapped intry: … except Exception: pass— emission is best-effort.Test plan
pytest tests/cli/test_terminal_title.py— 16/16 passingpython -c "import ast; ast.parse(open('cli.py').read())"— syntax validfrom hermes_cli import terminal_title)hermes acp: no escape sequences leak into JSON-RPC streamWhy
Agentic CLIs are increasingly run in many parallel terminal tabs. Without a tab title, vertical-tab UIs (Warp, WezTerm) just show the binary name + cwd — indistinguishable across sessions. Other agent CLIs (Claude Code, Codex) emit OSC titles with their conversation summary, so users can scan their tab strip and find the right session. This brings Hermes to parity for the universal title/cwd portion. (Per-agent icons in Warp are detected internally by Warp and not settable via OSC; that's a separate upstream Warp request.)