Skip to content

feat(cli): set terminal tab title to "<persona>: <session title>" - #10013

Closed
tsemana wants to merge 5 commits into
NousResearch:mainfrom
tsemana:feat/terminal-tab-title
Closed

feat(cli): set terminal tab title to "<persona>: <session title>"#10013
tsemana wants to merge 5 commits into
NousResearch:mainfrom
tsemana:feat/terminal-tab-title

Conversation

@tsemana

@tsemana tsemana commented Apr 15, 2026

Copy link
Copy Markdown

Summary

Emit OSC 0 (window/tab title) and OSC 7 (working directory) from interactive hermes chat sessions 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 features

The persona name is resolved from (in order):

  1. Optional agent.persona_name config key
  2. The Name: line in ~/.hermes/SOUL.md
  3. The first # heading in SOUL.md
  4. Fallback to "Hermes"

The session title comes from the existing SessionDB.get_session_title(session_id).

What changed

  • New module: hermes_cli/terminal_title.py — OSC emission with sanitisation, truncation, persona resolution, and atexit-restore.
  • Wired into cli.py at three spots (each guarded by try/except, so a failure here never breaks chat):
    • After self.session_id = … in the chat session ctor — initial emit
    • After the deferred set_session_title(...) apply — refresh once title generated
    • After the in-chat /title rename success — refresh
  • ACP no-op: acp_adapter/__main__.py sets HERMES_DISABLE_TAB_TITLE=1 because the controlling editor draws its own chrome and stdout is reserved for JSON-RPC.
  • Tests: 16 unit tests in tests/cli/test_terminal_title.py.

Safety

The module is a silent no-op when:

  • stdout/stderr aren't TTYs (pipes, captured output, CI)
  • TERM is unset or dumb
  • HERMES_DISABLE_TAB_TITLE=1 (set automatically in ACP mode)

It also:

  • Sanitises control characters / ESC / BEL out of titles to prevent injection
  • Truncates titles to 120 chars (with ellipsis)
  • Restores cleared title on normal interpreter exit
  • Emits to /dev/tty (preferred) or sys.stderr so output never lands in captured stdout

Every call site in cli.py is wrapped in try: … except Exception: pass — emission is best-effort.

Test plan

  • pytest tests/cli/test_terminal_title.py — 16/16 passing
  • python -c "import ast; ast.parse(open('cli.py').read())" — syntax valid
  • Module imports cleanly (from hermes_cli import terminal_title)
  • Manual smoke in Warp / iTerm2: tab updates after first turn (reviewers welcome to verify)
  • Manual smoke under hermes acp: no escape sequences leak into JSON-RPC stream

Why

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

@tsemana
tsemana force-pushed the feat/terminal-tab-title branch 4 times, most recently from 069b9e2 to 631cd9b Compare April 22, 2026 21:46
tsemana added 5 commits April 22, 2026 17:47
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.
@tsemana
tsemana force-pushed the feat/terminal-tab-title branch from 631cd9b to 32a6692 Compare April 22, 2026 21:48
@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/cli CLI entry point, hermes_cli/, setup wizard comp/acp Agent Communication Protocol adapter labels Apr 22, 2026

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:204 does not implement the advertised agent.persona_name behavior. update_for_session() calls resolve_persona_name() without config_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 changes run_agent.py to inject prefetched context each turn. Please keep a terminal-title PR scoped to terminal-title behavior.
  • hermes_cli/terminal_title.py:52 opens /dev/tty before 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_name through the config loader and add an end-to-end emission test.
  • Define and test the redirected-stdio behavior before retaining the /dev/tty path.

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."""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@teknium1 teknium1 added sweeper:risk-caching Sweeper risk: may break/degrade prompt caching or cache-key stability (invariant) sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows sweeper:blast-massive Sweeper blast radius: massive — everyone, every turn (invariant surface) area/sessions Session lifecycle, resume, persistence, history labels Jul 12, 2026
@OutThisLife

Copy link
Copy Markdown
Collaborator

Superseded by #74654 (classic CLI) and #74683 (TUI). #74654 covers the same terminal tab title feature at 204 lines vs 1917 here, with the OSC 1/2 lifecycle, config gate, and session title on current main. Closing to consolidate; the core implementation lives on.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/sessions Session lifecycle, resume, persistence, history comp/acp Agent Communication Protocol adapter comp/cli CLI entry point, hermes_cli/, setup wizard P3 Low — cosmetic, nice to have sweeper:blast-massive Sweeper blast radius: massive — everyone, every turn (invariant surface) sweeper:risk-caching Sweeper risk: may break/degrade prompt caching or cache-key stability (invariant) sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants