@@ -16,6 +16,7 @@ import { createExecutor } from '../../utils/CommandExecutor.js';
1616import { DevboxDetailPage } from '../../components/DevboxDetailPage.js' ;
1717import { DevboxCreatePage } from '../../components/DevboxCreatePage.js' ;
1818import { DevboxActionsMenu } from '../../components/DevboxActionsMenu.js' ;
19+ import { ActionsPopup } from '../../components/ActionsPopup.js' ;
1920
2021// Format time ago in a succinct way
2122const formatTimeAgo = ( timestamp : number ) : string => {
@@ -58,8 +59,12 @@ const ListDevboxesUI: React.FC<{ status?: string }> = ({ status }) => {
5859 const [ showDetails , setShowDetails ] = React . useState ( false ) ;
5960 const [ showCreate , setShowCreate ] = React . useState ( false ) ;
6061 const [ showActions , setShowActions ] = React . useState ( false ) ;
62+ const [ showPopup , setShowPopup ] = React . useState ( false ) ;
63+ const [ selectedOperation , setSelectedOperation ] = React . useState ( 0 ) ;
6164 const [ refreshing , setRefreshing ] = React . useState ( false ) ;
6265 const [ refreshIcon , setRefreshIcon ] = React . useState ( 0 ) ;
66+ const [ searchMode , setSearchMode ] = React . useState ( false ) ;
67+ const [ searchQuery , setSearchQuery ] = React . useState ( '' ) ;
6368
6469 // Calculate responsive dimensions
6570 const terminalWidth = stdout ?. columns || 120 ;
@@ -96,6 +101,19 @@ const ListDevboxesUI: React.FC<{ status?: string }> = ({ status }) => {
96101 nameWidth = Math . max ( 8 , remainingWidth ) ;
97102 }
98103
104+ // Define allOperations
105+ const allOperations = [
106+ { key : 'logs' , label : 'View Logs' , color : 'blue' , icon : figures . info , shortcut : 'l' } ,
107+ { key : 'exec' , label : 'Execute Command' , color : 'green' , icon : figures . play , shortcut : 'e' } ,
108+ { key : 'upload' , label : 'Upload File' , color : 'green' , icon : figures . arrowUp , shortcut : 'u' } ,
109+ { key : 'snapshot' , label : 'Create Snapshot' , color : 'yellow' , icon : figures . circleFilled , shortcut : 'n' } ,
110+ { key : 'ssh' , label : 'SSH onto the box' , color : 'cyan' , icon : figures . arrowRight , shortcut : 's' } ,
111+ { key : 'tunnel' , label : 'Open Tunnel' , color : 'magenta' , icon : figures . pointerSmall , shortcut : 't' } ,
112+ { key : 'suspend' , label : 'Suspend Devbox' , color : 'yellow' , icon : figures . squareSmallFilled , shortcut : 'p' } ,
113+ { key : 'resume' , label : 'Resume Devbox' , color : 'green' , icon : figures . play , shortcut : 'r' } ,
114+ { key : 'delete' , label : 'Shutdown Devbox' , color : 'red' , icon : figures . cross , shortcut : 'd' } ,
115+ ] ;
116+
99117 React . useEffect ( ( ) => {
100118 const list = async ( isInitialLoad : boolean = false ) => {
101119 try {
@@ -160,6 +178,15 @@ const ListDevboxesUI: React.FC<{ status?: string }> = ({ status }) => {
160178 useInput ( ( input , key ) => {
161179 const pageDevboxes = currentDevboxes . length ;
162180
181+ // Skip input handling when in search mode - let TextInput handle it
182+ if ( searchMode ) {
183+ if ( key . escape ) {
184+ setSearchMode ( false ) ;
185+ setSearchQuery ( '' ) ;
186+ }
187+ return ;
188+ }
189+
163190 // Skip input handling when in details view - let DevboxDetailPage handle it
164191 if ( showDetails ) {
165192 return ;
@@ -175,6 +202,34 @@ const ListDevboxesUI: React.FC<{ status?: string }> = ({ status }) => {
175202 return ;
176203 }
177204
205+ // Handle popup navigation
206+ if ( showPopup ) {
207+ if ( key . escape || input === 'q' ) {
208+ console . clear ( ) ;
209+ setShowPopup ( false ) ;
210+ setSelectedOperation ( 0 ) ;
211+ } else if ( key . upArrow && selectedOperation > 0 ) {
212+ setSelectedOperation ( selectedOperation - 1 ) ;
213+ } else if ( key . downArrow && selectedOperation < operations . length - 1 ) {
214+ setSelectedOperation ( selectedOperation + 1 ) ;
215+ } else if ( key . return ) {
216+ // Execute the selected operation
217+ console . clear ( ) ;
218+ setShowPopup ( false ) ;
219+ setShowActions ( true ) ;
220+ } else if ( input ) {
221+ // Check for shortcut match
222+ const matchedOpIndex = operations . findIndex ( op => op . shortcut === input ) ;
223+ if ( matchedOpIndex !== - 1 ) {
224+ setSelectedOperation ( matchedOpIndex ) ;
225+ console . clear ( ) ;
226+ setShowPopup ( false ) ;
227+ setShowActions ( true ) ;
228+ }
229+ }
230+ return ;
231+ }
232+
178233 // Handle list view
179234 if ( key . upArrow && selectedIndex > 0 ) {
180235 setSelectedIndex ( selectedIndex - 1 ) ;
@@ -191,7 +246,8 @@ const ListDevboxesUI: React.FC<{ status?: string }> = ({ status }) => {
191246 setShowDetails ( true ) ;
192247 } else if ( input === 'a' ) {
193248 console . clear ( ) ;
194- setShowActions ( true ) ;
249+ setShowPopup ( true ) ;
250+ setSelectedOperation ( 0 ) ;
195251 } else if ( input === 'c' ) {
196252 console . clear ( ) ;
197253 setShowCreate ( true ) ;
@@ -214,22 +270,66 @@ const ListDevboxesUI: React.FC<{ status?: string }> = ({ status }) => {
214270 exec ( openCommand ) ;
215271 } ;
216272 openBrowser ( ) ;
273+ } else if ( input === '/' ) {
274+ setSearchMode ( true ) ;
275+ } else if ( key . escape && searchQuery ) {
276+ // Clear search when Esc is pressed and there's an active search
277+ setSearchQuery ( '' ) ;
278+ setCurrentPage ( 0 ) ;
279+ setSelectedIndex ( 0 ) ;
217280 } else if ( input === 'q' ) {
218281 process . exit ( 0 ) ;
219282 }
220283 } ) ;
221284
222- const running = devboxes . filter ( ( d ) => d . status === 'running' ) . length ;
223- const stopped = devboxes . filter ( ( d ) =>
285+ // Filter devboxes based on search query
286+ const filteredDevboxes = React . useMemo ( ( ) => {
287+ if ( ! searchQuery . trim ( ) ) return devboxes ;
288+
289+ const query = searchQuery . toLowerCase ( ) ;
290+ return devboxes . filter ( devbox => {
291+ return (
292+ devbox . id ?. toLowerCase ( ) . includes ( query ) ||
293+ devbox . name ?. toLowerCase ( ) . includes ( query ) ||
294+ devbox . status ?. toLowerCase ( ) . includes ( query )
295+ ) ;
296+ } ) ;
297+ } , [ devboxes , searchQuery ] ) ;
298+
299+ const running = filteredDevboxes . filter ( ( d ) => d . status === 'running' ) . length ;
300+ const stopped = filteredDevboxes . filter ( ( d ) =>
224301 [ 'stopped' , 'suspended' ] . includes ( d . status )
225302 ) . length ;
226303
227- const totalPages = Math . ceil ( devboxes . length / PAGE_SIZE ) ;
304+ const totalPages = Math . ceil ( filteredDevboxes . length / PAGE_SIZE ) ;
228305 const startIndex = currentPage * PAGE_SIZE ;
229- const endIndex = Math . min ( startIndex + PAGE_SIZE , devboxes . length ) ;
230- const currentDevboxes = devboxes . slice ( startIndex , endIndex ) ;
306+ const endIndex = Math . min ( startIndex + PAGE_SIZE , filteredDevboxes . length ) ;
307+ const currentDevboxes = filteredDevboxes . slice ( startIndex , endIndex ) ;
231308 const selectedDevbox = currentDevboxes [ selectedIndex ] ;
232309
310+ // Filter operations based on devbox status
311+ const operations = selectedDevbox ? allOperations . filter ( op => {
312+ const status = selectedDevbox . status ;
313+
314+ // When suspended: logs and resume
315+ if ( status === 'suspended' ) {
316+ return op . key === 'resume' || op . key === 'logs' ;
317+ }
318+
319+ // When not running (shutdown, failure, etc): only logs
320+ if ( status !== 'running' && status !== 'provisioning' && status !== 'initializing' ) {
321+ return op . key === 'logs' ;
322+ }
323+
324+ // When running: everything except resume
325+ if ( status === 'running' ) {
326+ return op . key !== 'resume' ;
327+ }
328+
329+ // Default for transitional states (provisioning, initializing)
330+ return op . key === 'logs' || op . key === 'delete' ;
331+ } ) : allOperations ;
332+
233333 // Create view
234334 if ( showCreate ) {
235335 return (
@@ -248,14 +348,20 @@ const ListDevboxesUI: React.FC<{ status?: string }> = ({ status }) => {
248348
249349 // Actions view
250350 if ( showActions && selectedDevbox ) {
351+ const selectedOp = operations [ selectedOperation ] ;
251352 return (
252353 < DevboxActionsMenu
253354 devbox = { selectedDevbox }
254- onBack = { ( ) => setShowActions ( false ) }
355+ onBack = { ( ) => {
356+ setShowActions ( false ) ;
357+ setSelectedOperation ( 0 ) ;
358+ } }
255359 breadcrumbItems = { [
256360 { label : 'Devboxes' } ,
257361 { label : selectedDevbox . name || selectedDevbox . id , active : true }
258362 ] }
363+ initialOperation = { selectedOp ?. key }
364+ initialOperationIndex = { selectedOperation }
259365 />
260366 ) ;
261367 }
@@ -265,6 +371,112 @@ const ListDevboxesUI: React.FC<{ status?: string }> = ({ status }) => {
265371 return < DevboxDetailPage devbox = { selectedDevbox } onBack = { ( ) => setShowDetails ( false ) } /> ;
266372 }
267373
374+ // Show popup with table in background
375+ if ( showPopup && selectedDevbox ) {
376+ return (
377+ < >
378+ < Breadcrumb items = { [
379+ { label : 'Devboxes' , active : true }
380+ ] } />
381+ { ! loading && ! error && devboxes . length > 0 && (
382+ < >
383+ < Table
384+ data = { currentDevboxes }
385+ keyExtractor = { ( devbox : any ) => devbox . id }
386+ selectedIndex = { selectedIndex }
387+ title = { `devboxes[${ devboxes . length } ]` }
388+ columns = { [
389+ {
390+ key : 'statusIcon' ,
391+ label : '' ,
392+ width : statusIconWidth ,
393+ render : ( devbox : any , index : number , isSelected : boolean ) => {
394+ const statusDisplay = getStatusDisplay ( devbox . status ) ;
395+
396+ // Truncate icon to fit width and pad
397+ const icon = statusDisplay . icon . slice ( 0 , statusIconWidth ) ;
398+ const padded = icon . padEnd ( statusIconWidth , ' ' ) ;
399+
400+ return (
401+ < Text color = { isSelected ? 'white' : statusDisplay . color } bold = { true } inverse = { isSelected } >
402+ { padded }
403+ </ Text >
404+ ) ;
405+ }
406+ } ,
407+ createTextColumn (
408+ 'id' ,
409+ 'ID' ,
410+ ( devbox : any ) => devbox . id ,
411+ { width : idWidth , color : 'gray' , dimColor : true , bold : false }
412+ ) ,
413+ {
414+ key : 'statusText' ,
415+ label : 'Status' ,
416+ width : statusTextWidth ,
417+ render : ( devbox : any , index : number , isSelected : boolean ) => {
418+ const statusDisplay = getStatusDisplay ( devbox . status ) ;
419+
420+ const truncated = statusDisplay . text . slice ( 0 , statusTextWidth ) ;
421+ const padded = truncated . padEnd ( statusTextWidth , ' ' ) ;
422+
423+ return (
424+ < Text color = { isSelected ? 'white' : statusDisplay . color } bold = { true } inverse = { isSelected } >
425+ { padded }
426+ </ Text >
427+ ) ;
428+ }
429+ } ,
430+ createTextColumn (
431+ 'name' ,
432+ 'Name' ,
433+ ( devbox : any ) => devbox . name || '' ,
434+ { width : nameWidth , dimColor : true }
435+ ) ,
436+ createTextColumn (
437+ 'capabilities' ,
438+ 'Capabilities' ,
439+ ( devbox : any ) => {
440+ const hasCapabilities = devbox . capabilities && devbox . capabilities . filter ( ( c : string ) => c !== 'unknown' ) . length > 0 ;
441+ return hasCapabilities
442+ ? `[${ devbox . capabilities
443+ . filter ( ( c : string ) => c !== 'unknown' )
444+ . map ( ( c : string ) => c === 'computer_usage' ? 'comp' : c === 'browser_usage' ? 'browser' : c === 'docker_in_docker' ? 'docker' : c )
445+ . join ( ',' ) } ]`
446+ : '' ;
447+ } ,
448+ { width : capabilitiesWidth , color : 'blue' , dimColor : true , bold : false , visible : showCapabilities }
449+ ) ,
450+ createTextColumn (
451+ 'tags' ,
452+ 'Tags' ,
453+ ( devbox : any ) => devbox . blueprint_id ? '[bp]' : devbox . snapshot_id ? '[snap]' : '' ,
454+ { width : tagWidth , color : 'yellow' , dimColor : true , bold : false , visible : showTags }
455+ ) ,
456+ createTextColumn (
457+ 'created' ,
458+ 'Created' ,
459+ ( devbox : any ) => devbox . create_time_ms ? formatTimeAgo ( devbox . create_time_ms ) : '' ,
460+ { width : timeWidth , color : 'gray' , dimColor : true , bold : false }
461+ ) ,
462+ ] }
463+ />
464+ </ >
465+ ) }
466+
467+ { /* Popup overlaying - use negative margin to pull it up over the table */ }
468+ < Box marginTop = { - Math . min ( operations . length + 10 , PAGE_SIZE + 5 ) } justifyContent = "center" >
469+ < ActionsPopup
470+ devbox = { selectedDevbox }
471+ operations = { operations }
472+ selectedOperation = { selectedOperation }
473+ onClose = { ( ) => setShowPopup ( false ) }
474+ />
475+ </ Box >
476+ </ >
477+ ) ;
478+ }
479+
268480 // List view
269481 return (
270482 < >
@@ -285,6 +497,29 @@ const ListDevboxesUI: React.FC<{ status?: string }> = ({ status }) => {
285497 ) }
286498 { ! loading && ! error && devboxes . length > 0 && (
287499 < >
500+ { searchMode && (
501+ < Box marginBottom = { 1 } >
502+ < Text color = "cyan" > { figures . pointerSmall } Search: </ Text >
503+ < TextInput
504+ value = { searchQuery }
505+ onChange = { setSearchQuery }
506+ placeholder = "Type to search (name, id, status)..."
507+ onSubmit = { ( ) => {
508+ setSearchMode ( false ) ;
509+ setCurrentPage ( 0 ) ;
510+ setSelectedIndex ( 0 ) ;
511+ } }
512+ />
513+ < Text color = "gray" dimColor > [Esc to cancel]</ Text >
514+ </ Box >
515+ ) }
516+ { searchQuery && ! searchMode && (
517+ < Box marginBottom = { 1 } >
518+ < Text color = "cyan" > { figures . info } Searching for: </ Text >
519+ < Text color = "yellow" bold > { searchQuery } </ Text >
520+ < Text color = "gray" dimColor > ({ filteredDevboxes . length } results) [/ to edit, Esc to clear]</ Text >
521+ </ Box >
522+ ) }
288523 < Table
289524 data = { currentDevboxes }
290525 keyExtractor = { ( devbox : any ) => devbox . id }
@@ -297,17 +532,13 @@ const ListDevboxesUI: React.FC<{ status?: string }> = ({ status }) => {
297532 width : statusIconWidth ,
298533 render : ( devbox : any , index : number , isSelected : boolean ) => {
299534 const statusDisplay = getStatusDisplay ( devbox . status ) ;
300- const status = devbox . status ;
301- let color : string = 'gray' ;
302- if ( status === 'running' ) color = 'green' ;
303- else if ( status === 'stopped' || status === 'suspended' ) color = 'gray' ;
304- else if ( status === 'starting' || status === 'stopping' ) color = 'yellow' ;
305- else if ( status === 'failed' ) color = 'red' ;
306535
307- const padded = statusDisplay . icon . padEnd ( statusIconWidth , ' ' ) ;
536+ // Truncate icon to fit width and pad
537+ const icon = statusDisplay . icon . slice ( 0 , statusIconWidth ) ;
538+ const padded = icon . padEnd ( statusIconWidth , ' ' ) ;
308539
309540 return (
310- < Text color = { isSelected ? 'white' : color } bold = { true } inverse = { isSelected } >
541+ < Text color = { isSelected ? 'white' : statusDisplay . color } bold = { true } inverse = { isSelected } >
311542 { padded }
312543 </ Text >
313544 ) ;
@@ -325,18 +556,12 @@ const ListDevboxesUI: React.FC<{ status?: string }> = ({ status }) => {
325556 width : statusTextWidth ,
326557 render : ( devbox : any , index : number , isSelected : boolean ) => {
327558 const statusDisplay = getStatusDisplay ( devbox . status ) ;
328- const status = devbox . status ;
329- let color : string = 'gray' ;
330- if ( status === 'running' ) color = 'green' ;
331- else if ( status === 'stopped' || status === 'suspended' ) color = 'gray' ;
332- else if ( status === 'starting' || status === 'stopping' ) color = 'yellow' ;
333- else if ( status === 'failed' ) color = 'red' ;
334559
335560 const truncated = statusDisplay . text . slice ( 0 , statusTextWidth ) ;
336561 const padded = truncated . padEnd ( statusTextWidth , ' ' ) ;
337562
338563 return (
339- < Text color = { isSelected ? 'white' : color } bold = { true } inverse = { isSelected } >
564+ < Text color = { isSelected ? 'white' : statusDisplay . color } bold = { true } inverse = { isSelected } >
340565 { padded }
341566 </ Text >
342567 ) ;
@@ -420,9 +645,10 @@ const ListDevboxesUI: React.FC<{ status?: string }> = ({ status }) => {
420645 </ Text >
421646 ) }
422647 < Text color = "gray" dimColor >
423- { ' ' } • [Enter] Details • [a] Actions • [c] Create • [o] Browser • [q] Quit
648+ { ' ' } • [Enter] Details • [a] Actions • [c] Create • [/] Search • [ o] Browser • [q] Quit
424649 </ Text >
425650 </ Box >
651+
426652 </ >
427653 ) }
428654 { error && < ErrorMessage message = "Failed to list devboxes" error = { error } /> }
0 commit comments