Repository navigation
feat(app): split a session's terminal into panes - #170
Conversation
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
Codex Review Summary: ✅ CompletedThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 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".
| go func() { | ||
| term, err := a.openPaneShell(s, p, client, spec) | ||
| a.post(func() { a.paneOpened(s, at, p, dir, client, spec, term, err) }) | ||
| }() |
There was a problem hiding this comment.
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 👍 / 👎.
| s.panes.focus, s.panes.zoomed, s.panes.refocus = p, false, true | ||
| s.panes.opened(p) | ||
| if !s.isLocal() { | ||
| a.startPane(s, p, spec) |
There was a problem hiding this comment.
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 👍 / 👎.
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.panesis a binary tree (paneTree). Each leaf is apane. Each inner node has a direction, the first child's share in percent, and two children.Sessionand ontopane: the terminal, the find bar state, the shell follower (shellDirState) and a local program's title.Session: client, SFTP, stats, tunnels and the command bar.Session.term()returns the focused pane's terminal andSession.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 throughterm(). So they send to its focused pane, and "All sessions" sends once per session.paneTree.insert(at, p, dir, before)andpaneTree.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
connectopens 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.OpenChannelErrorwith reason Prohibited, i.e. OpenSSHMaxSessions), a toast says so and nothing is added.paneExited) and other panes remain, the app sends a keepalive request on the connection:The shell's folder and the agent icon per pane (
internal/monitor/shelldir.go)/procscript 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.ShellStatenow takes aShellPick{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.paneTree.order, held in an atomic) and reads with it.UI (
internal/app/paneview.go)ui.SplitPercent/ui.SplitVerticalPercent. Double-clicking a divider gives both sides half (SplitReset(50)).splitMovedwas split intoclampSplitplus the part that saves the layout. Pane ratios are not saved.DrawOver. They take no input.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.Context menu
terminal.ViewcallsContextMenu(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.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)
Conflicts checked:
TestShortcutDefaults), or with the macOS system keys. Zoom Cmd+Shift+Enter is separate from full screen Cmd+Enter.checkShortcutused to refuse Alt + a shell key. Pane actions now carry analtflag, so they accept it, and the capture dialog shows a yellow warning (shortcutShellAltWarn).splitRight,splitDown,zoomPane,focusPaneLeft/Right/Up/Down.Other
sshtestgainedLogins,Shells,ShellInput,EndShellandLimitSessions(a MaxSessions-like refusal).stepErrornow hasUnwrap.locales/catalog_gen.goregenerated.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.go run ./tools/screenshotsleftdocs/screenshots/unchanged.Compared with the mockup
Matches:
Differences:
Testing
TestPaneTree: tree shape, rects, focus by direction, remove/re-insert (move), focus after removal, equalize.TestSplitPanes(internal/sshtest):TestClosePanes: a shell that exits closes its pane; Ctrl+Shift+W closes the focused pane, then the session.TestPanesReconnect:DropConnectionsgives 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. WithLimitSessionsreached, 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 ownShellPick; 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/procscript 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 inTestShortcutChecksandTestShortcutDefaults.TestPanesScreenshots: screenshots in en and zh-Hans.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) andgo test -race ./...all pass. The pane tests passed 5 runs in a row with-race.Not tested:
FINALLYSHELL_TEST_SSHis not set here. The/procpick logic was tested only against a local process tree.Generated by Claude Code