| |
| |
| |
| |
| |
|
|
| import { Box, Text } from 'ink'; |
| import { useEffect, useState, useRef } from 'react'; |
| import { useUIActions } from '../contexts/UIActionsContext.js'; |
| import { theme } from '../semantic-colors.js'; |
| import { |
| ShellExecutionService, |
| shortenPath, |
| tildeifyPath, |
| type AnsiOutput, |
| type AnsiLine, |
| type AnsiToken, |
| } from '@google/gemini-cli-core'; |
| import { cpLen, cpSlice, getCachedStringWidth } from '../utils/textUtils.js'; |
| import { type BackgroundTask } from '../hooks/useExecutionLifecycle.js'; |
| import { Command } from '../key/keyMatchers.js'; |
| import { useKeypress } from '../hooks/useKeypress.js'; |
| import { formatCommand } from '../key/keybindingUtils.js'; |
| import { |
| ScrollableList, |
| type ScrollableListRef, |
| } from './shared/ScrollableList.js'; |
|
|
| import { SCROLL_TO_ITEM_END } from './shared/VirtualizedList.js'; |
|
|
| import { |
| RadioButtonSelect, |
| type RadioSelectItem, |
| } from './shared/RadioButtonSelect.js'; |
| import { useKeyMatchers } from '../hooks/useKeyMatchers.js'; |
|
|
| interface BackgroundTaskDisplayProps { |
| shells: Map<number, BackgroundTask>; |
| activePid: number; |
| width: number; |
| height: number; |
| isFocused: boolean; |
| isListOpenProp: boolean; |
| } |
|
|
| const CONTENT_PADDING_X = 1; |
| const BORDER_WIDTH = 2; |
| const MAIN_BORDER_HEIGHT = 2; |
| const HEADER_HEIGHT = 1; |
| const FOOTER_HEIGHT = 1; |
| const TOTAL_OVERHEAD_HEIGHT = |
| MAIN_BORDER_HEIGHT + HEADER_HEIGHT + FOOTER_HEIGHT; |
| const PROCESS_LIST_HEADER_HEIGHT = 3; |
| const TAB_DISPLAY_HORIZONTAL_PADDING = 4; |
| const LOG_PATH_OVERHEAD = 7; |
|
|
| const formatShellCommandForDisplay = (command: string, maxWidth: number) => { |
| const commandFirstLine = command.split('\n')[0]; |
| return cpLen(commandFirstLine) > maxWidth |
| ? `${cpSlice(commandFirstLine, 0, maxWidth - 3)}...` |
| : commandFirstLine; |
| }; |
|
|
| export const BackgroundTaskDisplay = ({ |
| shells, |
| activePid, |
| width, |
| height, |
| isFocused, |
| isListOpenProp, |
| }: BackgroundTaskDisplayProps) => { |
| const keyMatchers = useKeyMatchers(); |
| const { |
| dismissBackgroundTask, |
| setActiveBackgroundTaskPid, |
| setIsBackgroundTaskListOpen, |
| } = useUIActions(); |
| const activeShell = shells.get(activePid); |
| const [output, setOutput] = useState<string | AnsiOutput>( |
| activeShell?.output || '', |
| ); |
| const [highlightedPid, setHighlightedPid] = useState<number | null>( |
| activePid, |
| ); |
| const outputRef = useRef<ScrollableListRef<AnsiLine | string>>(null); |
| const subscribedRef = useRef(false); |
|
|
| useEffect(() => { |
| if (!activePid) return; |
|
|
| const ptyWidth = Math.max(1, width - BORDER_WIDTH - CONTENT_PADDING_X * 2); |
| const ptyHeight = Math.max(1, height - TOTAL_OVERHEAD_HEIGHT); |
| ShellExecutionService.resizePty(activePid, ptyWidth, ptyHeight); |
| }, [activePid, width, height]); |
|
|
| useEffect(() => { |
| if (!activePid) { |
| setOutput(''); |
| return; |
| } |
|
|
| |
| const shell = shells.get(activePid); |
| if (shell) { |
| setOutput(shell.output); |
| } |
|
|
| subscribedRef.current = false; |
|
|
| |
| const unsubscribe = ShellExecutionService.subscribe(activePid, (event) => { |
| if (event.type === 'data') { |
| if (typeof event.chunk === 'string') { |
| if (!subscribedRef.current) { |
| |
| setOutput(event.chunk); |
| } else { |
| |
| setOutput((prev) => |
| typeof prev === 'string' ? prev + event.chunk : event.chunk, |
| ); |
| } |
| } else { |
| |
| setOutput(event.chunk); |
| } |
| } |
| }); |
|
|
| subscribedRef.current = true; |
|
|
| return () => { |
| unsubscribe(); |
| subscribedRef.current = false; |
| }; |
| }, [activePid, shells]); |
|
|
| |
| useEffect(() => { |
| if (isListOpenProp) { |
| setHighlightedPid(activePid); |
| } |
| }, [isListOpenProp, activePid]); |
|
|
| useKeypress( |
| (key) => { |
| if (!activeShell) return; |
|
|
| if (isListOpenProp) { |
| |
| |
| |
|
|
| if (keyMatchers[Command.BACKGROUND_SHELL_ESCAPE](key)) { |
| setIsBackgroundTaskListOpen(false); |
| return true; |
| } |
|
|
| if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) { |
| if (highlightedPid) { |
| void dismissBackgroundTask(highlightedPid); |
| |
| } |
| return true; |
| } |
|
|
| if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) { |
| if (highlightedPid) { |
| setActiveBackgroundTaskPid(highlightedPid); |
| } |
| setIsBackgroundTaskListOpen(false); |
| return true; |
| } |
| return false; |
| } |
|
|
| if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) { |
| return false; |
| } |
|
|
| if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) { |
| void dismissBackgroundTask(activeShell.pid); |
| return true; |
| } |
|
|
| if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) { |
| setIsBackgroundTaskListOpen(true); |
| return true; |
| } |
|
|
| if (keyMatchers[Command.BACKGROUND_SHELL_SELECT](key)) { |
| ShellExecutionService.writeToPty(activeShell.pid, '\r'); |
| return true; |
| } else if (keyMatchers[Command.DELETE_CHAR_LEFT](key)) { |
| ShellExecutionService.writeToPty(activeShell.pid, '\b'); |
| return true; |
| } else if (key.sequence) { |
| ShellExecutionService.writeToPty(activeShell.pid, key.sequence); |
| return true; |
| } |
| return false; |
| }, |
| { isActive: isFocused && !!activeShell }, |
| ); |
|
|
| const helpTextParts = [ |
| { label: 'Close', command: Command.TOGGLE_BACKGROUND_SHELL }, |
| { label: 'Kill', command: Command.KILL_BACKGROUND_SHELL }, |
| { label: 'List', command: Command.TOGGLE_BACKGROUND_SHELL_LIST }, |
| ]; |
|
|
| const helpTextStr = helpTextParts |
| .map((p) => `${p.label} (${formatCommand(p.command)})`) |
| .join(' | '); |
|
|
| const renderHelpText = () => ( |
| <Text> |
| {helpTextParts.map((p, i) => ( |
| <Text key={p.label}> |
| {i > 0 ? ' | ' : ''} |
| {p.label} ( |
| <Text color={theme.text.accent}>{formatCommand(p.command)}</Text>) |
| </Text> |
| ))} |
| </Text> |
| ); |
|
|
| const renderTabs = () => { |
| const shellList = Array.from(shells.values()).filter( |
| (s) => s.status === 'running', |
| ); |
|
|
| const pidInfoWidth = getCachedStringWidth( |
| ` (PID: ${activePid}) ${isFocused ? '(Focused)' : ''}`, |
| ); |
|
|
| const availableWidth = |
| width - |
| TAB_DISPLAY_HORIZONTAL_PADDING - |
| getCachedStringWidth(helpTextStr) - |
| pidInfoWidth; |
|
|
| let currentWidth = 0; |
| const tabs = []; |
|
|
| for (let i = 0; i < shellList.length; i++) { |
| const shell = shellList[i]; |
| |
| const labelOverhead = 4 + (i + 1).toString().length; |
| const maxTabLabelLength = Math.max( |
| 1, |
| Math.floor(availableWidth / shellList.length) - labelOverhead, |
| ); |
| const truncatedCommand = formatShellCommandForDisplay( |
| shell.command, |
| maxTabLabelLength, |
| ); |
| const label = ` ${i + 1}: ${truncatedCommand} `; |
| const labelWidth = getCachedStringWidth(label); |
|
|
| |
| |
| if (i > 0 && currentWidth + labelWidth > availableWidth) { |
| break; |
| } |
|
|
| const isActive = shell.pid === activePid; |
|
|
| tabs.push( |
| <Text |
| key={shell.pid} |
| color={isActive ? theme.text.primary : theme.text.secondary} |
| bold={isActive} |
| > |
| {label} |
| </Text>, |
| ); |
| currentWidth += labelWidth; |
| } |
|
|
| if (shellList.length > tabs.length && !isListOpenProp) { |
| const overflowLabel = ` ... (${formatCommand(Command.TOGGLE_BACKGROUND_SHELL_LIST)}) `; |
| const overflowWidth = getCachedStringWidth(overflowLabel); |
|
|
| |
| |
| const shouldShowOverflow = |
| tabs.length > 1 || availableWidth - currentWidth >= overflowWidth; |
|
|
| if (shouldShowOverflow) { |
| tabs.push( |
| <Text key="overflow" color={theme.status.warning} bold> |
| {overflowLabel} |
| </Text>, |
| ); |
| } |
| } |
|
|
| return tabs; |
| }; |
|
|
| const renderProcessList = () => { |
| const maxCommandLength = Math.max( |
| 0, |
| width - BORDER_WIDTH - CONTENT_PADDING_X * 2 - 10, |
| ); |
|
|
| const items: Array<RadioSelectItem<number>> = Array.from( |
| shells.values(), |
| ).map((shell, index) => { |
| const truncatedCommand = formatShellCommandForDisplay( |
| shell.command, |
| maxCommandLength, |
| ); |
|
|
| let label = `${index + 1}: ${truncatedCommand} (PID: ${shell.pid})`; |
| if (shell.status === 'exited') { |
| label += ` (Exit Code: ${shell.exitCode})`; |
| } |
|
|
| return { |
| key: shell.pid.toString(), |
| value: shell.pid, |
| label, |
| }; |
| }); |
|
|
| const initialIndex = items.findIndex((item) => item.value === activePid); |
|
|
| return ( |
| <Box flexDirection="column" height="100%" width="100%"> |
| <Box flexShrink={0} marginBottom={1} paddingTop={1}> |
| <Text bold> |
| {`Select Process (${formatCommand(Command.BACKGROUND_SHELL_SELECT)} to select, ${formatCommand(Command.KILL_BACKGROUND_SHELL)} to kill, ${formatCommand(Command.BACKGROUND_SHELL_ESCAPE)} to cancel):`} |
| </Text> |
| </Box> |
| <Box flexGrow={1} width="100%"> |
| <RadioButtonSelect |
| items={items} |
| initialIndex={initialIndex >= 0 ? initialIndex : 0} |
| onSelect={(pid) => { |
| setActiveBackgroundTaskPid(pid); |
| setIsBackgroundTaskListOpen(false); |
| }} |
| onHighlight={(pid) => setHighlightedPid(pid)} |
| isFocused={isFocused} |
| maxItemsToShow={Math.max( |
| 1, |
| height - TOTAL_OVERHEAD_HEIGHT - PROCESS_LIST_HEADER_HEIGHT, |
| )} |
| renderItem={( |
| item, |
| { isSelected: _isSelected, titleColor: _titleColor }, |
| ) => { |
| // Custom render to handle exit code coloring if needed, |
| // or just use default. The default RadioButtonSelect renderer |
| // handles standard label. |
| // But we want to color exit code differently? |
| // The previous implementation colored exit code green/red. |
| // Let's reimplement that. |
| |
| // We need access to shell details here. |
| // We can put shell details in the item or lookup. |
| // Lookup from shells map. |
| const shell = shells.get(item.value); |
| if (!shell) return <Text>{item.label}</Text>; |
| |
| const truncatedCommand = formatShellCommandForDisplay( |
| shell.command, |
| maxCommandLength, |
| ); |
| |
| return ( |
| <Text> |
| {truncatedCommand} (PID: {shell.pid}) |
| {shell.status === 'exited' ? ( |
| <Text |
| color={ |
| shell.exitCode === 0 |
| ? theme.status.success |
| : theme.status.error |
| } |
| > |
| {' '} |
| (Exit Code: {shell.exitCode}) |
| </Text> |
| ) : null} |
| </Text> |
| ); |
| }} |
| /> |
| </Box> |
| </Box> |
| ); |
| }; |
|
|
| const renderFooter = () => { |
| const pidToDisplay = isListOpenProp |
| ? (highlightedPid ?? activePid) |
| : activePid; |
| if (!pidToDisplay) return null; |
| const logPath = ShellExecutionService.getLogFilePath(pidToDisplay); |
| const displayPath = shortenPath( |
| tildeifyPath(logPath), |
| width - LOG_PATH_OVERHEAD, |
| ); |
| return ( |
| <Box paddingX={1}> |
| <Text color={theme.text.secondary}>Log: {displayPath}</Text> |
| </Box> |
| ); |
| }; |
|
|
| const renderOutput = () => { |
| const lines = typeof output === 'string' ? output.split('\n') : output; |
|
|
| return ( |
| <ScrollableList |
| ref={outputRef} |
| data={lines} |
| renderItem={({ item: line, index }) => { |
| if (typeof line === 'string') { |
| return <Text key={index}>{line}</Text>; |
| } |
| return ( |
| <Text key={index} wrap="truncate"> |
| {line.length > 0 |
| ? line.map((token: AnsiToken, tokenIndex: number) => ( |
| <Text |
| key={tokenIndex} |
| color={token.fg} |
| backgroundColor={token.bg} |
| inverse={token.inverse} |
| dimColor={token.dim} |
| bold={token.bold} |
| italic={token.italic} |
| underline={token.underline} |
| > |
| {token.text} |
| </Text> |
| )) |
| : null} |
| </Text> |
| ); |
| }} |
| estimatedItemHeight={() => 1} |
| keyExtractor={(_, index) => index.toString()} |
| hasFocus={isFocused} |
| initialScrollIndex={SCROLL_TO_ITEM_END} |
| /> |
| ); |
| }; |
|
|
| return ( |
| <Box |
| flexDirection="column" |
| height="100%" |
| width="100%" |
| borderStyle="single" |
| borderColor={isFocused ? theme.ui.focus : undefined} |
| > |
| <Box |
| flexDirection="row" |
| justifyContent="space-between" |
| borderStyle="single" |
| borderBottom={false} |
| borderLeft={false} |
| borderRight={false} |
| borderTop={false} |
| paddingX={1} |
| borderColor={isFocused ? theme.ui.focus : undefined} |
| > |
| <Box flexDirection="row"> |
| {renderTabs()} |
| <Text bold> |
| {' '} |
| (PID: {activeShell?.pid}) {isFocused ? '(Focused)' : ''} |
| </Text> |
| </Box> |
| {renderHelpText()} |
| </Box> |
| <Box flexGrow={1} overflow="hidden" paddingX={CONTENT_PADDING_X}> |
| {isListOpenProp ? renderProcessList() : renderOutput()} |
| </Box> |
| {renderFooter()} |
| </Box> |
| ); |
| }; |
|
|