Skip to content

feat(cli): show session state in classic terminal title - #74654

Open
konsisumer wants to merge 1 commit into
NousResearch:mainfrom
konsisumer:feat/classic-cli-terminal-title
Open

feat(cli): show session state in classic terminal title#74654
konsisumer wants to merge 1 commit into
NousResearch:mainfrom
konsisumer:feat/classic-cli-terminal-title

Conversation

@konsisumer

Copy link
Copy Markdown
Contributor

What changed and why

Per the follow-up triage, this adds the missing, current-main terminal-title lifecycle to the classic prompt_toolkit CLI. It emits sanitized OSC 1/2 updates only for interactive terminals, uses the active skin's response symbol, includes the session name, marks active work with an hourglass, and keeps response panel labels aligned with named sessions. display.terminal_title: false disables the behavior.

The lifecycle refreshes at startup, thinking transitions, completion, manual and automatic titles, new sessions, resumes, and branches. The automatic-title callback is session-scoped so a delayed title cannot overwrite a newly selected session's tab.

Addressing maintainer feedback

The maintainer identified #10013 and #5318 as related open work. This is a new, atomic current-main implementation for #5505 rather than modifying either existing PR branch: it supplies the classic-CLI OSC writer and lifecycle integration called for by the follow-up triage, while leaving #10013 and #5318 untouched for maintainers to consolidate as appropriate.

How to test

  1. Run the classic CLI in a terminal that supports OSC titles, set a session name with /title Release prep, and verify the tab/window shows the active skin symbol plus Release prep.
  2. Submit a prompt and verify the title gains while Hermes is working, then returns to idle when the turn completes.
  3. Exercise /new, /resume, and /branch; each should retitle the tab for the active session.
  4. Set display.terminal_title: false in config.yaml and verify no OSC title updates are emitted.
  5. Automated: /opt/homebrew/bin/timeout -k 30 480 sh -c 'pytest tests/ -q -x --timeout=60 "$@"' sh

What platforms tested on

  • macOS (automated test suite and OSC-writer unit coverage)

Fixes #5505

@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 splitting the classic-CLI implementation into a focused current-main change. The gap is real: current main has terminal-title handling in the TUI, not in the classic prompt_toolkit CLI.

Problems

  • hermes_cli/terminal_title.py:60 writes to sys.stdout, but the classic interactive loop runs under patch_stdout (cli.py:17185). cli.py:3060 explicitly documents that raw ANSI through its StdoutProxy is swallowed, so the new OSC updates may never reach the terminal in the path this PR targets.
  • hermes_cli/terminal_title.py:49-61 has no native-Windows branch. The existing TUI title hook uses process.title on Windows because classic conhost does not support OSC (ui-tui/packages/hermes-ink/src/ink/hooks/use-terminal-title.ts:15-31).

Suggested changes

  • Route the OSC writer through a prompt_toolkit-safe real-terminal/raw-output path and add a regression test for that interactive path.
  • Add a native-Windows fallback or safe no-op, with coverage.

This is an automated hermes-sweeper review.

if output is None or not output.isatty():
return False
clean_title = sanitize_terminal_title(title)
if not clean_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.

The classic interactive loop is inside patch_stdout (cli.py:17185), and cli.py:3060 documents that raw ANSI written to its StdoutProxy is swallowed. Please write this sequence through the real terminal/raw prompt_toolkit output path and add a regression test; otherwise the title lifecycle can be invisible in the primary classic-CLI path.

Comment thread hermes_cli/terminal_title.py Outdated
"""Emit OSC 1/2 title updates when stdout is an interactive terminal.

OSC 1 updates a terminal icon/tab label and OSC 2 updates the window title.
Unsupported terminals safely ignore both sequences. The writer deliberately

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 emits OSC for native Windows TTYs too. The existing TUI title hook explicitly uses process.title on Windows because classic conhost does not support OSC (ui-tui/packages/hermes-ink/src/ink/hooks/use-terminal-title.ts:15-31). Please add a Windows fallback or explicit safe no-op with coverage.

@teknium1 teknium1 added 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-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 30, 2026
@konsisumer

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Addressed in this push:

  • patch_stdout path: the classic CLI now passes its prompt_toolkit Application.output into the title writer, which emits OSC via write_raw() and flushes it; regression coverage verifies that raw-output path.
  • Native Windows: title updates now use SetConsoleTitleW rather than OSC, with coverage confirming the fallback and that no OSC payload is written.

Verified with pytest tests/ -q -x --timeout=60 and focused Ruff checks.

@konsisumer
konsisumer force-pushed the feat/classic-cli-terminal-title branch from cf1210a to f10dd8c Compare July 31, 2026 03:24
@YangKangSung

Copy link
Copy Markdown

Review note (Windows user / VS Code + Git Bash)

Thanks for the focused classic-CLI path and for addressing the earlier patch_stdout / SetConsoleTitleW feedback. The lifecycle hooks (thinking, turn start/end, /title, auto-title, resume/branch) look right for #5505.

Remaining gaps on the platform this feature is meant to help

These are the environments where “title doesn’t feel alive” shows up most for me:

  1. Git Bash / mintty / MSYS (very common Hermes-on-Windows path)
    sys.platform == "win32"SetConsoleTitleW only. Mintty tabs usually ignore the Win32 console title and expect OSC 0/1/2. Result: unit tests pass, real Git Bash tab stays stuck on bash / hermes.
    Suggestion: on Windows, try both SetConsoleTitleW and a raw OSC write to the real terminal stream (same write_raw / non-patch_stdout path you already added). Don’t treat Win32 API success as “tab updated.”

  2. VS Code integrated terminal tab
    VS Code’s tab label often doesn’t track SetConsoleTitleW the way a bare conhost window does. OSC title sequences are what usually move the editor tab title. Worth an explicit manual check matrix:

    • Windows Terminal tab
    • VS Code integrated terminal tab
    • plain conhost
    • Git Bash (mintty)
  3. Density vs TUI / Grok-like status board
    This PR composes symbol [session] [⏳]. The Ink TUI already has composeTabTitle(marker, session, model, cwd). If the goal is “glanceable state while alt-tabbed,” missing pieces users will still notice:

    • approval / action-required (not the same as busy thinking)
    • optional model · short cwd (even truncated)
    • idle vs busy is binary; no sustained activity pulse (contrast feat: add CLI tab activity indicator #36698’s spinner frames — that PR overlaps; maintainers may want one surface)
  4. Exit / teardown
    I didn’t see a restore-to-previous-title (or clear) on CLI exit. Not blocking, but multiplexers and long-lived VS Code terminals get sticky “Hermes ⏳” leftovers if a crash path skips the idle update.

  5. Default on (display.terminal_title: true)
    Reasonable for the product feel, but multiplexers / nested SSH sometimes hate surprise OSC. Opt-out is documented; a one-line note in the PR body about “disable if your terminal steals titles” would help review.

What already looks solid

  • Sanitization / length bound
  • Session-scoped auto-title callback (no stale overwrite after /new)
  • prompt_toolkit output.write_raw path after the sweeper note
  • Native Windows API fallback for classic conhost

Happy to re-test on Win11 + VS Code + Git Bash if you add the dual-write path. Not asking to expand into a second product (spinner templates live in #36698) — just the Windows tab-actually-moves case.

(Human review from a Windows daily driver; not the sweeper bot.)

@konsisumer

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed Windows report. Addressed in this push:

  • Git Bash / mintty and VS Code terminals: Windows now calls SetConsoleTitleW for conhost and also sends OSC 1/2 through the existing raw-output path, so terminals that ignore the Win32 API can update their tabs.
  • Regression coverage: the Windows test now verifies both the native title call and the emitted OSC sequence.
  • Scope: I kept this change to the title transport issue; approval state, extra title metadata, pulses, and teardown are separate lifecycle/UI decisions. display.terminal_title remains the opt-out for terminals that should not receive title updates.

Verified with focused Ruff checks and pytest tests/ -q -x --timeout=60. I would appreciate a re-test on the Windows Terminal, VS Code, conhost, and Git Bash matrix.

@GottZ

GottZ commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

This was generated by AI during triage.

Summary

Four PRs address or reference #5505: #4834 introduced OSC title updates inside a large unrelated bundle, #5318 and #8497 carried the same stale and unsafe classic-CLI patch, and #74654 is the focused current-main implementation covering the classic CLI lifecycle, session names, busy state, opt-out, and tests.

Related pull requests

Duplicates

#5318 and #8497 are effectively the same change; #4834 contains an earlier version of the same terminal-title feature within a much broader bundle. All three are superseded for this issue by the focused #74654 implementation.

Suggested consolidation

Keep #74654 open with a salvage path: retain its focused current-main title helper, lifecycle hooks, prompt_toolkit raw-output transport, Windows dual transport, opt-out, and tests, then obtain contributor re-review of the two previously blocking transport findings and validate the requested Windows Terminal, VS Code, conhost, and Git Bash matrix. Keep #4834 closed as superseded by #74654, keep #5318 closed as superseded by #74654, and keep #8497 closed as a duplicate of #5318 in the same superseded chain.

Complex graph

flowchart LR
    classDef open fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a
    classDef merged fill:#dcfce7,stroke:#15803d,color:#14532d
    classDef closed fill:#e5e7eb,stroke:#6b7280,color:#1f2937
    classDef unverified fill:#f3f4f6,stroke:#9ca3af,color:#374151
    classDef best stroke-width:3px,stroke:#b45309
    classDef target stroke-width:3px,stroke:#4338ca
    I5505(["issue #5505 (open)"])
    subgraph Dup4834 ["PRs duplicating each other"]
        P4834["PR #4834 (closed)"]
        P5318["PR #5318 (closed)"]
        P8497["PR #8497 (closed)"]
        P74654["PR #74654 (open)"]
    end
    P74654 -->|best fix| I5505
    class I5505 open
    class P4834 closed
    class P5318 closed
    class P8497 closed
    class P74654 open
    class P4834 best
    class P74654 best
    class P74654 target
    click I5505 "https://github.com/NousResearch/hermes-agent/issues/5505"
    click P4834 "https://github.com/NousResearch/hermes-agent/pull/4834"
    click P5318 "https://github.com/NousResearch/hermes-agent/pull/5318"
    click P8497 "https://github.com/NousResearch/hermes-agent/pull/8497"
    click P74654 "https://github.com/NousResearch/hermes-agent/pull/74654"
Loading

Graph: solid arrow = fixes / best fix, dashed arrow = partial or unverified (see edge label); boxed group = PRs duplicating each other; amber border = best fix; indigo border = target; gray node = closed (state tag in the node label).

Cross-PR triage: Reviewed 4 pull requests and 1 issue in this complex. Each diff was read against this issue; Assessment working set: 228 kB of PR diffs, 4 kB of issue/PR text, 10 kB of discussion (17 comments), 5 verify verdicts. verdicts reflect diff content, not PR titles. Part of an automated triage batch.

@aneym

aneym commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

I built the same feature against the classic CLI locally last week, so I ran my own test cases against this branch (head e9a67ac). Two failure modes that bit my version are already handled here:

  • Control bytes in titles. BEL, ESC, other C0, and DEL are stripped before the payload is built, so a chat message containing them cannot terminate the sequence early. The \x00-\x1f / \x7f-\x9f range covers it.
  • Title after a session switch. There is no per-session dedupe, so A → B → A writes all three times and the tab ends up showing A. /new followed by resuming a titled session also lands correctly.

Two ordering problems did show up, both from the delayed auto-title callback.

1. The session guard is checked outside the write lock. In _update_auto_title (cli.py) the comparison self.session_id == _title_session_id happens before _update_terminal_title, while _WRITE_LOCK wraps only the byte write inside _write_osc_terminal_title. Ordering:

  1. The auto-title callback for session A fires and evaluates the guard as true.
  2. The main thread switches to session B and writes B's title.
  3. The callback resumes and writes A's title.

My harness ended with \x1b]1;⚕ Generated A\x07\x1b]2;⚕ Generated A\x07 while B was the active session. Passing the expected session id into _update_terminal_title and comparing it under _WRITE_LOCK would close the window.

2. The callback clears the busy marker of the turn in flight. _update_terminal_title(session_title=title) takes busy=False by default, so a callback from turn 1 landing during turn 2 rewrites the title without ⏳ while the agent is still working. Recomposing from a stored busy flag under the same lock, instead of a parameter default, would keep the marker honest.

Neither needs an unusual setup, but both need the titler to race a fast session switch or a quick second turn, so I would call them polish rather than blockers. I am happy to send a patch for either if that helps.

This branch also covers ground mine did not: the config opt-out, the write_raw path for prompt_toolkit, the Windows native and OSC split, and the busy marker itself.

@aneym

aneym commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

One more from the same testing pass, and it is the one I would fix first.

The title write reaches prompt_toolkit's live output object from a background thread. _update_terminal_title passes app.output into write_terminal_title, which calls write_raw and then flush on it. The auto-title callback runs on the daemon thread started in agent/title_generator.py, and the busy update at cli.py:16983 runs on the process loop. Both share the Vt100_Output instance that the renderer flushes on the event-loop thread.

Vt100_Output is not thread-safe. Its flush reads and clears the buffer in two steps:

data = "".join(self._buffer)
self._buffer = []
flush_stdout(self.stdout, data)

A title write landing between the join and the rebind is dropped by the rebind. A title write that flushes while the renderer is inside its own flush can put the same bytes on the terminal twice. Forcing the interleaving deterministically (blocking inside the join, then writing a title from a second thread) gave me:

b'PROMPT\x1b]1;TITLE\x07\x1b]2;TITLE\x07PROMPT\x1b]1;TITLE\x07\x1b]2;TITLE\x07'

Prompt text and title sequence both emitted twice. That repro is synthetic in its timing, but the shared mutable buffer and the unsynchronized flush_stdout are not: _WRITE_LOCK serializes title writers against each other and leaves the renderer outside.

Scheduling the write onto the application event loop with call_soon_threadsafe keeps every Vt100_Output touch on one thread and makes the lock unnecessary for this path. Giving the output a single owning writer that both the renderer and the title path go through would work too.

@alt-glitch alt-glitch added platform/windows Native Windows-specific behavior or breakage area/config Config system, migrations, profiles and removed needs-decision Awaiting maintainer decision before any implementation labels Aug 4, 2026
@konsisumer

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed concurrency repros. Addressed in this push:

  • prompt_toolkit output safety: live title writes are now scheduled on the owning application loop; if that loop is unavailable during teardown, the cosmetic update is skipped rather than touching the renderer's output from another thread.
  • Stale auto-title callback: the callback passes its captured session ID, which is checked immediately before the scheduled write, so a session switch prevents the old title from replacing the active tab.
  • Busy marker: the title now reads _agent_running when the scheduled write executes, so a delayed callback cannot clear a later turn's busy state.
  • Regression coverage: added controlled event-loop tests for all three cases.

This is a two-file, 139-line follow-up limited to the reported concurrency behavior. Verified with focused Ruff checks and pytest tests/ -q -x --timeout=60.

@konsisumer
konsisumer force-pushed the feat/classic-cli-terminal-title branch from e9a67ac to 6bf58d5 Compare August 5, 2026 09:09
@alt-glitch alt-glitch removed the sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows label Aug 5, 2026
@alt-glitch alt-glitch removed the platform/windows Native Windows-specific behavior or breakage label Aug 5, 2026
@aneym

aneym commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Verified the new head (6bf58d5) against my earlier repros.

The scheduled-write path resolves all three findings: _update_terminal_title hands the write to app.loop.call_soon_threadsafe, the session guard runs on the owning loop immediately before the write, and busy reads _agent_running at execution time rather than at schedule time. The teardown fallback skips the update instead of touching the output from a foreign thread, which is the right trade for a cosmetic write.

pytest tests/hermes_cli/test_terminal_title.py passes here (9 tests), and the three loop-controlled tests encode exactly the stale-session and delayed-busy cases I reported.

Nothing further from my side.

@konsisumer
konsisumer force-pushed the feat/classic-cli-terminal-title branch from 6bf58d5 to d5dc6f8 Compare August 10, 2026 04:25
@konsisumer

Copy link
Copy Markdown
Contributor Author

Rebased onto current origin/main and resolved the two in-scope conflicts. The classic CLI now receives main's shared turn-start title callback while preserving current resume-safety checks.

Verified: Ruff on all six changed Python files; pytest tests/hermes_cli/test_terminal_title.py tests/agent/test_title_generator.py -q --timeout=60 (42 passed); Windows-footgun scan; and git diff --check. The bounded full suite could not collect locally because fastapi is absent and its sandboxed lazy install cannot access the uv cache.

The 6-file, 408-line diff is the original terminal-title feature scope after rebase, not repair scope expansion; this repair added no files and only reconciles its lifecycle integration with current main.

@konsisumer
konsisumer force-pushed the feat/classic-cli-terminal-title branch from d5dc6f8 to a536833 Compare August 15, 2026 00:16
@konsisumer

Copy link
Copy Markdown
Contributor Author

Rebased onto current origin/main and resolved the single in-scope cli.py default-config conflict, retaining both main's redraw setting and this PR's display.terminal_title setting. The 6-file, 408-line change remains the PR's original terminal-title feature scope; no files or features were added during repair.

Verified: focused terminal-title tests (9 passed), Ruff on all changed Python files, the Windows-footguns diff scan, git diff --check, and the bounded configured pytest suite.

@alt-glitch alt-glitch added platform/windows Native Windows-specific behavior or breakage sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows labels Aug 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/config Config system, migrations, profiles area/sessions Session lifecycle, resume, persistence, history comp/cli CLI entry point, hermes_cli/, setup wizard P3 Low — cosmetic, nice to have platform/windows Native Windows-specific behavior or breakage sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform 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 type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: terminal tab/window title with session name and thinking indicator

6 participants