fix(ui): make tool confirmations take up entire terminal height (#22366)

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
Dev Randalpura
2026-03-23 15:42:30 -04:00
committed by GitHub
parent b2d6dc4e32
commit 139ef0d5bd
21 changed files with 2393 additions and 227 deletions
@@ -5,8 +5,8 @@
*/
import type React from 'react';
import { useEffect, useMemo, useCallback, useState } from 'react';
import { Box, Text } from 'ink';
import { useEffect, useMemo, useCallback, useState, useRef } from 'react';
import { Box, Text, ResizeObserver, type DOMElement } from 'ink';
import { DiffRenderer } from './DiffRenderer.js';
import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
import {
@@ -85,6 +85,64 @@ export const ToolConfirmationMessage: React.FC<
? mcpDetailsExpansionState.expanded
: false;
const [measuredSecurityWarningsHeight, setMeasuredSecurityWarningsHeight] =
useState(0);
const observerRef = useRef<ResizeObserver | null>(null);
const deceptiveUrlWarnings = useMemo(() => {
const urls: string[] = [];
if (confirmationDetails.type === 'info' && confirmationDetails.urls) {
urls.push(...confirmationDetails.urls);
} else if (confirmationDetails.type === 'exec') {
const commands =
confirmationDetails.commands && confirmationDetails.commands.length > 0
? confirmationDetails.commands
: [confirmationDetails.command];
for (const cmd of commands) {
const matches = cmd.match(/https?:\/\/[^\s"'`<>;&|()]+/g);
if (matches) urls.push(...matches);
}
}
const uniqueUrls = Array.from(new Set(urls));
return uniqueUrls
.map(getDeceptiveUrlDetails)
.filter((d): d is DeceptiveUrlDetails => d !== null);
}, [confirmationDetails]);
const deceptiveUrlWarningText = useMemo(() => {
if (deceptiveUrlWarnings.length === 0) return null;
return `**Warning:** Deceptive URL(s) detected:\n\n${deceptiveUrlWarnings
.map(
(w) =>
` **Original:** ${w.originalUrl}\n **Actual Host (Punycode):** ${w.punycodeUrl}`,
)
.join('\n\n')}`;
}, [deceptiveUrlWarnings]);
const onSecurityWarningsRefChange = useCallback((node: DOMElement | null) => {
if (observerRef.current) {
observerRef.current.disconnect();
observerRef.current = null;
}
if (node) {
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
const newHeight = Math.round(entry.contentRect.height);
setMeasuredSecurityWarningsHeight((prev) =>
newHeight !== prev ? newHeight : prev,
);
}
});
observer.observe(node);
observerRef.current = observer;
} else {
setMeasuredSecurityWarningsHeight((prev) => (prev !== 0 ? 0 : prev));
}
}, []);
const settings = useSettings();
const allowPermanentApproval =
settings.merged.security.enablePermanentToolApproval &&
@@ -216,37 +274,6 @@ export const ToolConfirmationMessage: React.FC<
[handleConfirm],
);
const deceptiveUrlWarnings = useMemo(() => {
const urls: string[] = [];
if (confirmationDetails.type === 'info' && confirmationDetails.urls) {
urls.push(...confirmationDetails.urls);
} else if (confirmationDetails.type === 'exec') {
const commands =
confirmationDetails.commands && confirmationDetails.commands.length > 0
? confirmationDetails.commands
: [confirmationDetails.command];
for (const cmd of commands) {
const matches = cmd.match(/https?:\/\/[^\s"'`<>;&|()]+/g);
if (matches) urls.push(...matches);
}
}
const uniqueUrls = Array.from(new Set(urls));
return uniqueUrls
.map(getDeceptiveUrlDetails)
.filter((d): d is DeceptiveUrlDetails => d !== null);
}, [confirmationDetails]);
const deceptiveUrlWarningText = useMemo(() => {
if (deceptiveUrlWarnings.length === 0) return null;
return `**Warning:** Deceptive URL(s) detected:\n\n${deceptiveUrlWarnings
.map(
(w) =>
` **Original:** ${w.originalUrl}\n **Actual Host (Punycode):** ${w.punycodeUrl}`,
)
.join('\n\n')}`;
}, [deceptiveUrlWarnings]);
const getOptions = useCallback(() => {
const options: Array<RadioSelectItem<ToolConfirmationOutcome>> = [];
@@ -389,23 +416,36 @@ export const ToolConfirmationMessage: React.FC<
// Calculate the vertical space (in lines) consumed by UI elements
// surrounding the main body content.
const PADDING_OUTER_Y = 2; // Main container has `padding={1}` (top & bottom).
const MARGIN_BODY_BOTTOM = 1; // margin on the body container.
const PADDING_OUTER_Y = 1; // Main container has `paddingBottom={1}`.
const HEIGHT_QUESTION = 1; // The question text is one line.
const MARGIN_QUESTION_BOTTOM = 1; // Margin on the question container.
const SECURITY_WARNING_BOTTOM_MARGIN = 1; // Margin on the securityWarnings container.
const SHOW_MORE_LINES_HEIGHT = 1; // The "Press Ctrl+O to show more lines" hint.
const optionsCount = getOptions().length;
// The measured height includes the margin inside WarningMessage (1 line).
// We also add 1 line for the marginBottom on the securityWarnings container.
const securityWarningsHeight = deceptiveUrlWarningText
? measuredSecurityWarningsHeight + SECURITY_WARNING_BOTTOM_MARGIN
: 0;
const surroundingElementsHeight =
PADDING_OUTER_Y +
MARGIN_BODY_BOTTOM +
HEIGHT_QUESTION +
MARGIN_QUESTION_BOTTOM +
SHOW_MORE_LINES_HEIGHT +
optionsCount +
1; // Reserve one line for 'ShowMoreLines' hint
securityWarningsHeight;
return Math.max(availableTerminalHeight - surroundingElementsHeight, 1);
}, [availableTerminalHeight, getOptions, handlesOwnUI]);
}, [
availableTerminalHeight,
handlesOwnUI,
getOptions,
measuredSecurityWarningsHeight,
deceptiveUrlWarningText,
]);
const { question, bodyContent, options, securityWarnings, initialIndex } =
useMemo<{
@@ -547,10 +587,6 @@ export const ToolConfirmationMessage: React.FC<
let bodyContentHeight = availableBodyContentHeight();
let warnings: React.ReactNode = null;
if (bodyContentHeight !== undefined) {
bodyContentHeight -= 2; // Account for padding;
}
if (containsRedirection) {
// Calculate lines needed for Note and Tip
const safeWidth = Math.max(terminalWidth, 1);
@@ -759,7 +795,11 @@ export const ToolConfirmationMessage: React.FC<
</Box>
{securityWarnings && (
<Box flexShrink={0} marginBottom={1}>
<Box
flexShrink={0}
marginBottom={1}
ref={onSecurityWarningsRefChange}
>
{securityWarnings}
</Box>
)}