fix(cli): Polish shell autocomplete rendering to be a little more shell native feeling. (#20931)

This commit is contained in:
Jacob Richman
2026-03-03 22:52:56 -08:00
committed by GitHub
parent 1017b78157
commit 12957ea16a
5 changed files with 327 additions and 49 deletions
@@ -279,6 +279,9 @@ describe('InputPrompt', () => {
}, },
getCompletedText: vi.fn().mockReturnValue(null), getCompletedText: vi.fn().mockReturnValue(null),
completionMode: CompletionMode.IDLE, completionMode: CompletionMode.IDLE,
forceShowShellSuggestions: false,
setForceShowShellSuggestions: vi.fn(),
isShellSuggestionsVisible: true,
}; };
mockedUseCommandCompletion.mockReturnValue(mockCommandCompletion); mockedUseCommandCompletion.mockReturnValue(mockCommandCompletion);
+53 -23
View File
@@ -301,6 +301,27 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const resetCommandSearchCompletionState = const resetCommandSearchCompletionState =
commandSearchCompletion.resetCompletionState; commandSearchCompletion.resetCompletionState;
const getActiveCompletion = useCallback(() => {
if (commandSearchActive) return commandSearchCompletion;
if (reverseSearchActive) return reverseSearchCompletion;
return completion;
}, [
commandSearchActive,
commandSearchCompletion,
reverseSearchActive,
reverseSearchCompletion,
completion,
]);
const activeCompletion = getActiveCompletion();
const shouldShowSuggestions = activeCompletion.showSuggestions;
const {
forceShowShellSuggestions,
setForceShowShellSuggestions,
isShellSuggestionsVisible,
} = completion;
const showCursor = focus && isShellFocused && !isEmbeddedShellFocused; const showCursor = focus && isShellFocused && !isEmbeddedShellFocused;
// Notify parent component about escape prompt state changes // Notify parent component about escape prompt state changes
@@ -363,7 +384,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
userMessages, userMessages,
onSubmit: handleSubmitAndClear, onSubmit: handleSubmitAndClear,
isActive: isActive:
(!completion.showSuggestions || completion.suggestions.length === 1) && (!(completion.showSuggestions && isShellSuggestionsVisible) ||
completion.suggestions.length === 1) &&
!shellModeActive, !shellModeActive,
currentQuery: buffer.text, currentQuery: buffer.text,
currentCursorOffset: buffer.getOffset(), currentCursorOffset: buffer.getOffset(),
@@ -595,9 +617,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
keyMatchers[Command.END](key); keyMatchers[Command.END](key);
const isSuggestionsNav = const isSuggestionsNav =
(completion.showSuggestions || shouldShowSuggestions &&
reverseSearchCompletion.showSuggestions ||
commandSearchCompletion.showSuggestions) &&
(keyMatchers[Command.COMPLETION_UP](key) || (keyMatchers[Command.COMPLETION_UP](key) ||
keyMatchers[Command.COMPLETION_DOWN](key) || keyMatchers[Command.COMPLETION_DOWN](key) ||
keyMatchers[Command.EXPAND_SUGGESTION](key) || keyMatchers[Command.EXPAND_SUGGESTION](key) ||
@@ -612,6 +632,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
isHistoryNav || isCursorMovement || keyMatchers[Command.ESCAPE](key), isHistoryNav || isCursorMovement || keyMatchers[Command.ESCAPE](key),
); );
hasUserNavigatedSuggestions.current = false; hasUserNavigatedSuggestions.current = false;
if (key.name !== 'tab') {
setForceShowShellSuggestions(false);
}
} }
// TODO(jacobr): this special case is likely not needed anymore. // TODO(jacobr): this special case is likely not needed anymore.
@@ -641,15 +665,25 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const isPlainTab = const isPlainTab =
key.name === 'tab' && !key.shift && !key.alt && !key.ctrl && !key.cmd; key.name === 'tab' && !key.shift && !key.alt && !key.ctrl && !key.cmd;
const hasTabCompletionInteraction = const hasTabCompletionInteraction =
completion.showSuggestions || (completion.showSuggestions && isShellSuggestionsVisible) ||
Boolean(completion.promptCompletion.text) || Boolean(completion.promptCompletion.text) ||
reverseSearchActive || reverseSearchActive ||
commandSearchActive; commandSearchActive;
if (isPlainTab && shellModeActive) { if (isPlainTab && shellModeActive) {
resetPlainTabPress(); resetPlainTabPress();
if (!completion.showSuggestions) { if (!shouldShowSuggestions) {
setSuppressCompletion(false); setSuppressCompletion(false);
if (completion.promptCompletion.text) {
completion.promptCompletion.accept();
return true;
} else if (
completion.suggestions.length > 0 &&
!forceShowShellSuggestions
) {
setForceShowShellSuggestions(true);
return true;
}
} }
} else if (isPlainTab) { } else if (isPlainTab) {
if (!hasTabCompletionInteraction) { if (!hasTabCompletionInteraction) {
@@ -752,7 +786,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if ( if (
key.sequence === '!' && key.sequence === '!' &&
buffer.text === '' && buffer.text === '' &&
!completion.showSuggestions !(completion.showSuggestions && isShellSuggestionsVisible)
) { ) {
setShellModeActive(!shellModeActive); setShellModeActive(!shellModeActive);
buffer.setText(''); // Clear the '!' from input buffer.setText(''); // Clear the '!' from input
@@ -791,15 +825,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return true; return true;
} }
if (shellModeActive) { if (completion.showSuggestions && isShellSuggestionsVisible) {
setShellModeActive(false); completion.resetCompletionState();
setExpandedSuggestionIndex(-1);
resetEscapeState(); resetEscapeState();
return true; return true;
} }
if (completion.showSuggestions) { if (shellModeActive) {
completion.resetCompletionState(); setShellModeActive(false);
setExpandedSuggestionIndex(-1);
resetEscapeState(); resetEscapeState();
return true; return true;
} }
@@ -895,7 +929,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
completion.isPerfectMatch && completion.isPerfectMatch &&
keyMatchers[Command.SUBMIT](key) && keyMatchers[Command.SUBMIT](key) &&
recentUnsafePasteTime === null && recentUnsafePasteTime === null &&
(!completion.showSuggestions || (!(completion.showSuggestions && isShellSuggestionsVisible) ||
(completion.activeSuggestionIndex <= 0 && (completion.activeSuggestionIndex <= 0 &&
!hasUserNavigatedSuggestions.current)) !hasUserNavigatedSuggestions.current))
) { ) {
@@ -909,7 +943,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return true; return true;
} }
if (completion.showSuggestions) { if (completion.showSuggestions && isShellSuggestionsVisible) {
if (completion.suggestions.length > 1) { if (completion.suggestions.length > 1) {
if (keyMatchers[Command.COMPLETION_UP](key)) { if (keyMatchers[Command.COMPLETION_UP](key)) {
completion.navigateUp(); completion.navigateUp();
@@ -1007,7 +1041,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if ( if (
key.name === 'tab' && key.name === 'tab' &&
!key.shift && !key.shift &&
!completion.showSuggestions && !(completion.showSuggestions && isShellSuggestionsVisible) &&
completion.promptCompletion.text completion.promptCompletion.text
) { ) {
completion.promptCompletion.accept(); completion.promptCompletion.accept();
@@ -1190,6 +1224,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
focus, focus,
buffer, buffer,
completion, completion,
setForceShowShellSuggestions,
shellModeActive, shellModeActive,
setShellModeActive, setShellModeActive,
onClearScreen, onClearScreen,
@@ -1221,6 +1256,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
registerPlainTabPress, registerPlainTabPress,
resetPlainTabPress, resetPlainTabPress,
toggleCleanUiDetailsVisible, toggleCleanUiDetailsVisible,
shouldShowSuggestions,
isShellSuggestionsVisible,
forceShowShellSuggestions,
], ],
); );
@@ -1346,14 +1384,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
]); ]);
const { inlineGhost, additionalLines } = getGhostTextLines(); const { inlineGhost, additionalLines } = getGhostTextLines();
const getActiveCompletion = () => {
if (commandSearchActive) return commandSearchCompletion;
if (reverseSearchActive) return reverseSearchCompletion;
return completion;
};
const activeCompletion = getActiveCompletion();
const shouldShowSuggestions = activeCompletion.showSuggestions;
const useBackgroundColor = config.getUseBackgroundColor(); const useBackgroundColor = config.getUseBackgroundColor();
const isLowColor = isLowColorDepth(); const isLowColor = isLowColorDepth();
@@ -46,6 +46,7 @@ vi.mock('./useShellCompletion', () => ({
completionStart: 0, completionStart: 0,
completionEnd: 0, completionEnd: 0,
query: '', query: '',
activeStart: 0,
})), })),
})); }));
@@ -57,7 +58,12 @@ const setupMocks = ({
isLoading = false, isLoading = false,
isPerfectMatch = false, isPerfectMatch = false,
slashCompletionRange = { completionStart: 0, completionEnd: 0 }, slashCompletionRange = { completionStart: 0, completionEnd: 0 },
shellCompletionRange = { completionStart: 0, completionEnd: 0, query: '' }, shellCompletionRange = {
completionStart: 0,
completionEnd: 0,
query: '',
activeStart: 0,
},
}: { }: {
atSuggestions?: Suggestion[]; atSuggestions?: Suggestion[];
slashSuggestions?: Suggestion[]; slashSuggestions?: Suggestion[];
@@ -69,6 +75,7 @@ const setupMocks = ({
completionStart: number; completionStart: number;
completionEnd: number; completionEnd: number;
query: string; query: string;
activeStart?: number;
}; };
}) => { }) => {
// Mock for @-completions // Mock for @-completions
@@ -116,7 +123,10 @@ const setupMocks = ({
setSuggestions(shellSuggestions); setSuggestions(shellSuggestions);
} }
}, [enabled, setSuggestions, setIsLoadingSuggestions]); }, [enabled, setSuggestions, setIsLoadingSuggestions]);
return shellCompletionRange; return {
...shellCompletionRange,
activeStart: shellCompletionRange.activeStart ?? 0,
};
}, },
); );
}; };
@@ -139,38 +149,57 @@ describe('useCommandCompletion', () => {
}); });
} }
let hookResult: ReturnType<typeof useCommandCompletion> & {
textBuffer: ReturnType<typeof useTextBuffer>;
};
function TestComponent({
initialText,
cursorOffset,
shellModeActive,
active,
}: {
initialText: string;
cursorOffset?: number;
shellModeActive: boolean;
active: boolean;
}) {
const textBuffer = useTextBufferForTest(initialText, cursorOffset);
const completion = useCommandCompletion({
buffer: textBuffer,
cwd: testRootDir,
slashCommands: [],
commandContext: mockCommandContext,
reverseSearchActive: false,
shellModeActive,
config: mockConfig,
active,
});
hookResult = { ...completion, textBuffer };
return null;
}
const renderCommandCompletionHook = ( const renderCommandCompletionHook = (
initialText: string, initialText: string,
cursorOffset?: number, cursorOffset?: number,
shellModeActive = false, shellModeActive = false,
active = true, active = true,
) => { ) => {
let hookResult: ReturnType<typeof useCommandCompletion> & { const renderResult = renderWithProviders(
textBuffer: ReturnType<typeof useTextBuffer>; <TestComponent
}; initialText={initialText}
cursorOffset={cursorOffset}
function TestComponent() { shellModeActive={shellModeActive}
const textBuffer = useTextBufferForTest(initialText, cursorOffset); active={active}
const completion = useCommandCompletion({ />,
buffer: textBuffer, );
cwd: testRootDir,
slashCommands: [],
commandContext: mockCommandContext,
reverseSearchActive: false,
shellModeActive,
config: mockConfig,
active,
});
hookResult = { ...completion, textBuffer };
return null;
}
renderWithProviders(<TestComponent />);
return { return {
result: { result: {
get current() { get current() {
return hookResult; return hookResult;
}, },
}, },
...renderResult,
}; };
}; };
@@ -524,6 +553,129 @@ describe('useCommandCompletion', () => {
expect(result.current.textBuffer.text).toBe('@src\\components\\'); expect(result.current.textBuffer.text).toBe('@src\\components\\');
}); });
it('should show ghost text for a single shell completion', async () => {
const text = 'l';
setupMocks({
shellSuggestions: [{ label: 'ls', value: 'ls' }],
shellCompletionRange: {
completionStart: 0,
completionEnd: 1,
query: 'l',
activeStart: 0,
},
});
const { result } = renderCommandCompletionHook(
text,
text.length,
true, // shellModeActive
);
await waitFor(() => {
expect(result.current.isLoadingSuggestions).toBe(false);
});
// Should show "ls " as ghost text (including trailing space)
expect(result.current.promptCompletion.text).toBe('ls ');
});
it('should not show ghost text if there are multiple completions', async () => {
const text = 'l';
setupMocks({
shellSuggestions: [
{ label: 'ls', value: 'ls' },
{ label: 'ln', value: 'ln' },
],
shellCompletionRange: {
completionStart: 0,
completionEnd: 1,
query: 'l',
activeStart: 0,
},
});
const { result } = renderCommandCompletionHook(
text,
text.length,
true, // shellModeActive
);
await waitFor(() => {
expect(result.current.isLoadingSuggestions).toBe(false);
});
expect(result.current.promptCompletion.text).toBe('');
});
it('should not show ghost text if the typed text extends past the completion', async () => {
// "ls " is already typed.
const text = 'ls ';
const cursorOffset = text.length;
const { result } = renderCommandCompletionHook(
text,
cursorOffset,
true, // shellModeActive
);
await waitFor(() => {
expect(result.current.isLoadingSuggestions).toBe(false);
});
expect(result.current.promptCompletion.text).toBe('');
});
it('should clear ghost text after user types a space when exact match ghost text was showing', async () => {
const textWithoutSpace = 'ls';
setupMocks({
shellSuggestions: [{ label: 'ls', value: 'ls' }],
shellCompletionRange: {
completionStart: 0,
completionEnd: 2,
query: 'ls',
activeStart: 0,
},
});
const { result } = renderCommandCompletionHook(
textWithoutSpace,
textWithoutSpace.length,
true, // shellModeActive
);
await waitFor(() => {
expect(result.current.isLoadingSuggestions).toBe(false);
});
// Initially no ghost text because "ls" perfectly matches "ls"
expect(result.current.promptCompletion.text).toBe('');
// Now simulate typing a space.
// In the real app, shellCompletionRange.completionStart would change immediately to 3,
// but suggestions (and activeStart) would still be from the previous token for a few ms.
setupMocks({
shellSuggestions: [{ label: 'ls', value: 'ls' }], // Stale suggestions
shellCompletionRange: {
completionStart: 3, // New token position
completionEnd: 3,
query: '',
activeStart: 0, // Stale active start
},
});
act(() => {
result.current.textBuffer.setText('ls ', 'end');
});
await waitFor(() => {
expect(result.current.isLoadingSuggestions).toBe(false);
});
// Should STILL be empty because completionStart (3) !== activeStart (0)
expect(result.current.promptCompletion.text).toBe('');
});
}); });
describe('prompt completion filtering', () => { describe('prompt completion filtering', () => {
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { useCallback, useMemo, useEffect } from 'react'; import { useCallback, useMemo, useEffect, useState } from 'react';
import type { Suggestion } from '../components/SuggestionsDisplay.js'; import type { Suggestion } from '../components/SuggestionsDisplay.js';
import type { CommandContext, SlashCommand } from '../commands/types.js'; import type { CommandContext, SlashCommand } from '../commands/types.js';
import type { TextBuffer } from '../components/shared/text-buffer.js'; import type { TextBuffer } from '../components/shared/text-buffer.js';
@@ -37,6 +37,9 @@ export interface UseCommandCompletionReturn {
showSuggestions: boolean; showSuggestions: boolean;
isLoadingSuggestions: boolean; isLoadingSuggestions: boolean;
isPerfectMatch: boolean; isPerfectMatch: boolean;
forceShowShellSuggestions: boolean;
setForceShowShellSuggestions: (value: boolean) => void;
isShellSuggestionsVisible: boolean;
setActiveSuggestionIndex: React.Dispatch<React.SetStateAction<number>>; setActiveSuggestionIndex: React.Dispatch<React.SetStateAction<number>>;
resetCompletionState: () => void; resetCompletionState: () => void;
navigateUp: () => void; navigateUp: () => void;
@@ -80,6 +83,9 @@ export function useCommandCompletion({
config, config,
active, active,
}: UseCommandCompletionOptions): UseCommandCompletionReturn { }: UseCommandCompletionOptions): UseCommandCompletionReturn {
const [forceShowShellSuggestions, setForceShowShellSuggestions] =
useState(false);
const { const {
suggestions, suggestions,
activeSuggestionIndex, activeSuggestionIndex,
@@ -93,11 +99,16 @@ export function useCommandCompletion({
setIsPerfectMatch, setIsPerfectMatch,
setVisibleStartIndex, setVisibleStartIndex,
resetCompletionState, resetCompletionState: baseResetCompletionState,
navigateUp, navigateUp,
navigateDown, navigateDown,
} = useCompletion(); } = useCompletion();
const resetCompletionState = useCallback(() => {
baseResetCompletionState();
setForceShowShellSuggestions(false);
}, [baseResetCompletionState]);
const cursorRow = buffer.cursor[0]; const cursorRow = buffer.cursor[0];
const cursorCol = buffer.cursor[1]; const cursorCol = buffer.cursor[1];
@@ -231,10 +242,73 @@ export function useCommandCompletion({
? shellCompletionRange.query ? shellCompletionRange.query
: memoQuery; : memoQuery;
const promptCompletion = usePromptCompletion({ const basePromptCompletion = usePromptCompletion({
buffer, buffer,
}); });
const isShellSuggestionsVisible =
completionMode !== CompletionMode.SHELL || forceShowShellSuggestions;
const promptCompletion = useMemo(() => {
if (
completionMode === CompletionMode.SHELL &&
suggestions.length === 1 &&
query != null &&
shellCompletionRange.completionStart === shellCompletionRange.activeStart
) {
const suggestion = suggestions[0];
const textToInsertBase = suggestion.value;
if (
textToInsertBase.startsWith(query) &&
textToInsertBase.length > query.length
) {
const currentLine = buffer.lines[cursorRow] || '';
const start = shellCompletionRange.completionStart;
const end = shellCompletionRange.completionEnd;
let textToInsert = textToInsertBase;
const charAfterCompletion = currentLine[end];
if (
charAfterCompletion !== ' ' &&
!textToInsert.endsWith('/') &&
!textToInsert.endsWith('\\')
) {
textToInsert += ' ';
}
const newText =
currentLine.substring(0, start) +
textToInsert +
currentLine.substring(end);
return {
text: newText,
isActive: true,
isLoading: false,
accept: () => {
buffer.replaceRangeByOffset(
logicalPosToOffset(buffer.lines, cursorRow, start),
logicalPosToOffset(buffer.lines, cursorRow, end),
textToInsert,
);
},
clear: () => {},
markSelected: () => {},
};
}
}
return basePromptCompletion;
}, [
completionMode,
suggestions,
query,
basePromptCompletion,
buffer,
cursorRow,
shellCompletionRange,
]);
useEffect(() => { useEffect(() => {
setActiveSuggestionIndex(suggestions.length > 0 ? 0 : -1); setActiveSuggestionIndex(suggestions.length > 0 ? 0 : -1);
setVisibleStartIndex(0); setVisibleStartIndex(0);
@@ -271,6 +345,7 @@ export function useCommandCompletion({
active && active &&
completionMode !== CompletionMode.IDLE && completionMode !== CompletionMode.IDLE &&
!reverseSearchActive && !reverseSearchActive &&
isShellSuggestionsVisible &&
(isLoadingSuggestions || suggestions.length > 0); (isLoadingSuggestions || suggestions.length > 0);
/** /**
@@ -395,6 +470,9 @@ export function useCommandCompletion({
showSuggestions, showSuggestions,
isLoadingSuggestions, isLoadingSuggestions,
isPerfectMatch, isPerfectMatch,
forceShowShellSuggestions,
setForceShowShellSuggestions,
isShellSuggestionsVisible,
setActiveSuggestionIndex, setActiveSuggestionIndex,
resetCompletionState, resetCompletionState,
navigateUp, navigateUp,
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { useEffect, useRef, useCallback, useMemo } from 'react'; import { useEffect, useRef, useCallback, useMemo, useState } from 'react';
import * as fs from 'node:fs/promises'; import * as fs from 'node:fs/promises';
import * as path from 'node:path'; import * as path from 'node:path';
import * as os from 'node:os'; import * as os from 'node:os';
@@ -435,6 +435,7 @@ export interface UseShellCompletionReturn {
completionStart: number; completionStart: number;
completionEnd: number; completionEnd: number;
query: string; query: string;
activeStart: number;
} }
const EMPTY_TOKENS: string[] = []; const EMPTY_TOKENS: string[] = [];
@@ -451,6 +452,7 @@ export function useShellCompletion({
const pathEnvRef = useRef<string>(process.env['PATH'] ?? ''); const pathEnvRef = useRef<string>(process.env['PATH'] ?? '');
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
const debounceRef = useRef<NodeJS.Timeout | null>(null); const debounceRef = useRef<NodeJS.Timeout | null>(null);
const [activeStart, setActiveStart] = useState<number>(-1);
const tokenInfo = useMemo( const tokenInfo = useMemo(
() => (enabled ? getTokenAtCursor(line, cursorCol) : null), () => (enabled ? getTokenAtCursor(line, cursorCol) : null),
@@ -467,6 +469,14 @@ export function useShellCompletion({
commandToken = '', commandToken = '',
} = tokenInfo || {}; } = tokenInfo || {};
// Immediately clear suggestions if the token range has changed.
// This avoids a frame of flickering with stale suggestions (e.g. "ls ls")
// when moving to a new token.
if (enabled && activeStart !== -1 && completionStart !== activeStart) {
setSuggestions([]);
setActiveStart(-1);
}
// Invalidate PATH cache when $PATH changes // Invalidate PATH cache when $PATH changes
useEffect(() => { useEffect(() => {
const currentPath = process.env['PATH'] ?? ''; const currentPath = process.env['PATH'] ?? '';
@@ -558,6 +568,7 @@ export function useShellCompletion({
if (signal.aborted) return; if (signal.aborted) return;
setSuggestions(results); setSuggestions(results);
setActiveStart(completionStart);
} catch (error) { } catch (error) {
if ( if (
!( !(
@@ -571,6 +582,7 @@ export function useShellCompletion({
} }
if (!signal.aborted) { if (!signal.aborted) {
setSuggestions([]); setSuggestions([]);
setActiveStart(completionStart);
} }
} finally { } finally {
if (!signal.aborted) { if (!signal.aborted) {
@@ -586,6 +598,7 @@ export function useShellCompletion({
cursorIndex, cursorIndex,
commandToken, commandToken,
cwd, cwd,
completionStart,
setSuggestions, setSuggestions,
setIsLoadingSuggestions, setIsLoadingSuggestions,
]); ]);
@@ -594,6 +607,7 @@ export function useShellCompletion({
if (!enabled) { if (!enabled) {
abortRef.current?.abort(); abortRef.current?.abort();
setSuggestions([]); setSuggestions([]);
setActiveStart(-1);
setIsLoadingSuggestions(false); setIsLoadingSuggestions(false);
} }
}, [enabled, setSuggestions, setIsLoadingSuggestions]); }, [enabled, setSuggestions, setIsLoadingSuggestions]);
@@ -633,5 +647,6 @@ export function useShellCompletion({
completionStart, completionStart,
completionEnd, completionEnd,
query, query,
activeStart,
}; };
} }