diff --git a/mobile/src/session/MobileNativeChatPermission.test.ts b/mobile/src/session/MobileNativeChatPermission.test.ts index 88de20a7ca5..a29067cf1f6 100644 --- a/mobile/src/session/MobileNativeChatPermission.test.ts +++ b/mobile/src/session/MobileNativeChatPermission.test.ts @@ -5,6 +5,7 @@ import { MobileNativeChatPermission } from './MobileNativeChatPermission' vi.mock('react-native', () => ({ Pressable: 'Pressable', + ScrollView: 'ScrollView', StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, Text: 'Text', View: 'View' @@ -62,4 +63,50 @@ describe('MobileNativeChatPermission', () => { await act(async () => cancel.props.onPress()) expect(onCancel).toHaveBeenCalledWith({ itemId: 'approval-1', expectedRevision: 4 }) }) + + it('keeps oversized provider context in a bounded scroller above the actions', async () => { + const description = `Workspace access ${'description '.repeat(400)}` + const decisionReason = `Outside the allowed root ${'reason '.repeat(400)}` + const blockedPath = `/repo/${'nested/'.repeat(400)}secrets.txt` + const ruleContent = `/repo/${'**/'.repeat(400)}` + await act(async () => { + renderer = create( + createElement(MobileNativeChatPermission, { + permission: { + title: 'Claude wants to read secrets.txt '.repeat(400), + description, + decisionReason, + blockedPath, + matchedAskRule: { source: 'project', toolName: 'Read', ruleContent }, + options: [{ label: 'Allow', send: '1' }] + }, + onRespond: vi.fn(async () => true) + }) + ) + }) + + const card = renderer.root.findByProps({ testID: 'native-chat-approval-card' }) + const title = renderer.root.findByProps({ testID: 'native-chat-approval-title' }) + const content = renderer.root.findByProps({ testID: 'native-chat-approval-content' }) + const actions = renderer.root.findByProps({ testID: 'native-chat-approval-actions' }) + const contentText = content.findAllByType('Text') + const containsText = (value: string): boolean => + contentText.some((node) => { + const children = Array.isArray(node.props.children) + ? node.props.children + : [node.props.children] + return children.includes(value) + }) + + expect(card.props.style).toMatchObject({ flexShrink: 1, minHeight: 0 }) + expect(title.props).toMatchObject({ numberOfLines: 2, ellipsizeMode: 'tail' }) + expect(content.props.style).toMatchObject({ maxHeight: 240, minHeight: 0, flexShrink: 1 }) + expect(containsText(description)).toBe(true) + expect(containsText(decisionReason)).toBe(true) + expect(containsText(blockedPath)).toBe(true) + expect(containsText(ruleContent)).toBe(true) + expect(content.findAllByProps({ children: 'Allow' })).toHaveLength(0) + expect(actions.findAllByProps({ children: 'Allow' })).toHaveLength(1) + expect(actions.props.style).toMatchObject({ flexShrink: 0 }) + }) }) diff --git a/mobile/src/session/MobileNativeChatPermission.tsx b/mobile/src/session/MobileNativeChatPermission.tsx index 47ad7a52022..e482ac00418 100644 --- a/mobile/src/session/MobileNativeChatPermission.tsx +++ b/mobile/src/session/MobileNativeChatPermission.tsx @@ -1,5 +1,5 @@ import { memo, useRef, useState } from 'react' -import { Pressable, StyleSheet, Text, View } from 'react-native' +import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native' import { ShieldQuestion, X } from 'lucide-react-native' import { colors, radii, spacing, typography } from '../theme/mobile-theme' import type { MobileChatPermission } from './mobile-native-chat-permission' @@ -18,6 +18,13 @@ function MobileNativeChatPermissionImpl({ }): React.JSX.Element { const [submitting, setSubmitting] = useState(false) const submittingRef = useRef(false) + const hasContext = Boolean( + permission.description || + permission.decisionReason || + permission.blockedPath || + permission.matchedAskRule || + permission.detail + ) const respond = async (send: string): Promise => { if (submittingRef.current) { return @@ -31,10 +38,17 @@ function MobileNativeChatPermissionImpl({ } } return ( - + - {permission.title} + + {permission.title} + {onCancel ? ( ) : null} - {permission.detail ? {permission.detail} : null} - + {hasContext ? ( + + {permission.description ? ( + {permission.description} + ) : null} + {permission.decisionReason ? ( + + Reason: + {permission.decisionReason} + + ) : null} + {permission.blockedPath ? ( + + Blocked path: + {permission.blockedPath} + + ) : null} + {permission.matchedAskRule ? ( + + Ask rule: + {permission.matchedAskRule.ruleContent ?? permission.matchedAskRule.toolName} + {' · '} + {permission.matchedAskRule.source} + + ) : null} + {permission.detail ? {permission.detail} : null} + + ) : null} + {permission.options.map((option, index) => { const isPrimary = index === 0 return ( @@ -85,12 +131,15 @@ const styles = StyleSheet.create({ borderRadius: radii.card, borderWidth: StyleSheet.hairlineWidth, borderColor: colors.borderSubtle, - backgroundColor: colors.bgPanel + backgroundColor: colors.bgPanel, + flexShrink: 1, + minHeight: 0 }, header: { flexDirection: 'row', alignItems: 'center', - gap: spacing.sm + gap: spacing.sm, + flexShrink: 0 }, title: { flex: 1, @@ -109,10 +158,23 @@ const styles = StyleSheet.create({ fontSize: typography.metaSize, lineHeight: typography.metaSize + 5 }, + contextLabel: { + color: colors.textPrimary, + fontWeight: '600' + }, + contentScroll: { + maxHeight: 240, + minHeight: 0, + flexShrink: 1 + }, + content: { + gap: spacing.sm + }, options: { flexDirection: 'row', flexWrap: 'wrap', - gap: spacing.sm + gap: spacing.sm, + flexShrink: 0 }, option: { minHeight: 44, diff --git a/mobile/src/session/mobile-native-chat-permission.ts b/mobile/src/session/mobile-native-chat-permission.ts index 53799718eb4..dadefc19519 100644 --- a/mobile/src/session/mobile-native-chat-permission.ts +++ b/mobile/src/session/mobile-native-chat-permission.ts @@ -1,3 +1,5 @@ +import type { AgentJournalApprovalMatchedAskRule } from '../../../src/shared/agent-session-journal-types' + // Agent permission asks (e.g. Claude/Codex "Do you want to proceed?") surface // as plain TUI text in the agent's last assistant message — there is no // structured permission event on mobile. We detect them heuristically so the @@ -10,6 +12,11 @@ * (e.g. "y", "1") when the user taps it. */ export type MobileChatPermission = { title: string + displayName?: string + description?: string + decisionReason?: string + blockedPath?: string + matchedAskRule?: AgentJournalApprovalMatchedAskRule detail?: string /** Structured prompt identity, present only when the host can cancel it exactly. */ prompt?: { itemId: string; expectedRevision: number } diff --git a/mobile/src/session/mobile-structured-agent-prompts.ts b/mobile/src/session/mobile-structured-agent-prompts.ts index 82d619e49e6..329629c2bea 100644 --- a/mobile/src/session/mobile-structured-agent-prompts.ts +++ b/mobile/src/session/mobile-structured-agent-prompts.ts @@ -135,6 +135,11 @@ export function projectStructuredPermission( return { title: prompt.body.title, prompt: { itemId: prompt.itemId, expectedRevision: prompt.revision }, + ...(prompt.body.displayName ? { displayName: prompt.body.displayName } : {}), + ...(prompt.body.description ? { description: prompt.body.description } : {}), + ...(prompt.body.decisionReason ? { decisionReason: prompt.body.decisionReason } : {}), + ...(prompt.body.blockedPath ? { blockedPath: prompt.body.blockedPath } : {}), + ...(prompt.body.matchedAskRule ? { matchedAskRule: prompt.body.matchedAskRule } : {}), ...(prompt.body.detail ? { detail: prompt.body.detail } : {}), options: prompt.body.options.map((option) => ({ label: option.label, diff --git a/src/main/claude/claude-permission-presentation.ts b/src/main/claude/claude-permission-presentation.ts new file mode 100644 index 00000000000..4e6d8be1019 --- /dev/null +++ b/src/main/claude/claude-permission-presentation.ts @@ -0,0 +1,47 @@ +import type { CanUseTool } from '@anthropic-ai/claude-agent-sdk' +import { + stripAnsiEscapeSequences, + TERMINAL_CONTROL_CHARACTER_PATTERN +} from '../../shared/ansi-escape-sequences' +import type { ClaudePromptPresentation } from './claude-prompt-registry' + +type ClaudePermissionOptions = Parameters[2] + +function presentationText(value: unknown): string | null { + if (typeof value !== 'string') { + return null + } + const sanitized = stripAnsiEscapeSequences(value) + .replace(TERMINAL_CONTROL_CHARACTER_PATTERN, '') + .trim() + return sanitized.length > 0 ? sanitized : null +} + +export function claudePermissionPresentation( + options: ClaudePermissionOptions +): ClaudePromptPresentation { + const title = presentationText(options.title) + const displayName = presentationText(options.displayName) + const description = presentationText(options.description) + const decisionReason = presentationText(options.decisionReason) + const blockedPath = presentationText(options.blockedPath) + const matchedSource = presentationText(options.matchedAskRule?.source) + const matchedToolName = presentationText(options.matchedAskRule?.toolName) + const matchedRuleContent = presentationText(options.matchedAskRule?.ruleContent) + return { + ...(title ? { title } : {}), + ...(displayName ? { displayName } : {}), + ...(description ? { description } : {}), + ...(decisionReason ? { decisionReason } : {}), + ...(blockedPath ? { blockedPath } : {}), + ...(matchedSource && matchedToolName + ? { + matchedAskRule: { + source: matchedSource, + toolName: matchedToolName, + ...(matchedRuleContent ? { ruleContent: matchedRuleContent } : {}) + } + } + : {}) + } +} diff --git a/src/main/claude/claude-prompt-registry.ts b/src/main/claude/claude-prompt-registry.ts index 6411a19e215..78e17824e8a 100644 --- a/src/main/claude/claude-prompt-registry.ts +++ b/src/main/claude/claude-prompt-registry.ts @@ -1,9 +1,19 @@ import type { PermissionResult, PermissionUpdate } from '@anthropic-ai/claude-agent-sdk' +import type { AgentJournalApprovalMatchedAskRule } from '../../shared/agent-session-journal-types' /** Settles the SDK's `canUseTool` promise; `null` writes no provider response. */ export type ClaudePromptSettle = (response: PermissionResult | null) => void -export type ClaudePendingPrompt = { +export type ClaudePromptPresentation = { + title?: string + displayName?: string + description?: string + decisionReason?: string + blockedPath?: string + matchedAskRule?: AgentJournalApprovalMatchedAskRule +} + +export type ClaudePendingPrompt = ClaudePromptPresentation & { requestId: string promptKey: string toolUseId: string @@ -17,7 +27,7 @@ export type ClaudePendingPrompt = { turnId?: string | null } -export type ClaudePromptRegistration = { +export type ClaudePromptRegistration = ClaudePromptPresentation & { requestId: string toolName: string toolUseId: string @@ -89,6 +99,12 @@ export class ClaudePromptRegistry { kind: questions.length > 0 ? 'question' : 'approval', input, suggestions: Array.isArray(registration.suggestions) ? registration.suggestions : [], + ...(registration.title ? { title: registration.title } : {}), + ...(registration.displayName ? { displayName: registration.displayName } : {}), + ...(registration.description ? { description: registration.description } : {}), + ...(registration.decisionReason ? { decisionReason: registration.decisionReason } : {}), + ...(registration.blockedPath ? { blockedPath: registration.blockedPath } : {}), + ...(registration.matchedAskRule ? { matchedAskRule: registration.matchedAskRule } : {}), questionIds: questions.map(questionId), answers: new Map(), settle: registration.settle, diff --git a/src/main/claude/claude-structured-inbound-control.test.ts b/src/main/claude/claude-structured-inbound-control.test.ts index 07be4bbb516..4e799dd03fc 100644 --- a/src/main/claude/claude-structured-inbound-control.test.ts +++ b/src/main/claude/claude-structured-inbound-control.test.ts @@ -14,14 +14,17 @@ function permissionOptions( requestId: string, toolUseID: string, signal: AbortSignal, - suggestions?: unknown[] + suggestions?: CanUseToolOptions['suggestions'], + presentation: Partial = {} ): CanUseToolOptions { + // Spread first so the required fields below stay definite rather than optional. return { + ...presentation, requestId, toolUseID, signal, ...(suggestions ? { suggestions } : {}) - } as unknown as CanUseToolOptions + } } function callbacksFor() { @@ -41,7 +44,9 @@ describe('Claude permission callbacks', () => { const answered = control.canUseTool( 'Bash', { command: 'git status' }, - permissionOptions('perm-1', 'tool-1', new AbortController().signal, [{ type: 'addRules' }]) + permissionOptions('perm-1', 'tool-1', new AbortController().signal, [ + { type: 'addRules', rules: [], behavior: 'allow', destination: 'session' } + ]) ) expect(control.emit).toHaveBeenCalledWith( @@ -52,12 +57,49 @@ describe('Claude permission callbacks', () => { }) ) const found = control.prompts.find('perm-1') - expect(found?.prompt.suggestions).toEqual([{ type: 'addRules' }]) + expect(found?.prompt.suggestions).toEqual([ + { type: 'addRules', rules: [], behavior: 'allow', destination: 'session' } + ]) // The prompt's settle is the SDK callback's own resolve — answering resolves this promise. found?.prompt.settle({ behavior: 'allow', toolUseID: 'tool-1' }) await expect(answered).resolves.toEqual({ behavior: 'allow', toolUseID: 'tool-1' }) }) + it('keeps the SDK permission presentation and strips terminal escapes', async () => { + const control = callbacksFor() + const answered = control.canUseTool( + 'Read', + { file_path: '/repo/secrets.txt' }, + permissionOptions( + 'perm-presentation', + 'tool-presentation', + new AbortController().signal, + [], + { + title: '\u001b[31mClaude wants to read secrets.txt\u001b[0m', + displayName: 'Read file', + description: 'Read access outside the workspace', + decisionReason: '\u001b[33mThe path is outside the allowed root.\u001b[0m', + blockedPath: '/repo/secrets.txt', + matchedAskRule: { source: 'project', toolName: 'Read', ruleContent: '/repo/**' } + } + ) + ) + + const prompt = control.prompts.find('perm-presentation')?.prompt + expect(prompt).toMatchObject({ + title: 'Claude wants to read secrets.txt', + displayName: 'Read file', + description: 'Read access outside the workspace', + decisionReason: 'The path is outside the allowed root.', + blockedPath: '/repo/secrets.txt', + matchedAskRule: { source: 'project', toolName: 'Read', ruleContent: '/repo/**' } + }) + expect(JSON.stringify(prompt)).not.toContain('\\u001b') + prompt?.settle({ behavior: 'deny', message: 'done', toolUseID: 'tool-presentation' }) + await expect(answered).resolves.toMatchObject({ behavior: 'deny' }) + }) + it('denies a malformed permission request without registering a prompt', async () => { const control = callbacksFor() const answered = control.canUseTool( diff --git a/src/main/claude/claude-structured-inbound-control.ts b/src/main/claude/claude-structured-inbound-control.ts index 27a90181aec..f613cf1a1c8 100644 --- a/src/main/claude/claude-structured-inbound-control.ts +++ b/src/main/claude/claude-structured-inbound-control.ts @@ -1,6 +1,7 @@ import type { CanUseTool, OnUserDialog, PermissionResult } from '@anthropic-ai/claude-agent-sdk' import type { ClaudePromptRegistry } from './claude-structured-prompt-replies' import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' +import { claudePermissionPresentation } from './claude-permission-presentation' export const CLAUDE_CAN_USE_TOOL_SUBTYPE = 'can_use_tool' export const CLAUDE_REQUEST_USER_DIALOG_SUBTYPE = 'request_user_dialog' @@ -53,6 +54,7 @@ export function buildClaudePermissionCallbacks(deps: ClaudePermissionCallbackDep const canUseTool: CanUseTool = (toolName, input, options) => new Promise((resolve) => { const prompt = deps.prompts.register({ + ...claudePermissionPresentation(options), requestId: options.requestId, toolName, toolUseId: options.toolUseID, diff --git a/src/main/claude/claude-structured-prompt-items.test.ts b/src/main/claude/claude-structured-prompt-items.test.ts index eec4ee74c9c..0650cc769b5 100644 --- a/src/main/claude/claude-structured-prompt-items.test.ts +++ b/src/main/claude/claude-structured-prompt-items.test.ts @@ -3,13 +3,96 @@ import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key import { encodeAgentSessionQuestionAnswers } from '../../shared/agent-session-question-answer' import { cancelledJournalPromptBody } from '../native-chat/agent-session-journal/journal-prompt-body-bounds' import { MAX_JOURNAL_LIFECYCLE_BATCH_BYTES } from '../native-chat/agent-session-journal/journal-row-schema' -import { claudeQuestionItems } from './claude-structured-prompt-items' +import { MAX_TOOL_DETAIL_LENGTH } from '../../shared/native-chat-tool-summary' +import { claudeApprovalItem, claudeQuestionItems } from './claude-structured-prompt-items' import { applyClaudePromptAnswer, encodeClaudeQuestionOptionId, type ClaudePendingPrompt } from './claude-structured-prompt-replies' +function approvalPrompt( + input: Record, + presentation: Partial = {} +): ClaudePendingPrompt { + return { + requestId: 'approval-1', + promptKey: 'approval-1', + toolUseId: 'tool-approval', + toolName: 'ExitPlanMode', + kind: 'approval', + input, + suggestions: [], + questionIds: [], + answers: new Map(), + settle: () => {}, + ...presentation + } +} + +describe('Claude structured approval presentation', () => { + it('journals the harness presentation instead of reconstructing from tool input', () => { + const prompt = approvalPrompt( + { file_path: '/repo/secrets.txt', content: 'export const token = 1' }, + { + toolName: 'Write', + title: 'Claude wants to write secrets.txt', + displayName: 'Write file', + description: 'Write access inside the workspace.', + decisionReason: 'The path requires approval.', + blockedPath: '/repo/secrets.txt', + matchedAskRule: { source: 'project', toolName: 'Write', ruleContent: 'ask' } + } + ) + + expect(claudeApprovalItem(prompt)).toMatchObject({ + kind: 'approval', + title: 'Claude wants to write secrets.txt', + displayName: 'Write file', + description: 'Write access inside the workspace.', + decisionReason: 'The path requires approval.', + blockedPath: '/repo/secrets.txt', + matchedAskRule: { source: 'project', toolName: 'Write', ruleContent: 'ask' }, + options: [ + { id: 'allow', label: 'Allow' }, + { id: 'allowForSession', label: 'Allow for this session' }, + { id: 'deny', label: 'Deny' }, + { id: 'cancel', label: 'Stop' } + ] + }) + }) + + it.each([{ plan: '' }, {}])( + 'falls back to a reconstructed title when the harness sends no presentation', + (input) => { + const item = claudeApprovalItem(approvalPrompt(input)) + + expect(item.title).toBe('Allow ExitPlanMode?') + expect(item.detail).toContain('{') + expect(item.options[0]?.label).toBe('Allow') + } + ) + + it('caps an oversized generic payload with the shared tool-detail limit', () => { + const item = claudeApprovalItem( + approvalPrompt({ plan: '', payload: 'x'.repeat(MAX_TOOL_DETAIL_LENGTH * 2) }) + ) + + expect(item.detail?.length).toBeLessThanOrEqual(MAX_TOOL_DETAIL_LENGTH + 1) + expect(item.detail?.endsWith('…')).toBe(true) + }) + + it('denying authorizes nothing', () => { + const prompt = approvalPrompt({ plan: '# Release' }) + + expect(applyClaudePromptAnswer({ prompt }, 'deny')).toEqual({ + behavior: 'deny', + message: 'User denied this action.', + toolUseID: 'tool-approval' + }) + }) +}) + describe('Claude structured question addressing', () => { it('bounds a valid grouped question before cancellation enters a lifecycle batch', () => { const oversized = 'large prompt text '.repeat(40_000) diff --git a/src/main/claude/claude-structured-prompt-items.ts b/src/main/claude/claude-structured-prompt-items.ts index 25b307bd809..8a4f0151d8b 100644 --- a/src/main/claude/claude-structured-prompt-items.ts +++ b/src/main/claude/claude-structured-prompt-items.ts @@ -5,10 +5,7 @@ import type { AgentJournalQuestion, AgentJournalQuestionItem } from '../../shared/agent-session-journal-types' -import { - boundInlineText, - DEFAULT_JOURNAL_PAYLOAD_LIMITS -} from '../native-chat/agent-session-journal/journal-payload-bounds' +import { formatToolInput, truncateToolDetail } from '../../shared/native-chat-tool-summary' import { boundJournalPromptBody } from '../native-chat/agent-session-journal/journal-prompt-body-bounds' import { claudeRecord, claudeText } from './claude-structured-item-translation' import { @@ -45,17 +42,22 @@ export function claudePromptIdentity(input: { } export function claudeApprovalItem(prompt: ClaudePendingPrompt): AgentJournalApprovalItem { - const serialized = JSON.stringify(prompt.input) - return { + const detail = truncateToolDetail(formatToolInput(prompt.input)) + return boundJournalPromptBody({ kind: 'approval', - title: `Allow ${prompt.toolName}?`, - detail: serialized ? boundInlineText(serialized, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text : null, + title: prompt.title ?? `Allow ${prompt.toolName}?`, + ...(prompt.displayName ? { displayName: prompt.displayName } : {}), + ...(prompt.description ? { description: prompt.description } : {}), + ...(prompt.decisionReason ? { decisionReason: prompt.decisionReason } : {}), + ...(prompt.blockedPath ? { blockedPath: prompt.blockedPath } : {}), + ...(prompt.matchedAskRule ? { matchedAskRule: prompt.matchedAskRule } : {}), + detail: detail || null, options: CLAUDE_APPROVAL_DECISIONS.map((decision) => ({ id: decision, label: APPROVAL_LABELS[decision] })), resolution: { ...PENDING } - } + }) } export type ClaudeQuestionItem = { diff --git a/src/main/claude/claude-structured-prompt-replies.ts b/src/main/claude/claude-structured-prompt-replies.ts index 5a6bc19b9a8..ed3763c9d6c 100644 --- a/src/main/claude/claude-structured-prompt-replies.ts +++ b/src/main/claude/claude-structured-prompt-replies.ts @@ -10,6 +10,7 @@ export { ClaudePromptRegistry, type ClaudePendingPrompt, type ClaudePromptClaim, + type ClaudePromptPresentation, type ClaudePromptRegistration, type ClaudePromptSettle } from './claude-prompt-registry' diff --git a/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts b/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts index 2ecd4e22cb6..376c03be4d4 100644 --- a/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts +++ b/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts @@ -51,6 +51,23 @@ export function boundJournalPromptBody( return { ...body, title: boundPromptText(body.title), + ...(body.displayName === undefined ? {} : { displayName: boundPromptText(body.displayName) }), + ...(body.description === undefined ? {} : { description: boundPromptText(body.description) }), + ...(body.decisionReason === undefined + ? {} + : { decisionReason: boundPromptText(body.decisionReason) }), + ...(body.blockedPath === undefined ? {} : { blockedPath: boundPromptText(body.blockedPath) }), + ...(body.matchedAskRule === undefined + ? {} + : { + matchedAskRule: { + source: boundPromptText(body.matchedAskRule.source), + toolName: boundPromptText(body.matchedAskRule.toolName), + ...(body.matchedAskRule.ruleContent === undefined + ? {} + : { ruleContent: boundPromptText(body.matchedAskRule.ruleContent) }) + } + }), detail: body.detail === null ? null : boundPromptText(body.detail), options: boundPromptOptions(body.options) } diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index f6b540bc5af..2fccc11fb3c 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -661,7 +661,10 @@ describe('a structured Claude session over agentSession.*', () => { ) await getStructuredAgentSessionHost()?.flushStreamedEvents(SESSION) const approval = itemsOf(stream).find((item) => item.body?.kind === 'approval') - expect(approval?.body).toMatchObject({ title: 'Allow Bash?', detail: '{"command":"ls"}' }) + expect(approval?.body).toMatchObject({ + title: 'Allow Bash?', + detail: '{\n "command": "ls"\n}' + }) await ok('agentSession.respondToApproval', { envelope: envelope( 'agentSession.respondTo:approval', diff --git a/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx b/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx index b3321a6c86f..692ad269e5c 100644 --- a/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx @@ -1,9 +1,11 @@ // @vitest-environment happy-dom -import { fireEvent, render, screen } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' import { NativeChatApprovalCard } from './NativeChatApprovalCard' +afterEach(cleanup) + describe('NativeChatApprovalCard', () => { it('exposes cancellation while it owns the composer region', () => { const onCancel = vi.fn() @@ -26,4 +28,76 @@ describe('NativeChatApprovalCard', () => { fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) expect(onCancel).toHaveBeenCalledOnce() }) + + it('focuses once on appearance and routes Escape through cancellation', () => { + const onCancel = vi.fn() + const { rerender } = render( + {}} + onCancel={onCancel} + shouldFocus + /> + ) + const card = screen.getByRole('group', { name: 'Allow command?' }) + + expect(document.activeElement).toBe(card) + fireEvent.keyDown(card, { key: 'Escape' }) + expect(onCancel).toHaveBeenCalledOnce() + + const outside = document.createElement('button') + document.body.appendChild(outside) + outside.focus() + rerender( + {}} + onCancel={onCancel} + shouldFocus + /> + ) + expect(document.activeElement).toBe(outside) + outside.remove() + }) + + it('keeps all oversized provider context in one bounded scroller above the actions', () => { + const description = `Read access outside the workspace ${'description '.repeat(400)}` + const decisionReason = `The path is outside the allowed root. ${'reason '.repeat(400)}` + const blockedPath = `/repo/${'nested/'.repeat(400)}secrets.txt` + const ruleContent = `/repo/${'**/'.repeat(400)}` + render( + {}} + /> + ) + + const card = document.querySelector('[data-native-chat-approval-card="true"]') + const content = document.querySelector('[data-native-chat-approval-content="true"]') + const detail = document.querySelector('[data-native-chat-approval-detail="true"]') + const actions = document.querySelector('[data-native-chat-approval-actions="true"]') + const allow = screen.getByRole('button', { name: 'Allow' }) + + expect(card?.classList.contains('min-h-0')).toBe(true) + expect(card?.classList.contains('overflow-hidden')).toBe(true) + expect(content?.classList.contains('max-h-72')).toBe(true) + expect(content?.classList.contains('overflow-auto')).toBe(true) + expect(content?.getAttribute('tabindex')).toBe('0') + expect(content?.textContent).toContain(description.trim()) + expect(content?.textContent).toContain(decisionReason.trim()) + expect(content?.textContent).toContain(blockedPath) + expect(content?.textContent).toContain(ruleContent) + expect(content?.contains(detail)).toBe(true) + expect(content?.contains(allow)).toBe(false) + expect(actions?.contains(allow)).toBe(true) + expect(actions?.classList.contains('shrink-0')).toBe(true) + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx b/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx index 3c5766b7f2f..13864e36da1 100644 --- a/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx @@ -1,3 +1,4 @@ +import { useEffect, useRef } from 'react' import { ShieldQuestion, X } from 'lucide-react' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' @@ -5,36 +6,62 @@ import type { ChatApproval } from './native-chat-interactive-prompt' export type NativeChatApprovalCardProps = { approval: ChatApproval - /** Send the chosen option's literal string to the agent's PTY. */ - onChoose: (send: string) => void + /** Deliver the option's transport-specific response token. */ + onChoose: (option: string) => void /** Cancel the active provider turn while this card owns the composer region. */ onCancel?: () => void + shouldFocus?: boolean } /** * Native renderer for an agent tool-approval (PermissionRequest) as an - * Allow/Deny card. Each button writes its option's literal `send` string back - * to the agent (a number to allow; ESC to deny). The first option reads as the - * affirmative action and gets the primary styling. + * Allow/Deny card. PTY callers supply literal replies while structured callers + * supply journal option IDs. The first option gets the primary styling. */ export function NativeChatApprovalCard({ approval, onChoose, - onCancel + onCancel, + shouldFocus = false }: NativeChatApprovalCardProps): React.JSX.Element { + const cardRef = useRef(null) + const hasContext = Boolean( + approval.description || + approval.decisionReason || + approval.blockedPath || + approval.matchedAskRule || + approval.detail + ) + useEffect(() => { + if (shouldFocus) { + cardRef.current?.focus() + } + }, [shouldFocus]) + return ( -
-
-
-
+
+
+
{ + if (event.key === 'Escape' && !event.nativeEvent.isComposing && onCancel) { + event.preventDefault() + event.stopPropagation() + onCancel() + } + }} + className="flex min-h-0 w-full flex-1 flex-col gap-2 overflow-hidden rounded-lg border border-input bg-card px-4 py-3 shadow-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + > +
-

{approval.title}

- {approval.detail ? ( -

- {approval.detail} -

- ) : null} +

+ {approval.title} +

{onCancel ? (
-
+ {hasContext ? ( +
+ {approval.description ? ( +

{approval.description}

+ ) : null} + {approval.decisionReason ? ( +

+ + {translate('components.native-chat.approval.reason', 'Reason')}:{' '} + + {approval.decisionReason} +

+ ) : null} + {approval.blockedPath ? ( +

+ + {translate('components.native-chat.approval.blockedPath', 'Blocked path')}:{' '} + + {approval.blockedPath} +

+ ) : null} + {approval.matchedAskRule ? ( +

+ + {translate('components.native-chat.approval.askRule', 'Ask rule')}:{' '} + + {approval.matchedAskRule.ruleContent ?? approval.matchedAskRule.toolName} + + {' · '} + {approval.matchedAskRule.source} + +

+ ) : null} + {approval.detail ? ( +
+ {approval.detail} +
+ ) : null} +
+ ) : null} +
{approval.options.map((opt, i) => (
- {prompt?.body.kind === 'approval' ? ( + {prompt && approval ? ( ({ - label: option.label, - send: option.id - })) - }} + key={`${prompt.itemId}:${prompt.revision}`} + approval={approval} onChoose={(optionId) => void controller.respond(prompt, optionId)} onCancel={cancelPrompt} + shouldFocus={props.isVisible && props.isFocusedGroup} /> ) : null} {prompt && questionBody ? ( diff --git a/src/renderer/src/components/native-chat/native-chat-composer-reveal-focus.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-reveal-focus.test.tsx index 4c6023d63f2..b83f1df97e6 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-reveal-focus.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-reveal-focus.test.tsx @@ -254,6 +254,23 @@ describe('useNativeChatComposerRevealFocus', () => { expect(focusCalls).toBe(1) }) + it('re-arms when a prompt replaces an already focused composer', () => { + render({ isVisible: true, isFocusedGroup: true, composerReady: true }) + drainFrames() + expect(focusCalls).toBe(1) + + render({ isVisible: true, isFocusedGroup: true, composerReady: false }) + drainFrames() + act(() => { + ;(document.activeElement as HTMLElement | null)?.blur() + }) + render({ isVisible: true, isFocusedGroup: true, composerReady: true }) + drainFrames() + + expect(focusCalls).toBe(2) + expect(container.querySelector('textarea')).toBe(document.activeElement) + }) + it('leaves focus alone when it is already inside the pane', () => { render({ isVisible: false, isFocusedGroup: true }) const field = container.querySelector('textarea') diff --git a/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts b/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts index 1590f55e7cc..3c04b5b3343 100644 --- a/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts +++ b/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts @@ -1,4 +1,5 @@ import { translate } from '@/i18n/i18n' +import type { AgentJournalApprovalMatchedAskRule } from '../../../../shared/agent-session-journal-types' import { buildAskAnswerKeys, buildCodexAskAnswerKeys, @@ -31,6 +32,11 @@ export { export type ChatApproval = { title: string + displayName?: string + description?: string + decisionReason?: string + blockedPath?: string + matchedAskRule?: AgentJournalApprovalMatchedAskRule detail?: string options: { label: string; send: string }[] } diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts index c8a3e059489..819bc1e5229 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts @@ -36,11 +36,11 @@ export function useNativeChatComposerRevealFocus({ const claimedRef = useRef(false) useEffect(() => { - if (!revealed) { + if (!revealed || !composerReady) { claimedRef.current = false return } - if (claimedRef.current || !composerReady) { + if (claimedRef.current) { return } let cancelled = false diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 425b65754b9..8cfb88a7989 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17316,7 +17316,10 @@ "title": "Allow {{value0}}?", "allow": "Allow", "deny": "Deny", - "cancel": "Cancel" + "cancel": "Cancel", + "reason": "Reason", + "blockedPath": "Blocked path", + "askRule": "Ask rule" }, "launchPromptNotDelivered": "Not delivered — check the terminal", "structuredSessionCloseFailed": "Could not close this chat session", diff --git a/src/shared/agent-session-journal-schemas.test.ts b/src/shared/agent-session-journal-schemas.test.ts index 3a515952601..31b55077331 100644 --- a/src/shared/agent-session-journal-schemas.test.ts +++ b/src/shared/agent-session-journal-schemas.test.ts @@ -50,7 +50,12 @@ const CANONICAL_BODIES: AgentJournalItemBody[] = [ { kind: 'diff', path: 'a.ts', patch: PAYLOAD }, { kind: 'approval', - title: 'Run?', + title: 'Claude wants to present a plan', + displayName: 'Present plan', + description: 'Review the proposed implementation steps.', + decisionReason: 'Plan mode requires approval.', + blockedPath: '/repo/PLAN.md', + matchedAskRule: { source: 'project', toolName: 'ExitPlanMode', ruleContent: 'ask' }, detail: null, options: [{ id: 'a', label: 'Yes' }], resolution: RESOLUTION diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts index 5efd47e35c7..3a5f37e9f09 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -132,6 +132,12 @@ const Resolution = z.object({ resolvedAt: z.number().nullable() }) +const ApprovalMatchedAskRule = z.object({ + source: z.string(), + toolName: z.string(), + ruleContent: z.string().optional() +}) + const MessageBody = z.object({ kind: z.literal('message'), role: z.string().min(1), @@ -154,6 +160,11 @@ export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [ z.object({ kind: z.literal('approval'), title: z.string(), + displayName: z.string().optional(), + description: z.string().optional(), + decisionReason: z.string().optional(), + blockedPath: z.string().optional(), + matchedAskRule: ApprovalMatchedAskRule.optional(), detail: z.string().nullable(), options: z.array(PromptOption), resolution: Resolution diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index 6978b92b078..a8e11c8665a 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -140,9 +140,20 @@ export type AgentJournalQuestion = { freeTextQuestionId?: string } +export type AgentJournalApprovalMatchedAskRule = { + source: string + toolName: string + ruleContent?: string +} + export type AgentJournalApprovalItem = { kind: 'approval' title: string + displayName?: string + description?: string + decisionReason?: string + blockedPath?: string + matchedAskRule?: AgentJournalApprovalMatchedAskRule detail: string | null options: AgentJournalPromptOption[] resolution: AgentJournalResolution