Skip to content

feat(desktop): multi-terminal panel with read-only agent terminals - #54517

Merged
OutThisLife merged 14 commits into
mainfrom
bb/desktop-multiterminal
Jun 29, 2026
Merged

feat(desktop): multi-terminal panel with read-only agent terminals#54517
OutThisLife merged 14 commits into
mainfrom
bb/desktop-multiterminal

Conversation

@OutThisLife

@OutThisLife OutThisLife commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds a real multi-terminal experience to the desktop app and wires Hermes into it as a first-class, read-only participant.

  • Multiple terminals per pane, surfaced through a thin, icon-only vertical tab rail docked on the right edge of the terminal pane. Tabs close via exit, middle-click, the context menu (Close, Close others, Close all), or hotkeys.
  • Terminals are decoupled from chat/project state. Each terminal snapshots its initial cwd; switching chats or hiding the pane never kills a shell.
  • Relaunch restore, VS Code-style. User terminal tabs, active tab, cwd/title, and recent serialized xterm scrollback are persisted to localStorage. On app relaunch, the tab layout returns and recent history is replayed before starting fresh shells. Agent process mirrors stay runtime-only.
  • Read-only agent terminals. Every terminal(background=true) process is mirrored as a distinct read-only tab (the agent codicon, primary tint) that streams output live, is readable through read_terminal when active, and is linked from the composer's background status stack.
  • The agent can close its own tabs through a desktop-only close_terminal tool without killing the process. Output keeps buffering and the user can reopen from the status stack.
  • Editor-grade hotkeys, OS-aware: toggle (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

  • VS Code persists terminal sessions by serializing the xterm buffer (@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.
  • Live agent streaming needed backend fixes: local background readers now use stdout.buffer.read1(4096) instead of blocking stdout.read(4096), and agent.terminal.output / terminal.close events are routed to the owning desktop session.
  • Read-only agent tabs register the same renderer-side xterm reader as user terminals, so read_terminal reads whichever terminal tab is active.
  • Startup prompt cleanup is renderer-side only. We no longer inject Ctrl-L (\f) into the user's shell, which could render as literal ^L.

Footprint / Design Notes

  • close_terminal is HERMES_DESKTOP-gated, like read_terminal, so it is invisible outside desktop.
  • Relaunch restore persists only user terminals. Agent terminals are process mirrors and are recreated from live background process state, not stale localStorage.
  • Adds @xterm/addon-serialize for 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.py98/98 pass.
  • npm run test:ui -- --run src/app/right-sidebar/terminal/terminals.test.ts — terminal store persistence tests pass:
    • restores user terminal tabs and active tab on module load
    • persists user tabs and recent scrollback while skipping runtime-only agent tabs
    • clears remembered tabs when all terminals close
  • npm run typecheck --workspace hermes — desktop TypeScript clean.
  • Targeted Prettier/ESLint autofix ran clean on touched desktop files.

Manual QA

  1. Open the terminal pane → exactly one shell; rail appears with the new-terminal +.
  2. Ctrl+Shift+\`` opens more tabs; Ctrl+Shift+↑/↓cycles; typeexit`, middle-click, or context-menu to close.
  3. Right-click a terminal tab → Close all closes every terminal tab and hides the terminal pane.
  4. Switch chats / hide+show the pane → shells and scrollback survive.
  5. Quit and relaunch the app → user terminal tab count, active tab, titles/cwd, and recent history restore; shells are fresh processes.
  6. Ask Hermes to run a background tick loop → a read-only agent tab appears and streams live.
  7. Focus an agent tab → ⌘W closes it on macOS. Opening a background task from the status stack does not spawn a second empty shell.
  8. With an agent tab active, ask Hermes to read the in-app terminal → it reads the agent tab's xterm buffer.
  9. Ask Hermes to close_terminal a process → its tab disappears but the process keeps running; reopen it from the status stack and output is intact.
  10. Open a fresh terminal with Ctrl+\`` → the first prompt starts at the top without literal ^L` output.

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.
@alt-glitch alt-glitch added type/feature New feature or request comp/desktop Electron desktop app (apps/desktop/*) P3 Low — cosmetic, nice to have labels Jun 29, 2026
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).
@github-actions

github-actions Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

🔎 Lint report: bb/desktop-multiterminal vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 11699 on HEAD, 11697 on base (🆕 +2)

🆕 New issues (3):

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).
@OutThisLife OutThisLife changed the title feat(desktop): multi-terminal panel with side tab rail feat(desktop): multi-terminal panel with read-only agent terminals Jun 29, 2026
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
OutThisLife requested a review from a team June 29, 2026 02:42
@OutThisLife
OutThisLife force-pushed the bb/desktop-multiterminal branch from 18e6ddd to ae465e9 Compare June 29, 2026 02:46
@OutThisLife
OutThisLife enabled auto-merge June 29, 2026 02:48
@OutThisLife
OutThisLife merged commit 4488fe1 into main Jun 29, 2026
34 checks passed
@OutThisLife
OutThisLife deleted the bb/desktop-multiterminal branch June 29, 2026 02:52
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/desktop Electron desktop app (apps/desktop/*) P3 Low — cosmetic, nice to have type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants