Skip to content

feat: Dynamic multi-terminal UI with split-pane support#6

Open
VivanRajath wants to merge 11 commits into
open-gitagent:mainfrom
VivanRajath:feature/ui-improvements
Open

feat: Dynamic multi-terminal UI with split-pane support#6
VivanRajath wants to merge 11 commits into
open-gitagent:mainfrom
VivanRajath:feature/ui-improvements

Conversation

@VivanRajath
Copy link
Copy Markdown

Summary

Adds a fully dynamic multi-terminal interface to the ClawLess terminal panel. Users can now open multiple independent shell sessions, rename them inline, close them individually, and view them in a split-pane layout alongside the Agent terminal — all without any page reload.

Motivation

The original UI had a single fixed terminal pane tied to the agent. Power users working with ClawLess often need a separate shell for running commands while the agent is active. This PR addresses that gap with a scalable, tab-based terminal system.

File Changes

File What changed
src/container.ts Replaced fixed shellProcess2/shellWriter2 fields with a Map<string, WebContainerProcess> and a generic spawnShell(id, terminal) method supporting unlimited concurrent shells
src/sdk.ts Added spawnShell(id, terminal) passthrough; removed hardcoded _terminal2 and startShell2 calls
src/ui.ts Rewrote bindTerminalTabs() — dynamic tab creation, inline rename (double-click), tab close (×), split-pane toggle, and drag-to-resize handle
index.html Updated terminal panel markup: ⚙ Agent tab + button + split button; cleaner pane structure
src/style.css New styles for dynamic tabs, close button (hidden until hover), button, inline rename via contenteditable, scrollable overflow tab bar, split-pane layout

How to Test

Environment: Windows 11, Node.js v20, Chrome 123

  1. npm install && npm run dev
  2. Open http://localhost:5173 in Chrome
  3. Wait for the boot sequence to complete (agent installs)

Tab creation:

  • Click → "Terminal 1" tab appears; a live jsh shell launches inside the WebContainer
  • Type ls → lists workspace files independently from the agent
  • Click again → "Terminal 2" opens with another independent shell
  • Both terminals run simultaneously without interfering with each other

Rename:

  • Double-click "Terminal 1" tab label → it becomes editable (cursor appears)
  • Type "Server" → press Enter → label saves as "Server"
  • Click away also saves the rename

Close:

  • Hover any dynamic tab → × button appears
  • Click × → tab and its shell pane are removed; view switches back to Agent

Split view:

  • Click → terminal panel splits: active shell on right, Agent on left
  • Drag the divider between panes to resize
  • Click again → returns to single-pane view

No regressions:

  • Agent / gitclaw terminal works normally throughout
  • npm run build passes with zero TypeScript errors
  • No console errors observed

Checklist

  • npm run build passes
  • No TypeScript errors (strict mode enabled)
  • Manually tested on Windows 11 / Chrome
  • Backward compatible — existing startShell / startGitclaw flow untouched
  • No new dependencies added

Copy link
Copy Markdown
Contributor

@shreyas-lyzr shreyas-lyzr left a comment

Choose a reason for hiding this comment

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

Large feature PR — multi-terminal UI with split-pane support. The concept is solid and addresses a real gap for power users. A few things need attention before this is merge-ready:

Blocking — dependency regression

The package-lock.json downgrades monaco-editor from ^0.55.1 to ^0.53.0 and bumps vite from ^5.2.0 to ^8.0.1. The vite major bump is especially risky — vite 8 introduced breaking config changes. If this PR was developed on a different base branch, please rebase onto current main and re-lock. The lockfile changes will likely conflict once rebased.

New clawcontainer dependency

clawcontainer was added without explanation in the PR body. What does it provide and is it a trusted/internal package? A note on this would help reviewers.

Split-pane state on page reload

The new tab/pane state appears to live entirely in JS memory. If the user reloads, all open terminals and their layout disappear. Is that the intended behavior, or should open sessions persist in localStorage?

Accessibility

The new tab buttons use emoji characters (, , ) as their visible label with no aria-label. Screen readers will get meaningless characters. Add aria-label attributes to the interactive buttons.

Terminal lifecycle

When a tab is closed, is the associated WebContainer process explicitly terminated? If not, processes will leak silently in the background.

The overall direction is good. Addressing the dependency regression and clarifying the clawcontainer addition are the minimum requirements before merge.

Copy link
Copy Markdown
Contributor

@shreyas-lyzr shreyas-lyzr left a comment

Choose a reason for hiding this comment

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

Large, well-structured PR with a clear purpose. The multi-terminal tab system is a meaningful UX improvement and the code is generally solid. A few issues need attention before merge.

Blocking: onData listener accumulation in spawnShell

In container.ts, spawnShell registers terminal.onData(...) without disposing the previous one. Because TerminalManager.onData in this PR does dispose-before-register (via onDataDisposable), each new shell spawned to the same terminal will silently detach the previous shell's listener and attach the new one. This means switching between shells that share a TerminalManager instance will break input routing for all but the last-spawned shell. If each dynamic tab gets its own TerminalManager instance, this is fine — but that invariant is not enforced or documented, and the Map<string, TerminalManager> in UIManager suggests it might not always hold.

Blocking: window.addEventListener('resize') leak in spawnShell

Each call to spawnShell adds a new resize event listener on window with no corresponding removal. Spawning N shells registers N listeners, all of which survive even after the shell is closed. After a few sessions this adds up. Store the listener and remove it when the shell is killed, or use the ResizeObserver pattern already used in TerminalManager.

Concern: clawcontainer added as a production dependency

package.json adds clawcontainer: ^1.1.0 to dependencies. The diff does not show any direct import of this package in the source files being changed. If it is only used internally by spawnShell or if it is an internal package, that needs to be documented. If it is unused, remove it to avoid unnecessary bundle bloat.

Concern: vite 5 → 8 major bump without documented rationale

The package.json bump from vite ^5.2.0 to vite ^8.0.1 is a two-major-version jump. Vite 8 requires Node >=20.19.0 || >=22.12.0 and switches from esbuild to rolldown. This changes the minimum runtime requirement for contributors and may affect the WebContainer boot environment. This should be explicitly called out in the PR description and ideally confirmed against the CI node version.

Minor: monaco-editor downgraded from ^0.55.1 to ^0.53.0

This is a regression — going backwards on a dependency is unusual and may re-introduce bugs that were fixed between those versions. The reason is not mentioned in the PR description. Please justify or revert.

Minor: CSS split-pane specificity

The rule #terminal-panes.split-active .term-pane.active { display: flex; } and #terminal-panes.split-active #terminal-pane-split { display: flex !important; } mixes a specificity-won selector with !important. Prefer consistent specificity throughout; the !important here is a code smell that will make future layout overrides harder.

…e versions

- Drop clawcontainer: ^1.1.0 — never imported in src/, only used as a window property name
- Restore monaco-editor to ^0.55.1 (was downgraded to ^0.53.0 on branch)
- Restore vite to ^5.2.0 (was bumped to ^8.0.1 on branch)
- Regenerate package-lock.json cleanly
- TerminalManager.onData() now returns its IDisposable so callers can
  hold a reference and dispose it when the shell exits.
- spawnShell stores the onData disposable and resize handler in per-id
  maps (shellOnDataDisposables, shellResizeHandlers).
- A proc.exit handler cleans up both listeners when the shell exits
  naturally, via the new _cleanupShellListeners() helper.
- killShell() disposes the onData listener and resize handler immediately,
  then calls proc.kill() whose exit resolution handles the rest — no
  double-decrement of activeProcessCount.
The tab close handler now calls container.killShell(id) before removing
the pane, so the jsh process inside the WebContainer is explicitly killed
instead of being abandoned. Also calls tm.dispose() to clean up the
xterm instance.
Screen readers cannot infer meaning from the bare emoji characters
(⚙, +, ⊞). Add aria-label to the two <button> elements and role=tab
plus aria-label to the Agent tab <div> so assistive technology
announces them correctly.
The previous rule used a high-specificity ID selector combined with
!important to force display:flex on the split pane, which was both
unnecessary (ID specificity already wins over .term-pane) and
inconsistent with the other two rules in the same block.

Replace all three split-active rules with uniform class-based selectors
(.split-active .term-pane-split, .split-active .resize-split) and add
the corresponding classes to the elements in index.html. No !important
is needed — two-class specificity cleanly overrides the single-class
.term-pane base rule.
@VivanRajath VivanRajath force-pushed the feature/ui-improvements branch from f4cb061 to 1d5f3b8 Compare June 1, 2026 18:18
…terminal

killShell now deletes from shellProcesses before calling kill(), so the exit
promise handler can use shellProcesses.has(id) as a sentinel to short-circuit
and avoid double-decrement of activeProcessCount and double-disposal of listeners.
- Rename handler parameter from `code` to `exitCode` so it reads as
  the plain number WebContainer resolves to, with no Number() wrapper.
- Add .catch(()=>{}) to proc.output.pipeTo() so the rejection that
  fires when the process exits and closes the stream is not left
  unhandled (could surface as a misleading error in devtools).
jsh crashes with an internal exitCode error when it tries to resolve
the exit code of the clear(1) command. Erase the xterm display with
ESC[2J ESC[H directly instead so the shell starts clean without
jsh ever running clear as a child process.
All open terminal panes are now visible side-by-side simultaneously,
like VS Code's terminal split view:

- Each pane runs its own live shell and xterm instance.
- Clicking a pane (or its tab) focuses it with a blue-outline highlight;
  keystrokes go to that pane's shell via xterm's native focus — the
  per-shell onData disposables in ContainerManager are not touched.
- Draggable resize handles are inserted between adjacent panes on add.
- Closing a pane removes it and its adjacent handle, then resets
  remaining panes to equal flex so they re-tile evenly.
- The + and split (⊞) buttons both add a new tiled pane.
- Removed the static #terminal-pane-split / #resize-terminal-split
  slots and .split-active CSS rules; everything is fully dynamic now.
@VivanRajath
Copy link
Copy Markdown
Author

Hi @shreyas-lyzr, sorry for the long delay here, I got pulled into some other projects and only just got back to this. Thanks for the detailed review, it genuinely helped tighten this up. I've rebased onto current main and pushed updates for everything you raised, plus an improvement to the split view:

Dependency regressions: After rebasing onto latest main I regenerated the lockfile. monaco-editor is back to ^0.55.1 and vite is back to ^5.2.0, so the vite 8 jump and the Node requirement change are gone.

clawcontainer: Good catch, it wasn't imported anywhere in src, just a leftover reference, so I removed it from dependencies.

onData listener lifecycle: onData() now returns its IDisposable and disposes the previous handler before registering a new one, with per-shell disposables tracked in a shellOnDataDisposables map. Each pane keeps its own disposable, so input routing stays correct across multiple live shells.

resize listener leak: Each shell's resize handler is stored by id in shellResizeHandlers and removed via removeEventListener when the shell exits or is killed, so they no longer accumulate.

Terminal lifecycle: killShell() explicitly calls proc.kill() and handles cleanup (listeners, writer, maps) synchronously before the kill. The proc.exit handler uses shellProcesses.has(id) as a sentinel to avoid double-cleanup.

Accessibility: Added aria-label to the New Terminal and Split Terminal buttons and the Agent tab.

CSS specificity: Dropped the !important rules and reworked the split-pane styles into uniform two-class selectors.

Split view improvement: Following the reload/layout discussion, I reworked the split from a single swap slot into proper multi-pane tiling, closer to VS Code. Each open terminal can now render as its own live pane side by side, the focused pane is highlighted and receives keystrokes, panes are resizable via drag handles between them, and closing a pane kills its shell and re-tiles the rest.

While testing I also fixed a runtime crash where jsh threw an internal exitCode error when a clear was sent on shell startup, replacing it with a direct ANSI erase sequence.

On reload persistence: open sessions are still intentionally ephemeral and don't survive a reload. Happy to add localStorage persistence as a separate follow-up if you'd prefer to keep it out of this PR. Let me know which you'd rather.

Happy to make any other changes if something still looks off.

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