88 useRef ,
99 useState ,
1010} from 'react'
11- import { cn , TabStrip , type TabStripItem } from '@sim/emcn'
11+ import { cn , TabStrip , type TabStripItem , toast } from '@sim/emcn'
1212import { Loader , TerminalWindow } from '@sim/emcn/icons'
1313import { createLogger } from '@sim/logger'
1414import { FitAddon } from '@xterm/addon-fit'
@@ -31,8 +31,10 @@ import {
3131 writeToTerminal ,
3232} from '@/lib/terminal/transport'
3333import { useMothershipResources } from '@/app/workspace/[workspaceId]/home/components/mothership-resources-context'
34+ import { TerminalContextMenu } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-context-menu'
3435import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu'
3536import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
37+ import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
3638import { useCopilotTerminalStore } from '@/stores/copilot-terminal/store'
3739
3840const logger = createLogger ( 'TerminalSession' )
@@ -132,7 +134,16 @@ function attachWebglRenderer(terminal: Terminal): void {
132134 * visible, and only it is measured — `fit()` against a hidden element reads a
133135 * zero-sized box and would resize the PTY to nonsense.
134136 */
135- function TerminalView ( { terminalId, active } : { terminalId : string ; active : boolean } ) {
137+ function TerminalView ( {
138+ terminalId,
139+ active,
140+ canClose,
141+ } : {
142+ terminalId : string
143+ active : boolean
144+ canClose : boolean
145+ } ) {
146+ const { navigateToSettings } = useSettingsNavigation ( )
136147 const { resolvedTheme } = useTheme ( )
137148 const hostRef = useRef < HTMLDivElement > ( null )
138149 const terminalRef = useRef < Terminal | null > ( null )
@@ -264,13 +275,99 @@ function TerminalView({ terminalId, active }: { terminalId: string; active: bool
264275 return ( ) => cancelAnimationFrame ( frame )
265276 } , [ active ] )
266277
278+ const {
279+ isOpen : isMenuOpen ,
280+ position : menuPosition ,
281+ menuRef,
282+ handleContextMenu,
283+ closeMenu,
284+ } = useContextMenu ( )
285+ // Read at open time so Copy reflects the selection the click landed on.
286+ const [ hasSelection , setHasSelection ] = useState ( false )
287+
288+ const openMenu = useCallback (
289+ ( event : React . MouseEvent ) => {
290+ setHasSelection ( Boolean ( terminalRef . current ?. hasSelection ( ) ) )
291+ // Suppresses Electron's native editable-field menu, which would
292+ // otherwise target xterm's offscreen input textarea.
293+ handleContextMenu ( event )
294+ } ,
295+ [ handleContextMenu ]
296+ )
297+
298+ const copySelection = useCallback ( ( ) => {
299+ const selection = terminalRef . current ?. getSelection ( )
300+ if ( selection ) void navigator . clipboard . writeText ( selection )
301+ } , [ ] )
302+
303+ const pasteClipboard = useCallback ( ( ) => {
304+ void navigator . clipboard
305+ . readText ( )
306+ . then ( ( text ) => {
307+ if ( ! text ) return
308+ // Straight to the PTY: the shell echoes it, exactly like a real paste.
309+ writeToTerminal ( terminalId , text )
310+ terminalRef . current ?. focus ( )
311+ } )
312+ . catch ( ( ) => {
313+ // Reading the clipboard needs a permission the shell grants to its own
314+ // origin; an older shell that predates that grant denies it. Keyboard
315+ // paste is a native paste event and keeps working either way.
316+ toast . error ( 'Could not read the clipboard. Press ⌘V to paste.' )
317+ } )
318+ } , [ terminalId ] )
319+
320+ const selectAll = useCallback ( ( ) => {
321+ terminalRef . current ?. selectAll ( )
322+ } , [ ] )
323+
324+ const clearScreen = useCallback ( ( ) => {
325+ terminalRef . current ?. clear ( )
326+ terminalRef . current ?. focus ( )
327+ } , [ ] )
328+
329+ const newTab = useCallback ( ( ) => {
330+ void openTerminal ( )
331+ } , [ ] )
332+
333+ const openTerminalSettings = useCallback ( ( ) => {
334+ navigateToSettings ( { section : 'terminal' } )
335+ } , [ navigateToSettings ] )
336+
337+ // Scoped to the terminal that was right-clicked, not the active one.
338+ const closeThisTerminal = useCallback ( ( ) => {
339+ void closeTerminal ( terminalId )
340+ } , [ terminalId ] )
341+
267342 // An inactive tab is `display: none`, not merely invisible. xterm watches its
268343 // element with an IntersectionObserver and pauses rendering once it stops
269344 // intersecting — which `visibility: hidden` never does, since it still
270345 // occupies its box. Left that way, every background terminal keeps painting
271346 // output nobody is looking at, out of the active terminal's frame budget.
272347 // xterm re-measures and does a full refresh when the element comes back.
273- return < div ref = { hostRef } className = { cn ( 'absolute inset-0 px-2 py-1' , ! active && 'hidden' ) } />
348+ return (
349+ < >
350+ < div
351+ ref = { hostRef }
352+ onContextMenu = { openMenu }
353+ className = { cn ( 'absolute inset-0 px-2 py-1' , ! active && 'hidden' ) }
354+ />
355+ < TerminalContextMenu
356+ isOpen = { isMenuOpen }
357+ position = { menuPosition }
358+ menuRef = { menuRef }
359+ onClose = { closeMenu }
360+ hasSelection = { hasSelection }
361+ onCopy = { copySelection }
362+ onPaste = { pasteClipboard }
363+ onSelectAll = { selectAll }
364+ onClear = { clearScreen }
365+ onNewTab = { newTab }
366+ onOpenSettings = { openTerminalSettings }
367+ { ...( canClose ? { onCloseTerminal : closeThisTerminal } : { } ) }
368+ />
369+ </ >
370+ )
274371}
275372
276373/**
@@ -396,6 +493,7 @@ export function TerminalSession() {
396493 key = { tab . terminalId }
397494 terminalId = { tab . terminalId }
398495 active = { tab . terminalId === activeTerminalId }
496+ canClose = { tabs . length > 1 }
399497 />
400498 ) ) }
401499 { startError && (
0 commit comments