Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
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
28 changes: 28 additions & 0 deletions web/src/components/ToolCard/ToolCard.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
26 changes: 23 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,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
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

}

function ElapsedView(props: { from: number; active: boolean }) {
const [now, setNow] = useState(() => Date.now())
Expand Down Expand Up @@ -260,6 +278,7 @@ type ToolCardProps = {
api: ApiClient
sessionId: string
metadata: SessionMetadataSummary | null
terminalToolDisplayMode: TerminalToolDisplayMode
disabled: boolean
onDone: () => void
block: ToolCallBlock
Expand Down Expand Up @@ -289,15 +308,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 +344,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