Merge pull request #75975 from NousResearch/bb/terminal-session-link

Link terminal tabs to the session you're working in
This commit is contained in:
brooklyn!
2026-08-01 02:22:30 -05:00
committed by GitHub
6 changed files with 136 additions and 12 deletions

View File

@@ -33,4 +33,20 @@ describe('TerminalRail', () => {
expect(label?.classList.contains('inline-flex')).toBe(true)
expect(label?.classList.contains('flex')).toBe(false)
})
it('⌘-click closes the tab; a plain click selects it', () => {
$terminals.set([
...$terminals.get(),
{ auto: true, cwd: 'C:\\repo', id: 'term-2', kind: 'user', title: 'zsh' }
])
render(<TerminalRail />)
fireEvent.click(screen.getByRole('tab', { name: '2. zsh' }), { metaKey: true })
expect($terminals.get().map(term => term.id)).toEqual(['term-1'])
fireEvent.click(screen.getByRole('tab', { name: '1. PowerShell' }))
expect($activeTerminalId.get()).toBe('term-1')
expect($terminals.get()).toHaveLength(1)
})
})

View File

@@ -11,7 +11,7 @@ import {
import { Tip, TipHintLabel } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { formatCombo } from '@/lib/keybinds/combo'
import { middleClickHandlers } from '@/lib/middle-click'
import { isMetaClose, middleClickHandlers } from '@/lib/middle-click'
import { cn } from '@/lib/utils'
import { $bindings } from '@/store/keybinds'
@@ -132,7 +132,8 @@ function TerminalRailItem({ active, canCloseOthers, index, term, toggleHint }: T
: 'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
)}
{...middleClickHandlers(() => closeTerminal(term.id))}
onClick={() => selectTerminal(term.id)}
// ⌘-click closes (the pane-tab gesture); a plain click selects.
onClick={event => (isMetaClose(event) ? closeTerminal(term.id) : selectTerminal(term.id))}
role="tab"
type="button"
>

View File

@@ -4,11 +4,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const STORAGE_KEY = 'hermes.desktop.terminals.v1'
async function loadTerminalStore() {
const $currentCwd = atom('/workspace')
vi.doMock('@/store/session', () => ({
$currentCwd: atom('/workspace')
$currentCwd
}))
return import('./terminals')
return { ...(await import('./terminals')), $currentCwd }
}
describe('terminal store persistence', () => {
@@ -121,3 +123,68 @@ describe('terminal store persistence', () => {
expect($terminals.get().find(term => term.id === userId)?.restoreCwd).toBeUndefined()
})
})
describe('session cwd → terminal tab linking', () => {
beforeEach(() => {
window.localStorage.clear()
vi.resetModules()
})
it('re-selects the tab already pointed at the new session cwd (trailing slash tolerated)', async () => {
const { $activeTerminalId, $currentCwd, createTerminal } = await loadTerminalStore()
const repoTab = createTerminal('/repo')
const otherTab = createTerminal('/elsewhere')
expect($activeTerminalId.get()).toBe(otherTab)
$currentCwd.set('/repo/')
expect($activeTerminalId.get()).toBe(repoTab)
})
it('matches the live shell cwd (restoreCwd) over the launch dir', async () => {
const { $activeTerminalId, $currentCwd, createTerminal, updateTerminalRestoreCwd } = await loadTerminalStore()
const movedTab = createTerminal('/repo')
updateTerminalRestoreCwd(movedTab, '/repo/packages/api')
const otherTab = createTerminal('/elsewhere')
expect($activeTerminalId.get()).toBe(otherTab)
$currentCwd.set('/repo/packages/api')
expect($activeTerminalId.get()).toBe(movedTab)
// The launch dir no longer describes where that shell lives.
$currentCwd.set('/repo')
expect($activeTerminalId.get()).toBe(movedTab)
})
it('leaves the active tab alone when no tab lives in the session cwd or the cwd is empty', async () => {
const { $activeTerminalId, $currentCwd, createTerminal } = await loadTerminalStore()
createTerminal('/repo')
const activeTab = createTerminal('/elsewhere')
$currentCwd.set('/unrelated')
expect($activeTerminalId.get()).toBe(activeTab)
$currentCwd.set('')
expect($activeTerminalId.get()).toBe(activeTab)
})
it('stays put when the active tab already lives in the target cwd, and never matches agent tabs', async () => {
const { $activeTerminalId, $currentCwd, createTerminal, ensureAgentTerminal, selectTerminal } =
await loadTerminalStore()
const first = createTerminal('/repo')
const second = createTerminal('/repo')
ensureAgentTerminal('proc-1', 'background task')
selectTerminal(second)
// Both tabs match; the one already active keeps focus (no first-match steal).
$currentCwd.set('/repo')
expect($activeTerminalId.get()).toBe(second)
selectTerminal(first)
$currentCwd.set('/repo')
expect($activeTerminalId.get()).toBe(first)
})
})

View File

@@ -17,7 +17,8 @@ export interface TerminalEntry {
/** Working directory, snapshotted once at creation. Terminals live outside
* session/project state — the only thing they inherit is this initial cwd
* (the project root if opened in one, else the backend's default). Switching
* sessions never moves or recreates a terminal. */
* sessions never moves or recreates a terminal; at most it re-SELECTS a tab
* already pointed at the session's cwd (see the $currentCwd listener). */
cwd: string
/** Last observed working directory of the live shell (tracked via the PTY
* cwd probe / OSC 7). Used to reopen the tab where the user last `cd`'d
@@ -223,6 +224,45 @@ export function selectTerminal(id: string): void {
}
}
// Compare-ready form of a directory path: trimmed, trailing separators dropped
// (keeping a bare root intact) so `/repo/` and `/repo` are the same place.
const normalizePath = (value: string) => {
const trimmed = value.trim()
return trimmed.length > 1 ? trimmed.replace(/[\\/]+$/, '') || trimmed : trimmed
}
/** The directory a tab points at right now — the live shell cwd once observed
* (survives a `cd`), else the launch dir. */
const terminalCwd = (term: TerminalEntry) => normalizePath(term.restoreCwd || term.cwd)
// Session ↔ terminal linking. Entering a session whose cwd already has a user
// terminal pointed at it re-selects that tab, so the terminal pane follows the
// workspace you're in. Selection ONLY — it never creates a shell, never closes
// one, and never reveals the pane; a detached session (empty cwd) or a cwd no
// tab lives in leaves the tabs exactly where they were. `listen` (not
// `subscribe`) so boot keeps the persisted active tab.
$currentCwd.listen(cwd => {
const target = normalizePath(cwd)
if (!target) {
return
}
const list = $terminals.get()
const active = list.find(term => term.id === $activeTerminalId.get())
if (active?.kind === 'user' && terminalCwd(active) === target) {
return
}
const match = list.find(term => term.kind === 'user' && terminalCwd(term) === target)
if (match) {
$activeTerminalId.set(match.id)
}
})
/** Move the active tab by `direction` (+1 next / -1 prev), wrapping around. */
export function cycleTerminal(direction: 1 | -1): void {
const list = $terminals.get()

View File

@@ -1,6 +1,6 @@
import * as React from 'react'
import { middleClickHandlers } from '@/lib/middle-click'
import { isMetaClose, middleClickHandlers } from '@/lib/middle-click'
import { cn } from '@/lib/utils'
/** Inset stroke for a vertical tab rail — content-facing edge. */
@@ -43,12 +43,6 @@ interface PaneTabProps extends React.ComponentProps<'div'> {
side?: 'left' | 'right'
}
/** ⌘-click (metaKey + primary button) — the Mac has no middle button, so this
* is the trackpad equivalent of middle-click-to-close. Guarded on metaKey so
* it never collides with left-click (activate/drag) or ⌃-click (macOS context
* menu). */
const isMetaClose = (event: { button: number; metaKey: boolean }) => event.button === 0 && event.metaKey
/**
* Editor tab shell — preview rail + zone headers + collapsed vertical rails.
*

View File

@@ -3,6 +3,12 @@ import type * as React from 'react'
/** `MouseEvent.button` for the middle (wheel) button. */
const MIDDLE_BUTTON = 1
/** ⌘-click (metaKey + primary button) — the Mac has no middle button, so this
* is the trackpad equivalent of middle-click-to-close. Guarded on metaKey so
* it never collides with left-click (activate/drag) or ⌃-click (macOS context
* menu). */
export const isMetaClose = (event: { button: number; metaKey: boolean }) => event.button === 0 && event.metaKey
/** Where the current middle press started. One pointer holds one button, so a
* single slot is the whole state, and it's only ever compared by identity in
* the pointerup right after — a value left behind by a press released