Skip to content

feat(app): split a session's terminal into panes - #170

Merged
TooonyChen merged 2 commits into
mainfrom
feature/terminal-splits
Nov 10, 2026
Merged

TooonyChen merged 2 commits into
mainfrom
feature/terminal-splits

Conversation

@TooonyChen

Copy link
Copy Markdown
Owner

Summary

A session's terminal can now be split into panes. Each new pane is another shell on the same server. Over SSH it is a new channel on the session's client, so there is no second login. In a local terminal, a new pane runs the local program again.

Closes #168

Pane model (internal/app/panes.go)

  • Session.panes is a binary tree (paneTree). Each leaf is a pane. Each inner node has a direction, the first child's share in percent, and two children.
  • What belongs to one terminal moved off Session and onto pane: the terminal, the find bar state, the shell follower (shellDirState) and a local program's title.
  • What belongs to the connection stays on Session: client, SFTP, stats, tunnels and the command bar.
  • Session.term() returns the focused pane's terminal and Session.pane() returns the focused pane. There is no second source of truth. sendLine, the Commands tab, the command editor and the send target all reach a session through term(). So they send to its focused pane, and "All sessions" sends once per session.
  • paneTree.insert(at, p, dir, before) and paneTree.remove(p) are the two operations feat(app): drag a pane to move it within a session's splits #169 needs. Moving a pane is a remove followed by an insert. Both are tested.

Lifecycle

  • connect opens one shell per pane of the tree, one after the other, on a single login. A new session gets one pane. After Reconnect, every pane gets a shell again and the layout is kept, including the ratios.
  • If the first shell fails, the connection fails, as before. If the server refuses a later one, that pane is left out and a toast explains why.
  • Splitting opens the shell off the UI thread. The pane is added only once the shell is open. If the server refuses it (an OpenChannelError with reason Prohibited, i.e. OpenSSH MaxSessions), a toast says so and nothing is added.
  • When a pane's shell ends (paneExited) and other panes remain, the app sends a keepalive request on the connection:
    • if the server answers, only that pane closes;
    • if it doesn't, the session shows "Connection lost" as before, and the tree is kept for Reconnect.
    • A single pane behaves exactly as before.
  • Cmd+W / Ctrl+Shift+W closes the focused pane while there are several. With one pane left, it closes the session.

The shell's folder and the agent icon per pane (internal/monitor/shelldir.go)

  • The /proc script used to print nothing when the connection had more than one tty shell. With panes, that would have turned off folder following and the agent icon for every split session.
  • ShellState now takes a ShellPick{Index, Count}. The script sorts the tty children of sshd by start time and takes the Index-th. If their number isn't Count, for example while a pane is opening or closing, it prints nothing.
  • Each pane's follower keeps its place in open order (paneTree.order, held in an atomic) and reads with it.
  • The file panel follows the focused pane, and moves to that pane's folder when the focus changes.

UI (internal/app/paneview.go)

  • Panes are laid out with nested ui.SplitPercent / ui.SplitVerticalPercent. Double-clicking a divider gives both sides half (SplitReset(50)).
  • Dragging is clamped so no pane gets smaller than 120 DIPs wide or 60 DIPs high. For this, splitMoved was split into clampSplit plus the part that saves the layout. Pane ratios are not saved.
  • The dividers use a theme whose border color is the terminal's foreground at 16%. This gives a faint line between dark panes instead of the light window border.
  • The focus border (2 px, accent) and the dim veil over unfocused panes are painted with DrawOver. They take no input.
  • A click focuses a pane through ObserveInput, which doesn't consume the event. So selection and mouse reporting still reach the terminal; a test checks that a click in an unfocused pane delivers a mouse report.
  • The session row shows a pill after the name (grid icon + count) when there is more than one pane. The command bar shows the placeholder "Sends to the focused pane" while the session is split.

Context menu

  • MyGo's terminal.View calls ContextMenu(v.menu) itself, and the innermost element with a context menu wins. The view's menu also has no hook, and there is no public API to copy the selection. So the app cannot add items to the terminal's menu, or replace it without losing Copy.
  • Alternative: the split items (Split right, Split down, Zoom this pane, Equalize panes, Close this pane, with their keys) are in the session row's context menu, plus the shortcuts.
  • The smallest MyGo-side change would be a terminal.Options.ContextMenu func(m *ui.Menu) hook whose items the view adds above its own. That would let these items move into the terminal's menu as in the mockup.

Shortcuts (on the Shortcuts page, all changeable)

Action macOS (Ghostty) Windows/Linux (Windows Terminal)
Split right Cmd+D Alt+Shift+=
Split down Cmd+Shift+D Alt+Shift+-
Zoom / back Cmd+Shift+Enter Ctrl+Shift+X
Focus left/right/up/down Cmd+Option+arrows Alt+arrows

Conflicts checked:

  • None of these keys clashes with an existing default, with MyGo's terminal default keybinds (a new assertion in TestShortcutDefaults), or with the macOS system keys. Zoom Cmd+Shift+Enter is separate from full screen Cmd+Enter.
  • Windows Terminal has no default zoom key. I chose Ctrl+Shift+X, which is Terminator's zoom key. Ctrl+Shift+Z would have taken Redo from text fields such as the command bar.
  • Alt+arrows are word movement in some shells (fish, some readline setups), and Alt+Shift+- is bash's M-_ (yank last argument). To limit this:
    • the focus and zoom keys are taken only while the shown session is split, so a single-pane session's shell still gets Alt+arrows;
    • the split keys are taken only where a split is possible.
  • checkShortcut used to refuse Alt + a shell key. Pane actions now carry an alt flag, so they accept it, and the capture dialog shows a yellow warning (shortcutShellAltWarn).
  • New settings keys: splitRight, splitDown, zoomPane, focusPaneLeft/Right/Up/Down.

Other

  • sshtest gained Logins, Shells, ShellInput, EndShell and LimitSessions (a MaxSessions-like refusal).
  • stepError now has Unwrap.
  • New strings in both locales, with locales/catalog_gen.go regenerated.
  • Docs:
    • features.md: a Splits section, the Keyboard shortcuts table and notes, the Shortcuts page exceptions and keys.
    • development.md: the pane model and the file list.
    • Both READMEs: a paragraph in Terminal and the shortcut rows.
    • Both languages use the same headings. go run ./tools/screenshots left docs/screenshots/ unchanged.

Compared with the mockup

Matches:

  • three panes (one left, two stacked right);
  • the blue 2 px border on the focused pane and the dimmed others;
  • the row badge "▦ 3";
  • the bar placeholder "发到获得焦点的分屏";
  • the menu items and their order (Split right, Split down | Zoom this pane, Equalize | Close this pane) with the key labels;
  • the Chinese labels 向右分屏 / 向下分屏 / 放大此分屏 / 平均分配 / 关闭此分屏.

Differences:

  • The menu is on the session row, not on a right-click in the terminal (see Context menu above). Right-clicking the terminal still opens MyGo's Copy/Paste/Select All/Clear menu.
  • The English label is "Equalize panes" rather than just "Equalize", so it reads clearly in the row menu next to the session items.
  • Zoom has no on-screen marker other than the single pane; the row badge still shows the count.
  • The Windows/Linux zoom key is Ctrl+Shift+X (the mockup didn't name one).

Testing

  • New tests:
    • TestPaneTree: tree shape, rects, focus by direction, remove/re-insert (move), focus after removal, equalize.
    • TestSplitPanes (internal/sshtest):
      • split by key and by the row menu, nested; one login, three shells;
      • focus by keys and by click;
      • bar, quick command and command editor each reach only the focused pane's shell;
      • zoom on and off;
      • divider drag clamped at 120 DIPs, double-click back to 50%;
      • a click in an unfocused pane delivers a mouse report and focuses it.
    • TestClosePanes: a shell that exits closes its pane; Ctrl+Shift+W closes the focused pane, then the session.
    • TestPanesReconnect: DropConnections gives Connection lost with the layout kept. Reconnect gives one more login, three new shells, the same shape and ratio, and input reaching the right pane. With LimitSessions reached, a split shows the MaxSessions toast and adds nothing.
    • TestPanesSendToAll: "All sessions" sends once to the focused pane of each session.
    • TestPanesFollowFocusedShell: each pane reads with its own ShellPick; focusing a pane moves the file panel to its folder.
    • TestLocalPanes: split a local terminal; a pane's program exits; the last exit ends the tab.
    • TestShellDirScriptPanes: the real /proc script on this machine, with two shells on separate ptys, picks each by start order, and prints nothing when the count doesn't match.
    • TestShortcutAltWarning, plus new cases in TestShortcutChecks and TestShortcutDefaults.
    • TestPanesScreenshots: screenshots in en and zh-Hans.
  • Existing tests were updated for s.term() / s.pane() / setTerm.
  • go fmt ./... (no changes), go build ./..., go vet ./..., GOOS=windows go vet ./..., GOOS=darwin go vet ., go tool mygo vet, go tool mygo generate (up to date) and go test -race ./... all pass. The pane tests passed 5 runs in a row with -race.
  • Screenshots (English and Simplified Chinese) were compared with the mockup: three panes, the row menu open, zoomed.

Not tested:

  • A real OpenSSH server: FINALLYSHELL_TEST_SSH is not set here. The /proc pick logic was tested only against a local process tree.
  • macOS and Windows by hand. The macOS keys were checked only by the shortcut tests, not on a Mac.
  • The Ghostty-style keys against macOS menu key equivalents in the packaged app.

Generated by Claude Code

A session holds a tree of panes; each pane is another shell on the
session's SSH connection (or another run of a local terminal's program),
with its own terminal, find bar and shell follower. The focused pane is
the session's terminal, which the command bar, the Commands tab and the
command editor send to and the file panel follows. Splitting, zoom,
focus by direction and closing the focused pane have shortcuts (Ghostty's
on macOS, Windows Terminal's elsewhere) and items in the session row's
menu. A pane whose shell exits closes; a dropped connection keeps the
layout for Reconnect. The shell follower reads one shell of several on a
connection by the order they started in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nMqJHsqtndDSqJDPp1qYv
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nMqJHsqtndDSqJDPp1qYv
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 10, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-10T01:57:20.503304Z bd53b33 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bd53b33699

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/app/paneview.go
Comment on lines +59 to +62
go func() {
term, err := a.openPaneShell(s, p, client, spec)
a.post(func() { a.paneOpened(s, at, p, dir, client, spec, term, err) })
}()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Serialize shell creation before assigning pane order

If a user invokes Split twice before the first request completes, both calls open SSH shells concurrently. paneOpened then appends panes to panes.order in completion order, which need not match the remote processes' start order: sshx.OpenShell waits for several SSH replies after allocating the session. Since monitor.ShellState identifies shells by process start order, reversed completions permanently assign the two panes each other's directory and foreground-agent state. Serialize shell creation per connection, or identify each remote shell independently of callback completion order.

Useful? React with 👍 / 👎.

Comment thread internal/app/paneview.go
Comment on lines +82 to +85
s.panes.focus, s.panes.zoomed, s.panes.refocus = p, false, true
s.panes.opened(p)
if !s.isLocal() {
a.startPane(s, p, spec)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Follow the new pane's directory when splitting

When the current shell has changed directories and the file panel follows it, splitting focuses a newly started login shell but leaves the panel in the previous pane's directory. This assignment bypasses focusPane and its followShellDir call, while startShellDir treats the new shell's initial directory reads as baseline reads that explicitly do not move the panel. Consequently, the panel can remain on the wrong directory indefinitely until another command or focus change occurs. Arrange for the newly focused pane's first directory result to update the panel when terminal-directory following is enabled.

Useful? React with 👍 / 👎.

@TooonyChen
TooonyChen merged commit 67fd060 into main Nov 10, 2026
1 check passed
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.

feat(app): split a session's terminal into panes

2 participants