@@ -9,6 +9,10 @@ import {
99 Terminal ,
1010 ZoomIn ,
1111 ZoomOut ,
12+ Copy ,
13+ ClipboardPaste ,
14+ CheckSquare ,
15+ Trash2 ,
1216} from "lucide-react" ;
1317import { Button } from "@/components/ui/button" ;
1418import { cn } from "@/lib/utils" ;
@@ -66,6 +70,27 @@ export function TerminalPanel({
6670 const focusHandlerRef = useRef < { dispose : ( ) => void } | null > ( null ) ;
6771 const [ isTerminalReady , setIsTerminalReady ] = useState ( false ) ;
6872 const [ shellName , setShellName ] = useState ( "shell" ) ;
73+ const [ contextMenu , setContextMenu ] = useState < { x : number ; y : number } | null > ( null ) ;
74+ const [ isMac , setIsMac ] = useState ( false ) ;
75+ const isMacRef = useRef ( false ) ;
76+ const contextMenuRef = useRef < HTMLDivElement > ( null ) ;
77+ const [ focusedMenuIndex , setFocusedMenuIndex ] = useState ( 0 ) ;
78+ const focusedMenuIndexRef = useRef ( 0 ) ;
79+
80+ // Detect platform on mount
81+ useEffect ( ( ) => {
82+ // Use modern userAgentData API with fallback to navigator.platform
83+ const nav = navigator as Navigator & { userAgentData ?: { platform : string } } ;
84+ let detected = false ;
85+ if ( nav . userAgentData ?. platform ) {
86+ detected = nav . userAgentData . platform . toLowerCase ( ) . includes ( "mac" ) ;
87+ } else if ( typeof navigator !== "undefined" ) {
88+ // Fallback for browsers without userAgentData (intentionally using deprecated API)
89+ detected = / m a c / i. test ( navigator . platform ) ;
90+ }
91+ setIsMac ( detected ) ;
92+ isMacRef . current = detected ;
93+ } , [ ] ) ;
6994
7095 // Get effective theme from store
7196 const getEffectiveTheme = useAppStore ( ( state ) => state . getEffectiveTheme ) ;
@@ -84,6 +109,8 @@ export function TerminalPanel({
84109 fontSizeRef . current = fontSize ;
85110 const themeRef = useRef ( effectiveTheme ) ;
86111 themeRef . current = effectiveTheme ;
112+ const copySelectionRef = useRef < ( ) => Promise < boolean > > ( ( ) => Promise . resolve ( false ) ) ;
113+ const pasteFromClipboardRef = useRef < ( ) => Promise < void > > ( ( ) => Promise . resolve ( ) ) ;
87114
88115 // Zoom functions - use the prop callback
89116 const zoomIn = useCallback ( ( ) => {
@@ -98,6 +125,75 @@ export function TerminalPanel({
98125 onFontSizeChange ( DEFAULT_FONT_SIZE ) ;
99126 } , [ onFontSizeChange ] ) ;
100127
128+ // Copy selected text to clipboard
129+ const copySelection = useCallback ( async ( ) : Promise < boolean > => {
130+ const terminal = xtermRef . current ;
131+ if ( ! terminal ) return false ;
132+
133+ const selection = terminal . getSelection ( ) ;
134+ if ( ! selection ) return false ;
135+
136+ try {
137+ await navigator . clipboard . writeText ( selection ) ;
138+ return true ;
139+ } catch ( err ) {
140+ console . error ( "[Terminal] Copy failed:" , err ) ;
141+ return false ;
142+ }
143+ } , [ ] ) ;
144+ copySelectionRef . current = copySelection ;
145+
146+ // Paste from clipboard
147+ const pasteFromClipboard = useCallback ( async ( ) => {
148+ const terminal = xtermRef . current ;
149+ if ( ! terminal || ! wsRef . current ) return ;
150+
151+ try {
152+ const text = await navigator . clipboard . readText ( ) ;
153+ if ( text && wsRef . current . readyState === WebSocket . OPEN ) {
154+ wsRef . current . send ( JSON . stringify ( { type : "input" , data : text } ) ) ;
155+ }
156+ } catch ( err ) {
157+ console . error ( "[Terminal] Paste failed:" , err ) ;
158+ }
159+ } , [ ] ) ;
160+ pasteFromClipboardRef . current = pasteFromClipboard ;
161+
162+ // Select all terminal content
163+ const selectAll = useCallback ( ( ) => {
164+ xtermRef . current ?. selectAll ( ) ;
165+ } , [ ] ) ;
166+
167+ // Clear terminal
168+ const clearTerminal = useCallback ( ( ) => {
169+ xtermRef . current ?. clear ( ) ;
170+ } , [ ] ) ;
171+
172+ // Close context menu
173+ const closeContextMenu = useCallback ( ( ) => {
174+ setContextMenu ( null ) ;
175+ } , [ ] ) ;
176+
177+ // Handle context menu action
178+ const handleContextMenuAction = useCallback ( async ( action : "copy" | "paste" | "selectAll" | "clear" ) => {
179+ closeContextMenu ( ) ;
180+ switch ( action ) {
181+ case "copy" :
182+ await copySelection ( ) ;
183+ break ;
184+ case "paste" :
185+ await pasteFromClipboard ( ) ;
186+ break ;
187+ case "selectAll" :
188+ selectAll ( ) ;
189+ break ;
190+ case "clear" :
191+ clearTerminal ( ) ;
192+ break ;
193+ }
194+ xtermRef . current ?. focus ( ) ;
195+ } , [ closeContextMenu , copySelection , pasteFromClipboard , selectAll , clearTerminal ] ) ;
196+
101197 const serverUrl = process . env . NEXT_PUBLIC_SERVER_URL || "http://localhost:3008" ;
102198 const wsUrl = serverUrl . replace ( / ^ h t t p / , "ws" ) ;
103199
@@ -263,6 +359,43 @@ export function TerminalPanel({
263359 return false ;
264360 }
265361
362+ const modKey = isMacRef . current ? event . metaKey : event . ctrlKey ;
363+ const otherModKey = isMacRef . current ? event . ctrlKey : event . metaKey ;
364+
365+ // Ctrl+Shift+C / Cmd+Shift+C - Always copy (Linux terminal convention)
366+ if ( modKey && ! otherModKey && event . shiftKey && ! event . altKey && code === 'KeyC' ) {
367+ event . preventDefault ( ) ;
368+ copySelectionRef . current ( ) ;
369+ return false ;
370+ }
371+
372+ // Ctrl+C / Cmd+C - Copy if text is selected, otherwise send SIGINT
373+ if ( modKey && ! otherModKey && ! event . shiftKey && ! event . altKey && code === 'KeyC' ) {
374+ const hasSelection = terminal . hasSelection ( ) ;
375+ if ( hasSelection ) {
376+ event . preventDefault ( ) ;
377+ copySelectionRef . current ( ) ;
378+ terminal . clearSelection ( ) ;
379+ return false ;
380+ }
381+ // No selection - let xterm handle it (sends SIGINT)
382+ return true ;
383+ }
384+
385+ // Ctrl+V / Cmd+V or Ctrl+Shift+V / Cmd+Shift+V - Paste
386+ if ( modKey && ! otherModKey && ! event . altKey && code === 'KeyV' ) {
387+ event . preventDefault ( ) ;
388+ pasteFromClipboardRef . current ( ) ;
389+ return false ;
390+ }
391+
392+ // Ctrl+A / Cmd+A - Select all
393+ if ( modKey && ! otherModKey && ! event . shiftKey && ! event . altKey && code === 'KeyA' ) {
394+ event . preventDefault ( ) ;
395+ terminal . selectAll ( ) ;
396+ return false ;
397+ }
398+
266399 // Let xterm handle all other keys
267400 return true ;
268401 } ) ;
@@ -548,6 +681,108 @@ export function TerminalPanel({
548681 return ( ) => container . removeEventListener ( "wheel" , handleWheel ) ;
549682 } , [ zoomIn , zoomOut ] ) ;
550683
684+ // Context menu actions for keyboard navigation
685+ const menuActions = [ "copy" , "paste" , "selectAll" , "clear" ] as const ;
686+
687+ // Keep ref in sync with state for use in event handlers
688+ useEffect ( ( ) => {
689+ focusedMenuIndexRef . current = focusedMenuIndex ;
690+ } , [ focusedMenuIndex ] ) ;
691+
692+ // Close context menu on click outside or scroll, handle keyboard navigation
693+ useEffect ( ( ) => {
694+ if ( ! contextMenu ) return ;
695+
696+ // Reset focus index and focus menu when opened
697+ setFocusedMenuIndex ( 0 ) ;
698+ focusedMenuIndexRef . current = 0 ;
699+ requestAnimationFrame ( ( ) => {
700+ const firstButton = contextMenuRef . current ?. querySelector < HTMLButtonElement > ( '[role="menuitem"]' ) ;
701+ firstButton ?. focus ( ) ;
702+ } ) ;
703+
704+ const handleClick = ( ) => closeContextMenu ( ) ;
705+ const handleScroll = ( ) => closeContextMenu ( ) ;
706+ const handleKeyDown = ( e : KeyboardEvent ) => {
707+ const updateFocusIndex = ( newIndex : number ) => {
708+ focusedMenuIndexRef . current = newIndex ;
709+ setFocusedMenuIndex ( newIndex ) ;
710+ } ;
711+
712+ switch ( e . key ) {
713+ case "Escape" :
714+ e . preventDefault ( ) ;
715+ closeContextMenu ( ) ;
716+ break ;
717+ case "ArrowDown" :
718+ e . preventDefault ( ) ;
719+ updateFocusIndex ( ( focusedMenuIndexRef . current + 1 ) % menuActions . length ) ;
720+ break ;
721+ case "ArrowUp" :
722+ e . preventDefault ( ) ;
723+ updateFocusIndex ( ( focusedMenuIndexRef . current - 1 + menuActions . length ) % menuActions . length ) ;
724+ break ;
725+ case "Enter" :
726+ case " " :
727+ e . preventDefault ( ) ;
728+ handleContextMenuAction ( menuActions [ focusedMenuIndexRef . current ] ) ;
729+ break ;
730+ case "Tab" :
731+ e . preventDefault ( ) ;
732+ closeContextMenu ( ) ;
733+ break ;
734+ }
735+ } ;
736+
737+ document . addEventListener ( "click" , handleClick ) ;
738+ document . addEventListener ( "scroll" , handleScroll , true ) ;
739+ document . addEventListener ( "keydown" , handleKeyDown ) ;
740+
741+ return ( ) => {
742+ document . removeEventListener ( "click" , handleClick ) ;
743+ document . removeEventListener ( "scroll" , handleScroll , true ) ;
744+ document . removeEventListener ( "keydown" , handleKeyDown ) ;
745+ } ;
746+ } , [ contextMenu , closeContextMenu , handleContextMenuAction ] ) ;
747+
748+ // Focus the correct menu item when navigation changes
749+ useEffect ( ( ) => {
750+ if ( ! contextMenu || ! contextMenuRef . current ) return ;
751+ const buttons = contextMenuRef . current . querySelectorAll < HTMLButtonElement > ( '[role="menuitem"]' ) ;
752+ buttons [ focusedMenuIndex ] ?. focus ( ) ;
753+ } , [ focusedMenuIndex , contextMenu ] ) ;
754+
755+ // Handle right-click context menu with boundary checking
756+ const handleContextMenu = useCallback ( ( e : React . MouseEvent ) => {
757+ e . preventDefault ( ) ;
758+ e . stopPropagation ( ) ;
759+
760+ // Menu dimensions (approximate)
761+ const menuWidth = 160 ;
762+ const menuHeight = 152 ; // 4 items + separator + padding
763+ const padding = 8 ;
764+
765+ // Calculate position with boundary checks
766+ let x = e . clientX ;
767+ let y = e . clientY ;
768+
769+ // Check right edge
770+ if ( x + menuWidth + padding > window . innerWidth ) {
771+ x = window . innerWidth - menuWidth - padding ;
772+ }
773+
774+ // Check bottom edge
775+ if ( y + menuHeight + padding > window . innerHeight ) {
776+ y = window . innerHeight - menuHeight - padding ;
777+ }
778+
779+ // Ensure not negative
780+ x = Math . max ( padding , x ) ;
781+ y = Math . max ( padding , y ) ;
782+
783+ setContextMenu ( { x, y } ) ;
784+ } , [ ] ) ;
785+
551786 // Combine refs for the container
552787 const setRefs = useCallback ( ( node : HTMLDivElement | null ) => {
553788 containerRef . current = node ;
@@ -695,7 +930,73 @@ export function TerminalPanel({
695930 ref = { terminalRef }
696931 className = "flex-1 overflow-hidden"
697932 style = { { backgroundColor : currentTerminalTheme . background } }
933+ onContextMenu = { handleContextMenu }
698934 />
935+
936+ { /* Context menu */ }
937+ { contextMenu && (
938+ < div
939+ ref = { contextMenuRef }
940+ role = "menu"
941+ aria-label = "Terminal context menu"
942+ className = "fixed z-50 min-w-[160px] rounded-md border border-border bg-popover p-1 shadow-md animate-in fade-in-0 zoom-in-95"
943+ style = { { left : contextMenu . x , top : contextMenu . y } }
944+ onClick = { ( e ) => e . stopPropagation ( ) }
945+ >
946+ < button
947+ role = "menuitem"
948+ tabIndex = { focusedMenuIndex === 0 ? 0 : - 1 }
949+ className = { cn (
950+ "flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm text-popover-foreground cursor-default outline-none" ,
951+ focusedMenuIndex === 0 ? "bg-accent text-accent-foreground" : "hover:bg-accent hover:text-accent-foreground"
952+ ) }
953+ onClick = { ( ) => handleContextMenuAction ( "copy" ) }
954+ >
955+ < Copy className = "h-4 w-4" />
956+ < span className = "flex-1 text-left" > Copy</ span >
957+ < span className = "text-xs text-muted-foreground" > { isMac ? "⌘C" : "Ctrl+C" } </ span >
958+ </ button >
959+ < button
960+ role = "menuitem"
961+ tabIndex = { focusedMenuIndex === 1 ? 0 : - 1 }
962+ className = { cn (
963+ "flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm text-popover-foreground cursor-default outline-none" ,
964+ focusedMenuIndex === 1 ? "bg-accent text-accent-foreground" : "hover:bg-accent hover:text-accent-foreground"
965+ ) }
966+ onClick = { ( ) => handleContextMenuAction ( "paste" ) }
967+ >
968+ < ClipboardPaste className = "h-4 w-4" />
969+ < span className = "flex-1 text-left" > Paste</ span >
970+ < span className = "text-xs text-muted-foreground" > { isMac ? "⌘V" : "Ctrl+V" } </ span >
971+ </ button >
972+ < div role = "separator" className = "my-1 h-px bg-border" />
973+ < button
974+ role = "menuitem"
975+ tabIndex = { focusedMenuIndex === 2 ? 0 : - 1 }
976+ className = { cn (
977+ "flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm text-popover-foreground cursor-default outline-none" ,
978+ focusedMenuIndex === 2 ? "bg-accent text-accent-foreground" : "hover:bg-accent hover:text-accent-foreground"
979+ ) }
980+ onClick = { ( ) => handleContextMenuAction ( "selectAll" ) }
981+ >
982+ < CheckSquare className = "h-4 w-4" />
983+ < span className = "flex-1 text-left" > Select All</ span >
984+ < span className = "text-xs text-muted-foreground" > { isMac ? "⌘A" : "Ctrl+A" } </ span >
985+ </ button >
986+ < button
987+ role = "menuitem"
988+ tabIndex = { focusedMenuIndex === 3 ? 0 : - 1 }
989+ className = { cn (
990+ "flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm text-popover-foreground cursor-default outline-none" ,
991+ focusedMenuIndex === 3 ? "bg-accent text-accent-foreground" : "hover:bg-accent hover:text-accent-foreground"
992+ ) }
993+ onClick = { ( ) => handleContextMenuAction ( "clear" ) }
994+ >
995+ < Trash2 className = "h-4 w-4" />
996+ < span className = "flex-1 text-left" > Clear</ span >
997+ </ button >
998+ </ div >
999+ ) }
6991000 </ div >
7001001 ) ;
7011002}
0 commit comments