Skip to content

The docked terminal now supports **multiple concurrent sessions as…#219

Merged
inkeep-oss-sync[bot] merged 1 commit into
mainfrom
copybara/sync
Jun 20, 2026
Merged

The docked terminal now supports **multiple concurrent sessions as…#219
inkeep-oss-sync[bot] merged 1 commit into
mainfrom
copybara/sync

Conversation

@inkeep-oss-sync

Copy link
Copy Markdown
Contributor

The docked terminal now supports multiple concurrent sessions as tabs. Run an agent in one tab while you run git, a build, or a second shell in another — no more waiting for one session to free up or alt-tabbing to an external terminal.

  • A tab strip in the dock header lists open sessions with a + New Terminal affordance, a per-tab close (×), and an active indicator. Each tab is its own login shell at the project root.
  • Sessions are fully isolated: a flood or a crash in one tab pauses or restarts only that tab — output stays byte-exact with no cross-tab interleave, and backpressure is accounted per session.
  • Tab lifecycle follows the VS Code / Zed model: closing the active tab activates a neighbor, closing the last tab collapses the dock and returns focus to the editor, and hiding the dock (⌘J) keeps every session alive (hide is not kill).
  • "Open in terminal" always opens a fresh tab and runs claude there once — it never interrupts a tab you're already using.
  • The Terminal menu's New/Kill Terminal and a ⌘-number tab-switch shortcut operate on tabs; the tab strip is keyboard- and screen-reader-navigable (tablist roles, arrow-key movement, focus-on-close).
  • Each session keeps 10,000 lines of scrollback.

Under the hood, all of a window's sessions are multiplexed through one terminal host process (matching VS Code's shared-host model), so per-window memory stays flat regardless of tab count. The security posture is unchanged: terminals remain human-only and default-on, and no new IPC surface is added. Split panes and per-tab shell/cwd selection are deliberately not included yet.

…#1994)

* docs(open-knowledge): spec for docked terminal in-panel tabs (multi-session)

Reviewed spec for PRD-7138. Process model locked to Model B (one pty-host
per window multiplexing N PTYs). Includes evidence, audit, and design-review.

* docs(open-knowledge): relocate terminal specs into OK subtree

Move 2026-06-15-docked-terminal and 2026-06-17-terminal-multi-session from
the monorepo-root specs/ into public/open-knowledge/specs/, joining their
sibling 2026-06-17-terminal-usability. They were placed at root by a spec-dir
resolution quirk; all three lineage links now resolve relative to the subtree.

* [US-001] Multiplex N PTYs in pty-host (active slot to Map)

Replace the single `active: {ptyId,pty}|null` slot with a
`sessions: Map<ptyId, PtyProcessLike>` so one window-bound pty-host can
hold multiple concurrent shells. create ADDS a session (a duplicate id,
which can only arise from a contract skew, reaps the stale shell before
overwriting rather than orphaning it); input/resize/kill/pause/resume
look up by message ptyId; onExit removes only that entry; killActive
reaps every session for window-close/quit, resilient to a per-shell
ESRCH. Per-PTY security logic (env strip, shell resolve, spawn-error
containment, safeKill TOCTOU, message guard) is unchanged.

Extend the injected-spawn unit tests with a concurrent-sessions block:
add-not-replace, independent dual-id routing, single-entry exit removal,
kill-all reap, and ESRCH-resilient reap.

* [US-002] Per-session state and isolation in terminal-manager

PtyWindowHandle holds sessions: Map<ptyId, SessionState> (per-session outbound, flushToken, pendingBytes, paused, commandRan). create ADDS a session; input/resize/kill/exit/drain route per ptyId; coalesce and high/low-water pause/resume run per session so a flood or exit in one tab cannot pause, corrupt, or kill another. killForWindow/killAll iterate the window's sessions then kill the shared host; a host crash surfaces an exit per live session. Wire contract (ok:pty:*) unchanged. Adds 11 concurrent-session unit tests.

* [US-003] Real-shell multi-session flood-isolation harness (N-way)

Extend the real-PTY flood harness from one PTY to N concurrent PTYs
multiplexed through ONE host (Model B), proving the per-session isolation
single-PTY coverage and a faked-host manager unit test cannot reach.

- InProcessBridge: per-ptyId pause/resume tallies (pauseCountFor/resumeCountFor).
- Multi-session rig: one bridge + one manager, a fresh ptyId per create,
  distinct multibyte content per session for interleave detection.
- Scenario 3 (two sessions): B (sized < high-water) runs a full flood to
  completion while A is HELD paused by its own backpressure and never resumes
  -> pause state is per-session, not shared; both directions byte-exact, no
  cross-session interleave.
- Scenario 4 (N-way): 3 hidden 'yes' floods + 1 active session. Phase 1 keeps
  hidden tabs draining and asserts the active stream stays byte-exact,
  hidden-marker-free, never self-pauses, and the loop stays responsive over a
  sustained window. Phase 2 engages the pause-hidden-tabs fallback and proves
  the byte rate collapses to ~0 with bounded in-flight.
- Reaping backstop (process.on('exit') + hard-timeout) so the unbounded 'yes'
  floods can't orphan. Test entry now asserts ok=4 fail=0.

* [US-004] Add shadcn Tabs primitive and accessible terminal tab strip

Install the shadcn/Radix Tabs primitive (ui/tabs.tsx) and add
TerminalTabStrip, a fully controlled tab strip for the docked terminal's
concurrent sessions. The strip owns no selection state: the consumer
passes the session list and active id and reacts to onSelect/onNew/onClose.

- Each tab pairs a Radix tab trigger (the roving-focus, arrow-navigable
  role=tab target) with a sibling close Button, not a close nested inside
  the trigger (a button inside role=tab is invalid and unreachable). The
  new-terminal (+) button sits outside the tablist so the list holds only
  tabs. Only the active tab's close control stays in the tab order.
- Icon-only controls carry Lingui aria names: the tablist is labeled
  "Terminal sessions", each close is "Close <label>", the add is
  "New Terminal". en and pseudo catalogs extracted and compiled.
- 7 DOM tests: N tabs in a labeled tablist, active indicator, fully
  controlled click selection, arrow-key selection, onNew, onClose with the
  right id, and accessible names on every icon-only control.

The strip currently owns its own Tabs.Root, so Radix sets aria-controls on
each trigger pointing at panels this component does not render. US-005
supplies the session panels and must reconcile the tabpanel role with the
all-sessions-stay-mounted requirement.

* [US-005] Render concurrent terminal sessions in TerminalDock

Replace the single mount latch with a session collection: TerminalDock now
owns sessions[{id, launch}] + activeSessionId. Each session renders its own
TabsContent(forceMount) -> TerminalGate -> TerminalSession; inactive panels
are CSS-hidden (data-[state=inactive]:hidden) so every session stays mounted,
keeps consuming output + drain-acking, and retains its scrollback. Switching a
tab is show/hide, never unmount; no <Activity> unmount and the hybrid render
tree is preserved.

TerminalTabStrip gains an optional children panel-slot rendered under its own
Radix Tabs root, so triggers and panels share one root and Radix wires the
tab<->tabpanel aria-controls/aria-labelledby relationship (no dangling refs).
A new optional onTabActivate fires on pointer/Enter activation (not arrow-key
nav) so a deliberate tab select focuses the terminal while arrow navigation
stays in the tablist. Reveal focuses the active session; xterm scrollback is
set to 10000 per session.

The + affordance opens a new session (its TerminalSession spawns a fresh PTY),
launch intents open their own tab, the menu Kill action closes the active tab,
and terminalLive reflects "at least one session live".

* [US-006] Tab close, restart, and hide semantics with focus-on-close

closeSession's left-else-right neighbor, close-last collapse, terminalLive,
per-panel restart, and hide-survival were built in US-005. Harden them with
dedicated tests and close one focus gap: closing the active tab now moves
focus into the surviving neighbor's terminal instead of stranding it on the
document body, matching the existing reveal and tab-activate focus path.

- TerminalDock: focus the neighbor session on active-tab close, deferred via
  queueMicrotask so the newly shown panel is focusable.
- Dock DOM tests: closing a tab reaps only that session's PTY (the gate stub
  now mirrors the real create-on-mount and kill-on-unmount lifecycle with
  distinct pty ids); close active leftmost activates the right neighbor; close
  active moves focus into the neighbor; multi-session hide then reopen keeps
  every session and the last-active tab, and never kills on hide.
- Panel DOM test: restart isolation across two real TerminalPanels sharing one
  bridge. Crash and restart one spawns a fresh PTY only for it, the sibling's
  PTY is never reaped, and exactly one exit notice shows.

65 terminal DOM tests pass; typecheck and lint clean; turbo test 11/11.

* [US-007] Open in terminal always opens a new tab

Launch-to-new-tab routing was structurally built in US-005: the dock opens a
tab per fresh launch nonce, freezes the intent into the session descriptor,
and dedups re-renders by nonce, while the session writes claude once per nonce
after its shell is running and the claude preflight settles. This story closes
the nonce-collision bug flagged during US-005 and US-006.

EditorPane derived the launch nonce from the previous intent, but the
clear-on-hide effect resets that intent to null, so a launch, hide, launch
sequence restarted at nonce 1. The dock then saw the second click as a repeat
of the already-open tab and dropped it, opening no new tab. The nonce now comes
from a monotonic ref that survives the hide-clear, so two distinct clicks never
collide. Clear-on-hide stays: its regression test pins the prop-cleared-on-hide
contract and it is orthogonal to the collision fix.

No dock or session code change was needed (both were already correct given
monotonic nonces), no new IPC channel, and the terminal stays human-only.

- EditorPane: monotonic launchNonceRef instead of deriving the next nonce from
  the prior intent.
- EditorPane DOM test: a distinct launch after a hide gets a fresh, increasing
  nonce (RED before the fix: it reused nonce 1).
- Dock DOM tests: a launch while a tab is running opens a new tab and leaves
  the running one untouched; distinct nonces each open their own tab; a
  same-nonce re-render is deduped.

87 terminal DOM tests pass; typecheck 11/11; lint clean; turbo test 11/11.

* [US-008] Generalize Terminal menu and Cmd+number to N tabs

The main-process Terminal menu was already generic from earlier stories: New and Kill dispatch the menu actions, terminalLive is threaded from the renderer push, and Cmd+J stays the View toggle. That layer is unit-covered in menu.test.ts. US-008's work was renderer-side.

New Terminal now adds a tab. The dock's menu-action subscriber calls openSession(null) on new-terminal (it was kill-only). EditorPane still sets terminalVisible(true) on the same action so the dock reveals and the case where no dock is mounted yet (folder or asset target) is still covered. Both fire in one synchronous menu-action emit, React batches them, and the seed-on-reveal guard sees the just-added session and no-ops, so New Terminal yields exactly one tab whether the dock was hidden and empty, hidden with surviving sessions, or already visible.

Cmd+number switches tabs. A capture-phase window keydown maps Cmd+1 through Cmd+9 to the Nth tab, scoped to focus inside the dock so the digit chord stays free everywhere else. Cmd only: Ctrl+digit can be shell input, so it and every non-chord key (Escape included) fall through to the active shell, and the chord is consumed only when it lands on a real tab. Tablist arrow-key switching already works via Radix automatic activation as the alternative path.

No new IPC channel (the menu action already existed and the keydown is renderer-only), and the terminal stays human-only. menu.ts, bridge-contract.ts, and EditorPane comments were updated for the multi-tab semantics with no code or label change.

Tests: five new TerminalDock DOM tests (New Terminal menu adds and activates a tab spawning its PTY; Cmd+1 jumps while the terminal is focused and consumes the chord; a nonexistent index is left for the shell; ignored when focus is outside the dock; a non-chord Escape and plain digit pass through). 28 dock DOM tests (was 23) and 92 terminal and EditorPane DOM tests pass; typecheck 11/11; lint clean; turbo test 11/11 with the desktop menu suite re-run green.

* [US-009] Count-only concurrent-session telemetry signal

Add the per-window concurrency signal that, with the already per-session
recordTerminalSession/recordShellExit, makes multi-session adoption and
concurrency depth observable — never command contents.

- recordConcurrentSessions({count}) emitter: span
  ok.desktop.terminalConcurrentSessions carrying the lone bounded-int attr
  ok.desktop.concurrent_sessions; mirrors the recordShellExit withSpanSync +
  bounded-attribute discipline (SDK-disabled no-op).
- terminal-manager fires it on each successful create with the window's live
  session count (handle.sessions.size after the add). Concurrency only rises on
  create, so the per-window max over these spans reads both adoption (>=2) and
  peak depth. A refused (no-project) create emits nothing; consent gate and the
  ok:pty:* wire are unchanged — no new channel, terminal stays human-only.
- Wired the real emitter at the index.ts composition root beside its two
  sibling telemetry deps.

Count-only enforced two ways: the emitter test asserts the span's only
attribute is the integer count, and the manager test captures the whole payload
and asserts its only key is `count` even after real command input ran.

* chore(open-knowledge): add changeset for terminal multi-session tabs

* fixup! local-review: address findings (pass 1)

* fixup! local-review: address findings (pass 2)

* fixup! local-review: address findings (pass 3)

* fix(open-knowledge): address post-QA review findings for terminal tabs

- Handle WebGL context loss: with no tab cap and all sessions kept mounted,
  the compositor can evict an older tab's WebGL2 context; dispose on
  onContextLoss so xterm falls back to the DOM renderer instead of leaving a
  dead canvas. Log unexpected addon failures louder than the benign no-context
  case.
- pty-host asIncomingMessage now validates per-variant payload fields
  (data/cols/rows) before the cast, mirroring asHostMessage, so a contract-skew
  message can't reach node-pty with undefined args.
- node-pty bootstrap failure uses the instanceof-Error message extraction the
  rest of the file already uses, so a non-Error rejection still routes a
  spawn-error instead of hanging the panel.
- Terminal tab close button inherits the 24px icon-xs target (was 20px), meeting
  WCAG 2.5.8.
- Drop process/spec citations from terminal test comments per comment-discipline.

* refactor(open-knowledge): no-skimp do-now fixes for terminal tabs

- safeKill logs non-ESRCH reap failures (e.g. EPERM) via the host logger so a
  failed reap that could orphan a PTY (FR6) is diagnosable; still never rethrows.
- closeSession reads the live session list from sessionsRef (kept in sync
  post-commit), not the render closure, matching the Cmd-number handler — avoids
  a stale snapshot if two closes coalesce into one batch.
- Concurrency-telemetry comment now states it fires on create (before spawn
  confirms) and can transiently overstate the peak by 1 on a spawn-error,
  matching the actual behavior.
- Align the OkMenuAction terminal-menu comment in both bridge mirror copies with
  the fuller canonical text (dock-reveal + per-tab kill semantics).

* fix(open-knowledge): blend terminal dock chrome with the canvas

Paint the whole terminal dock panel with the exact xterm canvas color (the
same source TerminalPanel's <section> uses) so the tab strip and its controls
read as one continuous surface with the terminal — removes the app-background
seam that showed as a bar between the strip and the canvas. The tab strip is
now presentational (inherits the dock color) and drops its divider border; the
per-tab close stays an X that closes only that tab.

* fix(open-knowledge): raise app bundle size-limit for terminal tabs

The multi-session terminal UI (TerminalTabStrip + the Radix Tabs primitive)
adds ~2.65 kB gzipped to the eager web bundle, pushing the main app bundle to
417.65 kB over the 415 kB budget. Raise the limit to 420 kB. Documented the
growth + a lazy-load-TerminalDock follow-up (the dock is desktop-only and ships
as dead weight in the web bundle) in the size-limit TODO, matching the existing
date-fns / dnd-kit follow-up notes.

* test(open-knowledge): address review suggestions for terminal tabs

From the APPROVE-WITH-SUGGESTIONS review (all Consider / While-You're-Here):
- TerminalPanel: log create() rejections so a per-tab PTY creation failure is
  diagnosable, mirroring the WebGL catch (other tabs keep working, so the
  failure is otherwise easy to miss).
- terminal-manager safeKillUtility: discriminate ESRCH (expected) from other
  kill failures (e.g. EPERM, which orphans the host) and warn on the latter,
  matching pty-host's safeKill.
- Tests: pin the safeKill non-ESRCH warning (pty-host), the onTabActivate
  click-vs-arrow-key focus contract (TerminalTabStrip), and the visible=true
  cold-start seeding path (TerminalDock).

---------

GitOrigin-RevId: fb6325996ba5468e8cda78068b24e501ec582ec0

@inkeep-internal-ci inkeep-internal-ci Bot 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.

Automated approval from agents-private public-mirror-sync (run: https://github.com/inkeep/agents-private/actions/runs/27865947788). Source of truth is the monorepo; direct edits on inkeep/open-knowledge are overwritten on next sync.

@inkeep-oss-sync inkeep-oss-sync Bot merged commit 1dbb137 into main Jun 20, 2026
2 checks passed
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@inkeep-oss-sync inkeep-oss-sync Bot deleted the copybara/sync branch June 20, 2026 08:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants