feat(desktop): multi-terminal panel with read-only agent terminals - #54517
Merged
Conversation
Multiple persistent in-app terminals managed by a thin VS Code-style icon rail docked on the terminal pane's outer edge. Each tab is its own live xterm+PTY that survives tab switches, session switches, and hiding the pane (VS Code parity: only an explicit close or `exit` kills a shell). Terminals own their state independent of the session — the sole thing they inherit is an initial cwd snapshotted at creation. - Rail: icon-only tabs (name + live hotkey on hover), +/hide controls, context menu. Sits at z-40 above the collapsed sidebars' hover-reveal triggers and marks itself data-suppress-pane-reveal, so reaching for a tab can't summon the file-browser/review panel. - Lifecycle: PersistentTerminal latches mounted on first open so shells stay alive while hidden; ensureTerminal re-creates one on reopen. - Agent reader: id-keyed registry drives read_terminal off the active tab. - Keybinds (Ctrl-family, OS-aware): toggle Ctrl+`, new Ctrl+Shift+`, next/prev Ctrl+Shift+Down/Up, close Ctrl+Shift+W.
Hide inactive terminal tabs with `visibility` (absolute-stacked at full size) instead of `display:none`. A display:none host is 0×0, so its ResizeObserver fit bails and the terminal stops tracking pane resizes — re-showing it at a changed size reflowed the buffer into a garbled prompt. Visibility-hidden hosts keep their layout size, stay in sync, and switch instantly.
Fold terminal close into the existing ⌘/Ctrl+W handler so focus decides the target: a focused terminal takes ⌘W (closes the active tab) and otherwise the keystroke closes the active preview tab as before. Only the ⌘ gesture is intercepted — Ctrl+W stays the shell's werase — and a focused terminal never lets ⌘/Ctrl+W close a preview out from under it.
Replace the one-off isTerminalFocused with isFocusWithin(selector) in the
keybinds lib (beside isEditableTarget) — the reusable primitive for any
focus-scoped shortcut. The terminal marks itself data-terminal and the ⌘W
handler routes via isFocusWithin('[data-terminal]'); future surfaces just add
their own marker.
A WebGL terminal doesn't paint while visibility:hidden, so switching to it (e.g. after closing the active tab) revealed a stale/garbled frame. On activation, clear the glyph atlas and force a full term.refresh against the live buffer (after the refit), then focus.
When the agent runs terminal(background=true) — Hermes's equivalent of
Cursor's is_background — surface it as a read-only "agent" tab in the rail
(distinct sparkle icon), alongside the glanceable status-stack row, which now
links to the tab. The tab is a write-only xterm (no PTY, no input) fed by the
process output tail, appended live (faster poll while a tab is open) and
env-agnostic (works for local/docker/ssh shells alike).
- terminals.ts: TerminalEntry gains kind ('user'|'agent') + procId; agent tabs
auto-surface once (closing one doesn't resurrect it) and the status row can
reopen/focus them. ensureTerminal now guarantees a user shell specifically.
- use-agent-terminal.ts: slim read-only xterm hook, delta-appended.
- workspace: render user vs agent instances; auto-surface from the background
store; tail faster while an agent tab exists.
- composer-status: $backgroundOutputByProc selector; status row links to the tab
instead of an inline disclosure.
Replace the 5s output_tail poll (which often showed nothing) with a real push
stream. The process registry gains an on_output sink called from its reader
threads with each chunk; the tui_gateway wires it to emit agent.terminal.output
{process_id, chunk} (write_json is _stdout_lock-guarded, so emitting from the
reader thread is safe). The desktop routes chunks by process id straight into
the read-only agent xterm via a small writer registry, with a capped backlog so
a tab opened mid-stream (or reopened) replays what it missed.
Drops the fragile poll/tail path: no session-key matching, no truncation, no
lag — full-fidelity ANSI, env-agnostic (local/docker/ssh).
Contributor
🔎 Lint report:
|
| Rule | Count |
|---|---|
unresolved-attribute |
2 |
invalid-assignment |
1 |
First entries
tests/run_agent/test_credits_notices_toggle.py:76: [unresolved-attribute] unresolved-attribute: Unresolved attribute `_credits_session_start_micros` on type `AIAgent`
tests/tools/test_process_registry.py:239: [invalid-assignment] invalid-assignment: Object of type `_FakeProcess` is not assignable to attribute `process` of type `Popen[Unknown] | None`
run_agent.py:3040: [unresolved-attribute] unresolved-attribute: Object of type `Self@get_credits_spent_micros` has no attribute `_credits_session_start_micros`
✅ Fixed issues (2):
| Rule | Count |
|---|---|
unresolved-attribute |
1 |
invalid-assignment |
1 |
First entries
tools/process_registry.py:908: [unresolved-attribute] unresolved-attribute: Attribute `read` is not defined on `None` in union `IO[Any] | None`
tests/run_agent/test_credits_notices_toggle.py:76: [invalid-assignment] invalid-assignment: Object of type `None` is not assignable to attribute `_credits_session_start_micros` of type `int`
Unchanged: 6140 pre-existing issues carried over.
Diagnostics are surfaced as warnings — this check never fails the build.
Read-only agent terminal tabs now consume both live agent.terminal.output chunks and the process-list/status snapshot. The snapshot seeds tabs opened after output already exists and acts as a fallback if the live stream races startup, so agent background tabs don't sit blank while the status stack already knows the tail.
Seed read-only agent terminal tabs with the background command immediately, so they never open as a blank pane while stdout is pending or a live stream races startup. Snapshot fallback now preserves that command header and appends only missing output without duplicating live chunks.
Make the read-only agent terminal mirrors stream in real time and give the agent a desktop-only way to dismiss its own tabs. - Stream background output live: the local reader used a blocking read(4096) that buffered small periodic output until EOF, so agent tabs only "filled in" at process exit. Switch to buffer.read1(4096) (decoded) for incremental chunks. - Route agent.terminal.output / terminal.close to the window that owns the process (its gateway session) instead of an empty session id, so events actually reach the desktop renderer. - Add close_terminal: a HERMES_DESKTOP-gated tool (sibling of read_terminal) that drops a process's read-only tab WITHOUT killing it via process_registry.on_close; output keeps buffering and the user can reopen from the status stack. - ⌘W now closes a focused agent tab: mark the agent instance data-terminal and focus it on activation so isFocusWithin routes there. - ensureTerminal() no longer spawns an extra user shell when a tab already exists (e.g. opening a background task from the status stack).
Register read-only agent terminals with the same renderer-side terminal reader as user terminals so read_terminal works on whichever tab is active. Also bring agent xterm rendering closer to user-terminal parity (unicode 11, web links, font weights/spacing) and make the gateway sink wiring resilient if only one terminal event sink was already installed.
Keep read-only agent terminal tabs visually and behaviorally aligned with normal terminal tabs by using the same 1,000-line scrollback cap.
Remove the prompt-gap cleanup that sent Ctrl-L into the user's shell; it could render as literal ^L and create the exact top-line gap it was meant to hide. Keep first-prompt cleanup renderer-side only, and parse short ESC charset sequences so the initial newline stripper does not disarm early. Also add a Close all action to the terminal tab context menu.
OutThisLife
force-pushed
the
bb/desktop-multiterminal
branch
from
June 29, 2026 02:46
18e6ddd to
ae465e9
Compare
OutThisLife
enabled auto-merge
June 29, 2026 02:48
1 task
waefrebeorn
pushed a commit
to waefrebeorn/slermes
that referenced
this pull request
Jul 2, 2026
…ltiterminal feat(desktop): multi-terminal panel with read-only agent terminals
habarmc1223-sudo
pushed a commit
to habarmc1223-sudo/hermes-agent-fluxmem
that referenced
this pull request
Jul 8, 2026
…ltiterminal feat(desktop): multi-terminal panel with read-only agent terminals
santhreal
pushed a commit
to santhreal/hermes-agent
that referenced
this pull request
Jul 13, 2026
…ltiterminal feat(desktop): multi-terminal panel with read-only agent terminals
Gravezzz
pushed a commit
to Gravezzz/hermes-agent
that referenced
this pull request
Jul 21, 2026
…ltiterminal feat(desktop): multi-terminal panel with read-only agent terminals
leewenjie
pushed a commit
to leewenjie/hermes-agent
that referenced
this pull request
Aug 7, 2026
…ltiterminal feat(desktop): multi-terminal panel with read-only agent terminals
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a real multi-terminal experience to the desktop app and wires Hermes into it as a first-class, read-only participant.
exit, middle-click, the context menu (Close,Close others,Close all), or hotkeys.cwd; switching chats or hiding the pane never kills a shell.terminal(background=true)process is mirrored as a distinct read-only tab (theagentcodicon, primary tint) that streams output live, is readable throughread_terminalwhen active, and is linked from the composer's background status stack.close_terminaltool without killing the process. Output keeps buffering and the user can reopen from the status stack.Ctrl+\``), new (Ctrl+Shift+`), next/prev (Ctrl+Shift+↓/↑), close active (Ctrl+Shift+W), and⌘W` to close the focused terminal on macOS.Why These Fixes Matter
@xterm/addon-serialize) plus launch details, then replaying history while reviving/relaunching the process. This PR now mirrors the useful renderer-level behavior: restored tabs plus recent history, without pretending app-quit processes can remain alive.stdout.buffer.read1(4096)instead of blockingstdout.read(4096), andagent.terminal.output/terminal.closeevents are routed to the owning desktop session.read_terminalreads whichever terminal tab is active.\f) into the user's shell, which could render as literal^L.Footprint / Design Notes
close_terminalisHERMES_DESKTOP-gated, likeread_terminal, so it is invisible outside desktop.@xterm/addon-serializefor lossless-ish scrollback replay; lockfile change is limited to the new dependency entry.Test Plan
Automated
scripts/run_tests.sh tests/tools/test_process_registry.py— 98/98 pass.npm run test:ui -- --run src/app/right-sidebar/terminal/terminals.test.ts— terminal store persistence tests pass:npm run typecheck --workspace hermes— desktop TypeScript clean.Manual QA
+.Ctrl+Shift+\`` opens more tabs;Ctrl+Shift+↑/↓cycles; typeexit`, middle-click, or context-menu to close.Close allcloses every terminal tab and hides the terminal pane.agenttab appears and streams live.⌘Wcloses it on macOS. Opening a background task from the status stack does not spawn a second empty shell.close_terminala process → its tab disappears but the process keeps running; reopen it from the status stack and output is intact.Ctrl+\`` → the first prompt starts at the top without literal^L` output.