diff --git a/.github/pr-assets/pr-601-mobile-terminal-card-regression.jpg b/.github/pr-assets/pr-601-mobile-terminal-card-regression.jpg new file mode 100644 index 0000000000..d811c8a046 Binary files /dev/null and b/.github/pr-assets/pr-601-mobile-terminal-card-regression.jpg differ diff --git a/web/src/components/AssistantChat/HappyThread.tsx b/web/src/components/AssistantChat/HappyThread.tsx index 8c4d1dbeb0..73aa4ddbfe 100644 --- a/web/src/components/AssistantChat/HappyThread.tsx +++ b/web/src/components/AssistantChat/HappyThread.tsx @@ -10,6 +10,7 @@ import { HappyUserMessage } from '@/components/AssistantChat/messages/UserMessag import { HappySystemMessage } from '@/components/AssistantChat/messages/SystemMessage' import { Button } from '@/components/ui/button' import { Spinner } from '@/components/Spinner' +import { useTerminalToolDisplayMode } from '@/hooks/useTerminalToolDisplayMode' import { useTranslation } from '@/lib/use-translation' import { CloseIcon } from '@/components/icons' @@ -264,6 +265,7 @@ export function HappyThread(props: { onOutlineItemClick?: (item: ConversationOutlineItem) => void }) { const { t } = useTranslation() + const { terminalToolDisplayMode } = useTerminalToolDisplayMode() const viewportRef = useRef(null) const contentRef = useRef(null) const topSentinelRef = useRef(null) @@ -676,6 +678,7 @@ export function HappyThread(props: { api: props.api, sessionId: props.sessionId, metadata: props.metadata, + terminalToolDisplayMode, disabled: props.disabled, onRefresh: props.onRefresh, onRetryMessage: props.onRetryMessage diff --git a/web/src/components/AssistantChat/context.tsx b/web/src/components/AssistantChat/context.tsx index e6d2b78cf2..fa3fc20eaf 100644 --- a/web/src/components/AssistantChat/context.tsx +++ b/web/src/components/AssistantChat/context.tsx @@ -1,12 +1,14 @@ import type { ReactNode } from 'react' import { createContext, useContext } from 'react' import type { ApiClient } from '@/api/client' +import type { TerminalToolDisplayMode } from '@/hooks/useTerminalToolDisplayMode' import type { SessionMetadataSummary } from '@/types/api' export type HappyChatContextValue = { api: ApiClient sessionId: string metadata: SessionMetadataSummary | null + terminalToolDisplayMode: TerminalToolDisplayMode disabled: boolean onRefresh: () => void onRetryMessage?: (localId: string) => void diff --git a/web/src/components/AssistantChat/messages/ToolMessage.tsx b/web/src/components/AssistantChat/messages/ToolMessage.tsx index fb0ce38b03..87725ea7ff 100644 --- a/web/src/components/AssistantChat/messages/ToolMessage.tsx +++ b/web/src/components/AssistantChat/messages/ToolMessage.tsx @@ -120,6 +120,7 @@ function HappyNestedBlockList(props: { api={ctx.api} sessionId={ctx.sessionId} metadata={ctx.metadata} + terminalToolDisplayMode={ctx.terminalToolDisplayMode} disabled={ctx.disabled} onDone={ctx.onRefresh} block={block} @@ -211,6 +212,7 @@ export function HappyToolMessage(props: ToolCallMessagePartProps) { api={ctx.api} sessionId={ctx.sessionId} metadata={ctx.metadata} + terminalToolDisplayMode={ctx.terminalToolDisplayMode} disabled={ctx.disabled} onDone={ctx.onRefresh} block={block} diff --git a/web/src/components/ToolCard/ToolCard.test.ts b/web/src/components/ToolCard/ToolCard.test.ts new file mode 100644 index 0000000000..910a9dda57 --- /dev/null +++ b/web/src/components/ToolCard/ToolCard.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { shouldShowInlineToolCardBody, shouldUseCompactTerminalToolCard } from '@/components/ToolCard/ToolCard' + +describe('ToolCard terminal display mode helpers', () => { + it('treats terminal-related cards as compact by default', () => { + expect(shouldUseCompactTerminalToolCard('CodexBash', 'compact')).toBe(true) + expect(shouldUseCompactTerminalToolCard('shell_command', 'compact')).toBe(true) + expect(shouldUseCompactTerminalToolCard('run_shell_command', 'compact')).toBe(true) + expect(shouldUseCompactTerminalToolCard('Read', 'compact')).toBe(false) + }) + + it('hides inline terminal previews in compact mode', () => { + expect(shouldShowInlineToolCardBody('CodexBash', false, 'compact')).toBe(false) + }) + + it('keeps inline terminal previews in detailed mode', () => { + expect(shouldShowInlineToolCardBody('CodexBash', false, 'detailed')).toBe(true) + expect(shouldShowInlineToolCardBody('Bash', true, 'detailed')).toBe(true) + expect(shouldShowInlineToolCardBody('shell_command', true, 'detailed')).toBe(true) + expect(shouldShowInlineToolCardBody('run_shell_command', true, 'detailed')).toBe(true) + }) + + it('still hides inline bodies for minimal and Task/Agent subagent cards', () => { + expect(shouldShowInlineToolCardBody('Task', false, 'detailed')).toBe(false) + expect(shouldShowInlineToolCardBody('Agent', false, 'detailed')).toBe(false) + expect(shouldShowInlineToolCardBody('Read', true, 'detailed')).toBe(false) + }) +}) diff --git a/web/src/components/ToolCard/ToolCard.tsx b/web/src/components/ToolCard/ToolCard.tsx index 529d346ccd..bf4d644cf8 100644 --- a/web/src/components/ToolCard/ToolCard.tsx +++ b/web/src/components/ToolCard/ToolCard.tsx @@ -17,6 +17,7 @@ import { getToolPresentation } from '@/components/ToolCard/knownTools' import { getToolFullViewComponent, getToolViewComponent } from '@/components/ToolCard/views/_all' import { getToolResultViewComponent } from '@/components/ToolCard/views/_results' import { formatTaskChildLabel, TaskStateIcon } from '@/components/ToolCard/helpers' +import type { TerminalToolDisplayMode } from '@/hooks/useTerminalToolDisplayMode' import { usePointerFocusRing } from '@/hooks/usePointerFocusRing' import { getInputString, getInputStringAny, truncate } from '@/lib/toolInputUtils' import { cn } from '@/lib/utils' @@ -25,6 +26,23 @@ import { TraceSection } from '@/components/ToolCard/trace' import { isSubagentToolName } from '@/chat/subagentTool' const ELAPSED_INTERVAL_MS = 1000 +const TERMINAL_RELATED_TOOL_NAMES = new Set(['Bash', 'CodexBash', 'shell_command', 'run_shell_command']) + +export function shouldUseCompactTerminalToolCard(toolName: string, terminalToolDisplayMode: TerminalToolDisplayMode): boolean { + return TERMINAL_RELATED_TOOL_NAMES.has(toolName) && terminalToolDisplayMode === 'compact' +} + +export function shouldShowInlineToolCardBody( + toolName: string, + presentationMinimal: boolean, + terminalToolDisplayMode: TerminalToolDisplayMode +): boolean { + if (isSubagentToolName(toolName)) return false + if (TERMINAL_RELATED_TOOL_NAMES.has(toolName)) { + return terminalToolDisplayMode === 'detailed' + } + return !presentationMinimal +} function ElapsedView(props: { from: number; active: boolean }) { const [now, setNow] = useState(() => Date.now()) @@ -260,6 +278,7 @@ type ToolCardProps = { api: ApiClient sessionId: string metadata: SessionMetadataSummary | null + terminalToolDisplayMode: TerminalToolDisplayMode disabled: boolean onDone: () => void block: ToolCallBlock @@ -289,7 +308,9 @@ function ToolCardInner(props: ToolCardProps) { const subtitle = presentation.subtitle ?? props.block.tool.description const taskSummary = renderTaskSummary(props.block, props.metadata, t) const runningFrom = props.block.tool.startedAt ?? props.block.tool.createdAt - const showInline = !presentation.minimal && !isSubagentToolName(toolName) + const isCodexAgentCard = toolName === 'CodexAgent' + const useCompactTerminalCard = shouldUseCompactTerminalToolCard(toolName, props.terminalToolDisplayMode) + const showInline = shouldShowInlineToolCardBody(toolName, presentation.minimal, props.terminalToolDisplayMode) const CompactToolView = showInline ? getToolViewComponent(toolName) : null const FullToolView = getToolFullViewComponent(toolName) const ResultToolView = getToolResultViewComponent(toolName) @@ -297,7 +318,6 @@ function ToolCardInner(props: ToolCardProps) { const isAskUserQuestion = isAskUserQuestionToolName(toolName) const isRequestUserInput = isRequestUserInputToolName(toolName) const isQuestionTool = isAskUserQuestion || isRequestUserInput - const isCodexAgentCard = toolName === 'CodexAgent' const showsPermissionFooter = Boolean(permission && ( permission.status === 'pending' || ((permission.status === 'denied' || permission.status === 'canceled') && Boolean(permission.reason)) @@ -324,7 +344,7 @@ function ToolCardInner(props: ToolCardProps) { {subtitle ? ( {truncate(subtitle, 160)} diff --git a/web/src/hooks/useTerminalToolDisplayMode.test.ts b/web/src/hooks/useTerminalToolDisplayMode.test.ts new file mode 100644 index 0000000000..281457cc77 --- /dev/null +++ b/web/src/hooks/useTerminalToolDisplayMode.test.ts @@ -0,0 +1,32 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + DEFAULT_TERMINAL_TOOL_DISPLAY_MODE, + getInitialTerminalToolDisplayMode, + getTerminalToolDisplayModeOptions, +} from './useTerminalToolDisplayMode' + +describe('useTerminalToolDisplayMode helpers', () => { + beforeEach(() => { + window.localStorage.clear() + }) + + it('returns the allowed terminal tool display options', () => { + expect(getTerminalToolDisplayModeOptions()).toEqual([ + { value: 'compact', labelKey: 'settings.chat.terminalToolDisplay.compact' }, + { value: 'detailed', labelKey: 'settings.chat.terminalToolDisplay.detailed' }, + ]) + }) + + it('falls back to the default display mode for missing or invalid storage values', () => { + expect(getInitialTerminalToolDisplayMode()).toBe(DEFAULT_TERMINAL_TOOL_DISPLAY_MODE) + + window.localStorage.setItem('hapi-terminal-tool-display-mode', 'invalid') + expect(getInitialTerminalToolDisplayMode()).toBe(DEFAULT_TERMINAL_TOOL_DISPLAY_MODE) + }) + + it('reads a valid stored terminal tool display mode', () => { + window.localStorage.setItem('hapi-terminal-tool-display-mode', 'detailed') + + expect(getInitialTerminalToolDisplayMode()).toBe('detailed') + }) +}) diff --git a/web/src/hooks/useTerminalToolDisplayMode.ts b/web/src/hooks/useTerminalToolDisplayMode.ts new file mode 100644 index 0000000000..81a0da4ca5 --- /dev/null +++ b/web/src/hooks/useTerminalToolDisplayMode.ts @@ -0,0 +1,99 @@ +import { useCallback, useEffect, useState } from 'react' + +export type TerminalToolDisplayMode = 'compact' | 'detailed' + +export const DEFAULT_TERMINAL_TOOL_DISPLAY_MODE: TerminalToolDisplayMode = 'compact' + +export function getTerminalToolDisplayModeOptions(): ReadonlyArray<{ value: TerminalToolDisplayMode; labelKey: string }> { + return [ + { value: 'compact', labelKey: 'settings.chat.terminalToolDisplay.compact' }, + { value: 'detailed', labelKey: 'settings.chat.terminalToolDisplay.detailed' }, + ] +} + +function getTerminalToolDisplayModeStorageKey(): string { + return 'hapi-terminal-tool-display-mode' +} + +function isBrowser(): boolean { + return typeof window !== 'undefined' && typeof document !== 'undefined' +} + +function safeGetItem(key: string): string | null { + if (!isBrowser()) { + return null + } + try { + return localStorage.getItem(key) + } catch { + return null + } +} + +function safeSetItem(key: string, value: string): void { + if (!isBrowser()) { + return + } + try { + localStorage.setItem(key, value) + } catch { + // Ignore storage errors + } +} + +function safeRemoveItem(key: string): void { + if (!isBrowser()) { + return + } + try { + localStorage.removeItem(key) + } catch { + // Ignore storage errors + } +} + +function parseTerminalToolDisplayMode(raw: string | null): TerminalToolDisplayMode { + if (raw === 'compact' || raw === 'detailed') { + return raw + } + return DEFAULT_TERMINAL_TOOL_DISPLAY_MODE +} + +export function getInitialTerminalToolDisplayMode(): TerminalToolDisplayMode { + return parseTerminalToolDisplayMode(safeGetItem(getTerminalToolDisplayModeStorageKey())) +} + +export function useTerminalToolDisplayMode(): { + terminalToolDisplayMode: TerminalToolDisplayMode + setTerminalToolDisplayMode: (mode: TerminalToolDisplayMode) => void +} { + const [terminalToolDisplayMode, setTerminalToolDisplayModeState] = useState(getInitialTerminalToolDisplayMode) + + useEffect(() => { + if (!isBrowser()) { + return + } + + const onStorage = (event: StorageEvent) => { + if (event.key !== getTerminalToolDisplayModeStorageKey()) { + return + } + setTerminalToolDisplayModeState(parseTerminalToolDisplayMode(event.newValue)) + } + + window.addEventListener('storage', onStorage) + return () => window.removeEventListener('storage', onStorage) + }, []) + + const setTerminalToolDisplayMode = useCallback((mode: TerminalToolDisplayMode) => { + setTerminalToolDisplayModeState(mode) + + if (mode === DEFAULT_TERMINAL_TOOL_DISPLAY_MODE) { + safeRemoveItem(getTerminalToolDisplayModeStorageKey()) + } else { + safeSetItem(getTerminalToolDisplayModeStorageKey(), mode) + } + }, []) + + return { terminalToolDisplayMode, setTerminalToolDisplayMode } +} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 8f09b929e6..e719dc882d 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -306,6 +306,9 @@ export default { 'settings.chat.enterBehavior': 'Enter Key', 'settings.chat.enterBehavior.send': 'Send message', 'settings.chat.enterBehavior.newline': 'Insert newline', + 'settings.chat.terminalToolDisplay': 'Terminal Tool Cards', + 'settings.chat.terminalToolDisplay.compact': 'Compact (command only)', + 'settings.chat.terminalToolDisplay.detailed': 'Detailed (show output preview)', 'settings.voice.title': 'Voice Assistant', 'settings.voice.language': 'Voice Language', 'settings.voice.autoDetect': 'Auto-detect', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index fb8850a6bc..55763025e5 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -308,6 +308,9 @@ export default { 'settings.chat.enterBehavior': '回车键行为', 'settings.chat.enterBehavior.send': '发送消息', 'settings.chat.enterBehavior.newline': '插入换行', + 'settings.chat.terminalToolDisplay': '终端工具卡片', + 'settings.chat.terminalToolDisplay.compact': '简洁(仅命令)', + 'settings.chat.terminalToolDisplay.detailed': '详细(显示输出预览)', 'settings.voice.title': '语音助手', 'settings.voice.language': '语音语言', 'settings.voice.autoDetect': '自动检测', diff --git a/web/src/routes/settings/index.test.tsx b/web/src/routes/settings/index.test.tsx index 75c27a731f..96758832bf 100644 --- a/web/src/routes/settings/index.test.tsx +++ b/web/src/routes/settings/index.test.tsx @@ -43,6 +43,14 @@ vi.mock('@/hooks/useComposerEnterBehavior', () => ({ ], })) +vi.mock('@/hooks/useTerminalToolDisplayMode', () => ({ + useTerminalToolDisplayMode: () => ({ terminalToolDisplayMode: 'compact', setTerminalToolDisplayMode: vi.fn() }), + getTerminalToolDisplayModeOptions: () => [ + { value: 'compact', labelKey: 'settings.chat.terminalToolDisplay.compact' }, + { value: 'detailed', labelKey: 'settings.chat.terminalToolDisplay.detailed' }, + ], +})) + // Mock useTheme hook vi.mock('@/hooks/useTheme', () => ({ useAppearance: () => ({ appearance: 'system', setAppearance: vi.fn() }), @@ -158,11 +166,19 @@ describe('SettingsPage', () => { expect(screen.getAllByText('Send message').length).toBeGreaterThanOrEqual(1) }) + it('renders the Terminal Tool Display setting', () => { + renderWithProviders() + expect(screen.getAllByText('Terminal Tool Cards').length).toBeGreaterThanOrEqual(1) + expect(screen.getAllByText('Compact (command only)').length).toBeGreaterThanOrEqual(1) + }) + it('uses correct i18n keys for the Enter Key setting', () => { const spyT = renderWithSpyT() const calledKeys = spyT.mock.calls.map((call) => call[0]) expect(calledKeys).toContain('settings.chat.title') expect(calledKeys).toContain('settings.chat.enterBehavior') expect(calledKeys).toContain('settings.chat.enterBehavior.send') + expect(calledKeys).toContain('settings.chat.terminalToolDisplay') + expect(calledKeys).toContain('settings.chat.terminalToolDisplay.compact') }) }) diff --git a/web/src/routes/settings/index.tsx b/web/src/routes/settings/index.tsx index ea76ba52ed..1dced93193 100644 --- a/web/src/routes/settings/index.tsx +++ b/web/src/routes/settings/index.tsx @@ -5,6 +5,7 @@ import { getElevenLabsSupportedLanguages, getLanguageDisplayName, type Language import { getFontScaleOptions, useFontScale, type FontScale } from '@/hooks/useFontScale' import { getTerminalFontSizeOptions, useTerminalFontSize, type TerminalFontSize } from '@/hooks/useTerminalFontSize' import { getComposerEnterBehaviorOptions, useComposerEnterBehavior, type ComposerEnterBehavior } from '@/hooks/useComposerEnterBehavior' +import { getTerminalToolDisplayModeOptions, useTerminalToolDisplayMode, type TerminalToolDisplayMode } from '@/hooks/useTerminalToolDisplayMode' import { useAppearance, getAppearanceOptions, type AppearancePreference } from '@/hooks/useTheme' import { PROTOCOL_VERSION } from '@hapi/protocol' @@ -80,16 +81,19 @@ export default function SettingsPage() { const [isFontOpen, setIsFontOpen] = useState(false) const [isTerminalFontOpen, setIsTerminalFontOpen] = useState(false) const [isChatOpen, setIsChatOpen] = useState(false) + const [isTerminalToolDisplayOpen, setIsTerminalToolDisplayOpen] = useState(false) const [isVoiceOpen, setIsVoiceOpen] = useState(false) const containerRef = useRef(null) const appearanceContainerRef = useRef(null) const fontContainerRef = useRef(null) const terminalFontContainerRef = useRef(null) const chatContainerRef = useRef(null) + const terminalToolDisplayContainerRef = useRef(null) const voiceContainerRef = useRef(null) const { fontScale, setFontScale } = useFontScale() const { terminalFontSize, setTerminalFontSize } = useTerminalFontSize() const { composerEnterBehavior, setComposerEnterBehavior } = useComposerEnterBehavior() + const { terminalToolDisplayMode, setTerminalToolDisplayMode } = useTerminalToolDisplayMode() const { appearance, setAppearance } = useAppearance() // Voice language state - read from localStorage @@ -100,12 +104,14 @@ export default function SettingsPage() { const fontScaleOptions = getFontScaleOptions() const terminalFontSizeOptions = getTerminalFontSizeOptions() const composerEnterBehaviorOptions = getComposerEnterBehaviorOptions() + const terminalToolDisplayModeOptions = getTerminalToolDisplayModeOptions() const appearanceOptions = getAppearanceOptions() const currentLocale = locales.find((loc) => loc.value === locale) const currentAppearanceLabel = appearanceOptions.find((opt) => opt.value === appearance)?.labelKey ?? 'settings.display.appearance.system' const currentFontScaleLabel = fontScaleOptions.find((opt) => opt.value === fontScale)?.label ?? '100%' const currentTerminalFontSizeLabel = terminalFontSizeOptions.find((opt) => opt.value === terminalFontSize)?.label ?? '13px' const currentComposerEnterBehaviorLabel = composerEnterBehaviorOptions.find((opt) => opt.value === composerEnterBehavior)?.labelKey ?? 'settings.chat.enterBehavior.send' + const currentTerminalToolDisplayModeLabel = terminalToolDisplayModeOptions.find((opt) => opt.value === terminalToolDisplayMode)?.labelKey ?? 'settings.chat.terminalToolDisplay.compact' const currentVoiceLanguage = voiceLanguages.find((lang) => lang.code === voiceLanguage) const handleLocaleChange = (newLocale: Locale) => { @@ -133,6 +139,11 @@ export default function SettingsPage() { setIsChatOpen(false) } + const handleTerminalToolDisplayModeChange = (newMode: TerminalToolDisplayMode) => { + setTerminalToolDisplayMode(newMode) + setIsTerminalToolDisplayOpen(false) + } + const handleVoiceLanguageChange = (language: Language) => { setVoiceLanguage(language.code) if (language.code === null) { @@ -145,7 +156,7 @@ export default function SettingsPage() { // Close dropdown when clicking outside useEffect(() => { - if (!isOpen && !isAppearanceOpen && !isFontOpen && !isTerminalFontOpen && !isChatOpen && !isVoiceOpen) return + if (!isOpen && !isAppearanceOpen && !isFontOpen && !isTerminalFontOpen && !isChatOpen && !isTerminalToolDisplayOpen && !isVoiceOpen) return const handleClickOutside = (event: MouseEvent) => { if (isOpen && containerRef.current && !containerRef.current.contains(event.target as Node)) { @@ -163,6 +174,9 @@ export default function SettingsPage() { if (isChatOpen && chatContainerRef.current && !chatContainerRef.current.contains(event.target as Node)) { setIsChatOpen(false) } + if (isTerminalToolDisplayOpen && terminalToolDisplayContainerRef.current && !terminalToolDisplayContainerRef.current.contains(event.target as Node)) { + setIsTerminalToolDisplayOpen(false) + } if (isVoiceOpen && voiceContainerRef.current && !voiceContainerRef.current.contains(event.target as Node)) { setIsVoiceOpen(false) } @@ -170,11 +184,11 @@ export default function SettingsPage() { document.addEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside) - }, [isOpen, isAppearanceOpen, isFontOpen, isTerminalFontOpen, isChatOpen, isVoiceOpen]) + }, [isOpen, isAppearanceOpen, isFontOpen, isTerminalFontOpen, isChatOpen, isTerminalToolDisplayOpen, isVoiceOpen]) // Close on escape key useEffect(() => { - if (!isOpen && !isAppearanceOpen && !isFontOpen && !isTerminalFontOpen && !isChatOpen && !isVoiceOpen) return + if (!isOpen && !isAppearanceOpen && !isFontOpen && !isTerminalFontOpen && !isChatOpen && !isTerminalToolDisplayOpen && !isVoiceOpen) return const handleEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') { @@ -183,13 +197,14 @@ export default function SettingsPage() { setIsFontOpen(false) setIsTerminalFontOpen(false) setIsChatOpen(false) + setIsTerminalToolDisplayOpen(false) setIsVoiceOpen(false) } } document.addEventListener('keydown', handleEscape) return () => document.removeEventListener('keydown', handleEscape) - }, [isOpen, isAppearanceOpen, isFontOpen, isTerminalFontOpen, isChatOpen, isVoiceOpen]) + }, [isOpen, isAppearanceOpen, isFontOpen, isTerminalFontOpen, isChatOpen, isTerminalToolDisplayOpen, isVoiceOpen]) return (
@@ -467,6 +482,54 @@ export default function SettingsPage() {
)} +
+ + + {isTerminalToolDisplayOpen && ( +
+ {terminalToolDisplayModeOptions.map((opt) => { + const isSelected = terminalToolDisplayMode === opt.value + return ( + + ) + })} +
+ )} +
{/* Voice Assistant section */}