From e5eb13756388572e59f34b51512ee767f0aa2d85 Mon Sep 17 00:00:00 2001 From: wsp1911 Date: Wed, 25 Mar 2026 21:38:00 +0800 Subject: [PATCH] feat: improve terminal tool card readability and command preview - align TerminalToolCard running/completed output backgrounds and spacing - match read-only xterm transparency behavior with the main terminal - add xterm viewport/background fallback to remove bottom black bar - soften header command text in light theme - show full command tooltip only when the header command is truncated - make command tooltip wrap, stay open on hover, and support text selection - extend shared Tooltip with interactive hover behavior --- .../components/Tooltip/Tooltip.scss | 5 ++ .../components/Tooltip/Tooltip.tsx | 42 ++++++++++- .../tool-cards/TerminalToolCard.scss | 63 ++++++++++++----- .../flow_chat/tool-cards/TerminalToolCard.tsx | 70 +++++++++++++++++-- .../components/TerminalOutputRenderer.tsx | 4 +- 5 files changed, 157 insertions(+), 27 deletions(-) diff --git a/src/web-ui/src/component-library/components/Tooltip/Tooltip.scss b/src/web-ui/src/component-library/components/Tooltip/Tooltip.scss index 0ff1f529..aa70e6e5 100644 --- a/src/web-ui/src/component-library/components/Tooltip/Tooltip.scss +++ b/src/web-ui/src/component-library/components/Tooltip/Tooltip.scss @@ -20,6 +20,10 @@ transform: scale(1); } + &--interactive.bitfun-tooltip--visible { + pointer-events: auto; + } + &__content { padding: 6px 10px; @@ -41,6 +45,7 @@ max-width: 280px; word-wrap: break-word; + user-select: text; } &--top.bitfun-tooltip--visible { diff --git a/src/web-ui/src/component-library/components/Tooltip/Tooltip.tsx b/src/web-ui/src/component-library/components/Tooltip/Tooltip.tsx index 3c979d5b..df5ea2af 100644 --- a/src/web-ui/src/component-library/components/Tooltip/Tooltip.tsx +++ b/src/web-ui/src/component-library/components/Tooltip/Tooltip.tsx @@ -16,6 +16,7 @@ export interface TooltipProps { delay?: number; disabled?: boolean; className?: string; + interactive?: boolean; } const assignRef = (ref: React.Ref | undefined, value: T | null): void => { @@ -150,6 +151,7 @@ export const Tooltip: React.FC = ({ delay = DEFAULT_TOOLTIP_DELAY, disabled = false, className = '', + interactive = false, }) => { const [visible, setVisible] = useState(false); const [position, setPosition] = useState({ top: 0, left: 0 }); @@ -159,6 +161,7 @@ export const Tooltip: React.FC = ({ const triggerRef = useRef(null); const tooltipRef = useRef(null); const timeoutRef = useRef | null>(null); + const hideTimeoutRef = useRef | null>(null); const latestMousePositionRef = useRef<{ x: number; y: number } | null>(null); const gap = 8; @@ -205,6 +208,10 @@ export const Tooltip: React.FC = ({ if (timeoutRef.current) { clearTimeout(timeoutRef.current); } + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current); + hideTimeoutRef.current = null; + } if (followCursor && e) { latestMousePositionRef.current = { x: e.clientX, y: e.clientY }; } @@ -223,6 +230,10 @@ export const Tooltip: React.FC = ({ clearTimeout(timeoutRef.current); timeoutRef.current = null; } + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current); + hideTimeoutRef.current = null; + } setVisible(false); setPositionReady(false); if (followCursor) { @@ -231,6 +242,22 @@ export const Tooltip: React.FC = ({ } }; + const scheduleHideTooltip = useCallback(() => { + if (!interactive) { + hideTooltip(); + return; + } + + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current); + } + + hideTimeoutRef.current = setTimeout(() => { + hideTimeoutRef.current = null; + hideTooltip(); + }, 150); + }, [interactive]); + const handleMouseMove = useCallback( (e: React.MouseEvent) => { if (followCursor && !visible) { @@ -271,6 +298,9 @@ export const Tooltip: React.FC = ({ if (timeoutRef.current) { clearTimeout(timeoutRef.current); } + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current); + } }; }, []); @@ -290,7 +320,7 @@ export const Tooltip: React.FC = ({ }; const handleMouseLeave = (e: React.MouseEvent) => { - if (trigger === 'hover') hideTooltip(); + if (trigger === 'hover') scheduleHideTooltip(); if (typeof childProps.onMouseLeave === 'function') { (childProps.onMouseLeave as (e: React.MouseEvent) => void)(e); } @@ -334,6 +364,7 @@ export const Tooltip: React.FC = ({ 'bitfun-tooltip', `bitfun-tooltip--${actualPlacement}`, visible && positionReady && 'bitfun-tooltip--visible', + interactive && 'bitfun-tooltip--interactive', className ].filter(Boolean).join(' '); @@ -344,6 +375,13 @@ export const Tooltip: React.FC = ({
{ + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current); + hideTimeoutRef.current = null; + } + } : undefined} + onMouseLeave={interactive ? scheduleHideTooltip : undefined} style={{ position: 'fixed', top: `${position.top}px`, @@ -359,4 +397,4 @@ export const Tooltip: React.FC = ({ ); }; -Tooltip.displayName = 'Tooltip'; \ No newline at end of file +Tooltip.displayName = 'Tooltip'; diff --git a/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.scss b/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.scss index 32f3135b..8d39db65 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.scss +++ b/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.scss @@ -95,6 +95,12 @@ } } +:root[data-theme="light"] .terminal-command, +:root[data-theme-type="light"] .terminal-command, +.light .terminal-command { + color: var(--color-text-secondary); +} + .terminal-command-input { flex: 1; min-width: 0; @@ -125,6 +131,12 @@ } } +:root[data-theme="light"] .terminal-command-input, +:root[data-theme-type="light"] .terminal-command-input, +.light .terminal-command-input { + color: var(--color-text-secondary); +} + /* ========== Status text styles ========== */ .terminal-status-text { display: inline-flex; @@ -262,18 +274,21 @@ } /* ========== Execution output container (streaming) ========== */ -.terminal-execution-output { - background: var(--color-bg-primary); - padding: 8px 12px; +.terminal-execution-output, +.terminal-result-output { + margin-bottom: 0; + background: transparent; + border: none; + overflow: hidden; max-height: 300px; overflow-y: auto; - + &.terminal-waiting { display: flex; align-items: center; justify-content: center; min-height: 60px; - + .waiting-text { color: var(--color-text-muted); font-size: 12px; @@ -348,15 +363,6 @@ line-height: 1.2; } -.terminal-result-output { - margin-bottom: 0; - background: transparent; - border: none; - overflow: hidden; - max-height: 300px; - overflow-y: auto; -} - .terminal-exit-code { display: inline-flex; align-items: center; @@ -390,18 +396,25 @@ .xterm { padding: 0; - + .xterm-viewport { overflow-y: auto !important; - } - + + &:not(.allow-transparency) { + .xterm-viewport { + background-color: var(--color-bg-scene); + } + } + .xterm-screen { + width: 100%; + height: 100%; } } } .terminal-output-renderer { - background: transparent; + background: var(--color-bg-scene); border-radius: 4px; overflow: hidden; @@ -410,6 +423,20 @@ } } +.terminal-command-tooltip { + .bitfun-tooltip__content { + max-width: min(640px, calc(100vw - 32px)); + cursor: text; + } +} + +.terminal-command-tooltip-content { + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; + user-select: text; +} + /* ========== Error content styles ========== */ .error-content { diff --git a/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx index 82d99246..a1586768 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx @@ -18,7 +18,7 @@ import type { ToolCardProps } from '../types/flow-chat'; import { Terminal, Play, X, ExternalLink, Square } from 'lucide-react'; import { createTerminalTab } from '@/shared/utils/tabUtils'; import { BaseToolCard, ToolCardHeader } from './BaseToolCard'; -import { CubeLoading, IconButton } from '../../component-library'; +import { CubeLoading, IconButton, Tooltip } from '../../component-library'; import { TerminalOutputRenderer } from '@/tools/terminal/components'; import { createLogger } from '@/shared/utils/logger'; import { useToolCardHeightContract } from './useToolCardHeightContract'; @@ -113,8 +113,10 @@ export const TerminalToolCard: React.FC = ({ const [isExpanded, setIsExpanded] = useState(() => getInitialExpandedState(toolId, status)); const [isExecuting, setIsExecuting] = useState(false); const [isEditingCommand, setIsEditingCommand] = useState(false); + const [isCommandTruncated, setIsCommandTruncated] = useState(false); const [editedCommand, setEditedCommand] = useState(''); const inputRef = useRef(null); + const commandRef = useRef(null); const hasInitializedExpand = useRef(false); const previousStatusRef = useRef(status); const { @@ -191,6 +193,50 @@ export const TerminalToolCard: React.FC = ({ } }, [status]); + const updateCommandTruncation = useCallback(() => { + const element = commandRef.current; + if (!element) { + setIsCommandTruncated(false); + return; + } + + const nextValue = element.scrollWidth - element.clientWidth > 1; + setIsCommandTruncated((prev) => (prev === nextValue ? prev : nextValue)); + }, []); + + useEffect(() => { + if (isEditingCommand) { + setIsCommandTruncated(false); + return; + } + + const element = commandRef.current; + if (!element) { + setIsCommandTruncated(false); + return; + } + + const frameId = window.requestAnimationFrame(updateCommandTruncation); + const resizeObserver = typeof ResizeObserver !== 'undefined' + ? new ResizeObserver(() => { + updateCommandTruncation(); + }) + : null; + + resizeObserver?.observe(element); + if (element.parentElement) { + resizeObserver?.observe(element.parentElement); + } + + window.addEventListener('resize', updateCommandTruncation); + + return () => { + window.cancelAnimationFrame(frameId); + resizeObserver?.disconnect(); + window.removeEventListener('resize', updateCommandTruncation); + }; + }, [command, isEditingCommand, updateCommandTruncation]); + const handleStartEdit = useCallback((e: React.MouseEvent) => { e.stopPropagation(); setEditedCommand(command || ''); @@ -390,16 +436,32 @@ export const TerminalToolCard: React.FC = ({ /> ); } - - return ( + + const commandNode = ( {command || (canEditCommand ? {t('toolCards.terminal.commandEmpty')} : {t('toolCards.terminal.noCommand')})} ); + + if (command && isCommandTruncated) { + return ( + {command}
} + placement="bottom" + className="terminal-command-tooltip" + interactive + > + {commandNode} + + ); + } + + return commandNode; }; const renderStatusText = () => { diff --git a/src/web-ui/src/tools/terminal/components/TerminalOutputRenderer.tsx b/src/web-ui/src/tools/terminal/components/TerminalOutputRenderer.tsx index b134d06d..ffa7afa7 100644 --- a/src/web-ui/src/tools/terminal/components/TerminalOutputRenderer.tsx +++ b/src/web-ui/src/tools/terminal/components/TerminalOutputRenderer.tsx @@ -102,9 +102,8 @@ export const TerminalOutputRenderer: React.FC = mem minimumContrastRatio: DEFAULT_XTERM_MINIMUM_CONTRAST_RATIO, scrollback: 5000, convertEol: true, - allowTransparency: true, + allowTransparency: false, theme: buildXtermTheme(currentTheme, { - background: 'transparent', cursor: 'transparent', // Hide cursor in read-only mode. cursorAccent: 'transparent', }), @@ -167,7 +166,6 @@ export const TerminalOutputRenderer: React.FC = mem const fontWeights = getXtermFontWeights(theme.type); terminal.options.theme = buildXtermTheme(theme, { - background: 'transparent', cursor: 'transparent', cursorAccent: 'transparent', });