Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions web/src/components/AssistantChat/HappyThread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -264,6 +265,7 @@ export function HappyThread(props: {
onOutlineItemClick?: (item: ConversationOutlineItem) => void
}) {
const { t } = useTranslation()
const { terminalToolDisplayMode } = useTerminalToolDisplayMode()
const viewportRef = useRef<HTMLDivElement | null>(null)
const contentRef = useRef<HTMLDivElement | null>(null)
const topSentinelRef = useRef<HTMLDivElement | null>(null)
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions web/src/components/AssistantChat/context.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 2 additions & 0 deletions web/src/components/AssistantChat/messages/ToolMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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}
Expand Down
24 changes: 24 additions & 0 deletions web/src/components/ToolCard/ToolCard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
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('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)
})

it('still hides inline bodies for minimal and Task/Agent subagent cards', () => {
expect(shouldShowInlineToolCardBody('CodexBash', true, 'detailed')).toBe(false)
expect(shouldShowInlineToolCardBody('Task', false, 'detailed')).toBe(false)
expect(shouldShowInlineToolCardBody('Agent', false, 'detailed')).toBe(false)
})
})
24 changes: 21 additions & 3 deletions web/src/components/ToolCard/ToolCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -25,6 +26,21 @@ 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'])
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Major] run_shell_command is already exercised as a shell-like tool in the web tests (knownTools.test.tsx expects it to surface a command subtitle), but it is not included here. That means Gemini ACP shell cards keep bypassing the new compact/detailed terminal-card setting, leaving a real terminal path with the old behavior.

Suggested fix:

const TERMINAL_RELATED_TOOL_NAMES = new Set(['Bash', 'CodexBash', 'shell_command', 'run_shell_command'])

Add a matching regression expectation too:

expect(shouldUseCompactTerminalToolCard('run_shell_command', 'compact')).toBe(true)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. run_shell_command is now treated as a terminal-related card too, so the compact default and detailed toggle cover the Gemini shell path instead of only Bash / CodexBash / shell_command.

Added regression coverage for:

  • shouldUseCompactTerminalToolCard('run_shell_command', 'compact')
  • shouldShowInlineToolCardBody('run_shell_command', true, 'detailed')

Re-ran:

  • bun run test -- src/components/ToolCard/ToolCard.test.ts src/routes/settings/index.test.tsx src/hooks/useTerminalToolDisplayMode.test.ts
  • bun run typecheck


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 {
return !presentationMinimal
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Detailed mode still cannot show Bash/shell_command output previews. This helper still requires !presentationMinimal, but knownTools marks Bash and shell_command as minimal: true, while this PR adds both names to the terminal-related set. As a result, selecting “Detailed (show output preview)” only restores inline output for non-minimal CodexBash results and leaves Claude/Gemini terminal cards compact.

Suggested fix:

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
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Detailed mode now bypasses the minimal gate for terminal-related cards, so Bash and shell_command restore inline output previews too instead of only CodexBash.

Added helper coverage for:

  • Bash detailed mode
  • shell_command detailed mode
  • non-terminal minimal cards still staying compact

Re-ran:

  • bun run test -- src/hooks/useTerminalToolDisplayMode.test.ts src/components/ToolCard/ToolCard.test.ts src/routes/settings/index.test.tsx
  • bun run typecheck

&& !isSubagentToolName(toolName)
&& !shouldUseCompactTerminalToolCard(toolName, terminalToolDisplayMode)
}

function ElapsedView(props: { from: number; active: boolean }) {
const [now, setNow] = useState(() => Date.now())
Expand Down Expand Up @@ -260,6 +276,7 @@ type ToolCardProps = {
api: ApiClient
sessionId: string
metadata: SessionMetadataSummary | null
terminalToolDisplayMode: TerminalToolDisplayMode
disabled: boolean
onDone: () => void
block: ToolCallBlock
Expand Down Expand Up @@ -289,15 +306,16 @@ 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)
const permission = props.block.tool.permission
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))
Expand All @@ -324,7 +342,7 @@ function ToolCardInner(props: ToolCardProps) {
{subtitle ? (
<CardDescription className={cn(
'font-mono text-xs text-[var(--app-tool-card-subtitle)]',
isCodexAgentCard ? 'truncate whitespace-nowrap' : 'break-all'
isCodexAgentCard || useCompactTerminalCard ? 'truncate whitespace-nowrap' : 'break-all'
)}>
{truncate(subtitle, 160)}
</CardDescription>
Expand Down
32 changes: 32 additions & 0 deletions web/src/hooks/useTerminalToolDisplayMode.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
99 changes: 99 additions & 0 deletions web/src/hooks/useTerminalToolDisplayMode.ts
Original file line number Diff line number Diff line change
@@ -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<TerminalToolDisplayMode>(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 }
}
3 changes: 3 additions & 0 deletions web/src/lib/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
3 changes: 3 additions & 0 deletions web/src/lib/locales/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': '自动检测',
Expand Down
16 changes: 16 additions & 0 deletions web/src/routes/settings/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() }),
Expand Down Expand Up @@ -158,11 +166,19 @@ describe('SettingsPage', () => {
expect(screen.getAllByText('Send message').length).toBeGreaterThanOrEqual(1)
})

it('renders the Terminal Tool Display setting', () => {
renderWithProviders(<SettingsPage />)
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(<SettingsPage />)
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')
})
})
Loading
Loading