mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-02 13:11:03 -07:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 85581fd3e1 | |||
| 05bf04c852 | |||
| 800aea6cfb |
Generated
+1
-1
@@ -17551,7 +17551,7 @@
|
||||
"fzf": "^0.5.2",
|
||||
"glob": "^12.0.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"ink": "npm:@jrichman/ink@6.5.0",
|
||||
"ink": "npm:@jrichman/ink@6.6.2",
|
||||
"ink-gradient": "^3.0.0",
|
||||
"ink-spinner": "^5.0.0",
|
||||
"latest-version": "^9.0.0",
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
"fzf": "^0.5.2",
|
||||
"glob": "^12.0.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"ink": "npm:@jrichman/ink@6.5.0",
|
||||
"ink": "npm:@jrichman/ink@6.6.2",
|
||||
"ink-gradient": "^3.0.0",
|
||||
"ink-spinner": "^5.0.0",
|
||||
"latest-version": "^9.0.0",
|
||||
|
||||
@@ -705,6 +705,10 @@ export class AppRig {
|
||||
return stripAnsi(this.renderResult.stdout.lastFrame() || '');
|
||||
}
|
||||
|
||||
generateSvg(): string {
|
||||
return this.renderResult?.generateSvg() ?? '';
|
||||
}
|
||||
|
||||
async waitForOutput(pattern: string | RegExp, timeout = 30000) {
|
||||
await this.waitUntil(
|
||||
() => {
|
||||
@@ -720,6 +724,58 @@ export class AppRig {
|
||||
);
|
||||
}
|
||||
|
||||
async waitForComponent(componentName: string, timeout = 30000) {
|
||||
await this.waitUntil(
|
||||
() => {
|
||||
const rootNode = this.renderResult?.rootNode;
|
||||
if (!rootNode) return false;
|
||||
|
||||
type TestableDOMNode = import('ink').DOMNode & {
|
||||
internal_componentName?: string;
|
||||
internal_testId?: string;
|
||||
attributes?: {
|
||||
internal_componentName?: string;
|
||||
internal_testId?: string;
|
||||
};
|
||||
style?: {
|
||||
internal_componentName?: string;
|
||||
internal_testId?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const find = (node: TestableDOMNode): boolean => {
|
||||
if (
|
||||
node.internal_componentName === componentName ||
|
||||
node.internal_testId === componentName ||
|
||||
node.attributes?.internal_testId === componentName ||
|
||||
node.attributes?.internal_componentName === componentName ||
|
||||
node.style?.internal_testId === componentName ||
|
||||
node.style?.internal_componentName === componentName
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if ('childNodes' in node && node.childNodes) {
|
||||
for (const child of node.childNodes) {
|
||||
if (
|
||||
child.nodeName !== '#text' &&
|
||||
find(child as TestableDOMNode)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
return find(rootNode as TestableDOMNode);
|
||||
},
|
||||
{
|
||||
timeout,
|
||||
message: `Timed out waiting for component: ${componentName}\nLast frame:\n${this.lastFrame}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async waitForIdle(timeout = 20000) {
|
||||
await this.waitForOutput('Type your message', timeout);
|
||||
}
|
||||
|
||||
@@ -10,11 +10,87 @@ import { expect, type Assertion } from 'vitest';
|
||||
import path from 'node:path';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
import type { TextBuffer } from '../ui/components/shared/text-buffer.js';
|
||||
import { type DOMElement as _DOMElement, type DOMNode } from 'ink';
|
||||
|
||||
export interface CustomMatchers<R = unknown> {
|
||||
toMatchSvgSnapshot(options?: {
|
||||
allowEmpty?: boolean;
|
||||
name?: string;
|
||||
}): Promise<R>;
|
||||
toContainComponent(componentName: string): R;
|
||||
toHaveOnlyValidCharacters(): R;
|
||||
}
|
||||
|
||||
// RegExp to detect invalid characters: backspace, and ANSI escape codes
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const invalidCharsRegex = /[\b\x1b]/;
|
||||
|
||||
type TestableDOMNode = DOMNode & {
|
||||
internal_componentName?: string;
|
||||
internal_testId?: string;
|
||||
attributes?: {
|
||||
internal_componentName?: string;
|
||||
internal_testId?: string;
|
||||
};
|
||||
style?: {
|
||||
internal_componentName?: string;
|
||||
internal_testId?: string;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Traverses the Ink tree to find a node matching a predicate.
|
||||
*/
|
||||
function findInTree(
|
||||
node: TestableDOMNode,
|
||||
predicate: (node: TestableDOMNode) => boolean,
|
||||
): TestableDOMNode | undefined {
|
||||
if (predicate(node)) return node;
|
||||
if ('childNodes' in node && node.childNodes) {
|
||||
for (const child of node.childNodes) {
|
||||
const found = findInTree(child as TestableDOMNode, predicate);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the Ink DOM tree contains a specific component by name or testId.
|
||||
*/
|
||||
export function toContainComponent(
|
||||
this: Assertion,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
received: any,
|
||||
componentName: string,
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { isNot } = this as any;
|
||||
|
||||
const rootNode = received.rootNode || received.renderResult?.rootNode;
|
||||
|
||||
// Check logical tree presence (Automatic via Ink Root)
|
||||
const pass = rootNode
|
||||
? !!findInTree(rootNode as TestableDOMNode, (node) => {
|
||||
if (node.nodeName === '#text') return false;
|
||||
const match =
|
||||
node.internal_componentName === componentName ||
|
||||
node.internal_testId === componentName ||
|
||||
node.attributes?.internal_componentName === componentName ||
|
||||
node.attributes?.internal_testId === componentName ||
|
||||
node.style?.internal_componentName === componentName ||
|
||||
node.style?.internal_testId === componentName;
|
||||
return match;
|
||||
})
|
||||
: false;
|
||||
|
||||
return {
|
||||
pass,
|
||||
message: () =>
|
||||
`Expected component "${componentName}" ${isNot ? 'NOT ' : ''}to be present in the Ink tree.`,
|
||||
};
|
||||
}
|
||||
|
||||
const callCountByTest = new Map<string, number>();
|
||||
|
||||
export async function toMatchSvgSnapshot(
|
||||
@@ -37,11 +113,17 @@ export async function toMatchSvgSnapshot(
|
||||
|
||||
let textContent: string;
|
||||
if (renderInstance.lastFrameRaw) {
|
||||
textContent = renderInstance.lastFrameRaw({
|
||||
allowEmpty: options?.allowEmpty,
|
||||
});
|
||||
textContent =
|
||||
typeof renderInstance.lastFrameRaw === 'function'
|
||||
? renderInstance.lastFrameRaw({
|
||||
allowEmpty: options?.allowEmpty,
|
||||
})
|
||||
: renderInstance.lastFrameRaw;
|
||||
} else if (renderInstance.lastFrame) {
|
||||
textContent = renderInstance.lastFrame({ allowEmpty: options?.allowEmpty });
|
||||
textContent =
|
||||
typeof renderInstance.lastFrame === 'function'
|
||||
? renderInstance.lastFrame({ allowEmpty: options?.allowEmpty })
|
||||
: renderInstance.lastFrame;
|
||||
} else {
|
||||
throw new Error(
|
||||
'toMatchSvgSnapshot requires a renderInstance with either lastFrameRaw or lastFrame',
|
||||
@@ -101,7 +183,7 @@ function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) {
|
||||
pass,
|
||||
message: () =>
|
||||
`Expected buffer ${isNot ? 'not ' : ''}to have only valid characters, but found invalid characters in lines:\n${invalidLines
|
||||
.map((l) => ` [${l.line}]: "${l.content}"`) /* This line was changed */
|
||||
.map((l) => ` [${l.line}]: "${l.content}"`)
|
||||
.join('\n')}`,
|
||||
actual: buffer.lines,
|
||||
expected: 'Lines with no line breaks, backspaces, or escape codes.',
|
||||
@@ -111,6 +193,7 @@ function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) {
|
||||
expect.extend({
|
||||
toHaveOnlyValidCharacters,
|
||||
toMatchSvgSnapshot,
|
||||
toContainComponent,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
|
||||
@@ -127,5 +210,6 @@ declare module 'vitest' {
|
||||
allowEmpty?: boolean;
|
||||
name?: string;
|
||||
}): Promise<void>;
|
||||
toContainComponent(componentName: string): T;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
render as inkRenderDirect,
|
||||
type Instance as InkInstance,
|
||||
type RenderOptions,
|
||||
type DOMElement,
|
||||
} from 'ink';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { Box } from 'ink';
|
||||
@@ -374,6 +375,7 @@ export type RenderInstance = {
|
||||
waitUntilReady: () => Promise<void>;
|
||||
capturedOverflowState: OverflowState | undefined;
|
||||
capturedOverflowActions: OverflowActions | undefined;
|
||||
rootNode: DOMElement;
|
||||
};
|
||||
|
||||
export type RenderWithProvidersInstance = RenderInstance & {
|
||||
@@ -464,8 +466,13 @@ export const render = async (
|
||||
lastFrameRaw: stdout.lastFrameRaw,
|
||||
generateSvg: stdout.generateSvg,
|
||||
terminal: state.terminal,
|
||||
get rootNode() {
|
||||
return (instance as InkInstance & { rootNode: DOMElement }).rootNode;
|
||||
},
|
||||
waitUntilReady: () => stdout.waitUntilReady(),
|
||||
};
|
||||
capturedOverflowState: undefined,
|
||||
capturedOverflowActions: undefined,
|
||||
} as RenderInstance;
|
||||
};
|
||||
|
||||
export const cleanup = () => {
|
||||
|
||||
@@ -80,7 +80,13 @@ export function SuggestionsDisplay({
|
||||
mode === 'slash' ? Math.min(maxLabelLength, Math.floor(width * 0.5)) : 0;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1} width={width}>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
paddingX={1}
|
||||
width={width}
|
||||
// @ts-expect-error - internal_testId is used for testing component presence
|
||||
internal_testId={SuggestionsDisplay.name}
|
||||
>
|
||||
{scrollOffset > 0 && <Text color={theme.text.primary}>▲</Text>}
|
||||
|
||||
{visibleSuggestions.map((suggestion, index) => {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { AppRig } from '../../test-utils/AppRig.js';
|
||||
import { SuggestionsDisplay } from './SuggestionsDisplay.js';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
describe('SuggestionsDisplay UX Journey', () => {
|
||||
let rig: AppRig;
|
||||
|
||||
beforeEach(async () => {
|
||||
const fakeResponsesPath = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'test-utils',
|
||||
'fixtures',
|
||||
'simple.responses',
|
||||
);
|
||||
rig = new AppRig({ fakeResponsesPath });
|
||||
await rig.initialize();
|
||||
await rig.render();
|
||||
await rig.waitForIdle();
|
||||
// Allow async command loading to settle
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rig.unmount();
|
||||
});
|
||||
|
||||
it('should visually show the suggestions display when / is typed', async () => {
|
||||
// Initially should not have suggestions
|
||||
expect(rig).not.toContainComponent(SuggestionsDisplay.name);
|
||||
|
||||
// Type '/' to trigger suggestions
|
||||
await rig.type('/');
|
||||
|
||||
// Wait for SuggestionsDisplay to appear (Automatic lookup!)
|
||||
await rig.waitForComponent(SuggestionsDisplay.name);
|
||||
|
||||
// Assert that the component is now present in the tree
|
||||
expect(rig).toContainComponent(SuggestionsDisplay.name);
|
||||
|
||||
// Also verify text for sanity
|
||||
expect(rig.lastFrame).toContain('about');
|
||||
|
||||
// Capture the state for manual inspection if needed
|
||||
await expect(rig).toMatchSvgSnapshot({ name: 'suggestions-opened' });
|
||||
});
|
||||
});
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1100" height="581" viewBox="0 0 1100 581">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="1100" height="581" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="9" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="18" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="27" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="54" y="19" fill="#ffffff" textLength="1026" lengthAdjust="spacingAndGlyphs"> Gemini CLI vtest-version </text>
|
||||
<text x="27" y="36" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="36" y="36" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="45" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="18" y="53" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▗</text>
|
||||
<text x="27" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▟</text>
|
||||
<text x="36" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="54" y="53" fill="#ffffff" textLength="1026" lengthAdjust="spacingAndGlyphs"> Authenticated with gemini-api-key /auth </text>
|
||||
<text x="9" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="18" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="0" y="121" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs">Tips for getting started: </text>
|
||||
<text x="0" y="138" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs">1. Create GEMINI.md files to customize your interactions </text>
|
||||
<text x="0" y="155" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs">2. /help for more information </text>
|
||||
<text x="0" y="172" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs">3. Ask coding questions, edit code or run commands </text>
|
||||
<text x="0" y="189" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs">4. Be specific for the best results </text>
|
||||
<text x="0" y="257" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs">──────────────────────────────────────────────────────────── </text>
|
||||
<text x="0" y="274" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs"> Shift+Tab to accept edits </text>
|
||||
<text x="0" y="308" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs">▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ </text>
|
||||
<text x="0" y="325" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs"> > /</text>
|
||||
<rect x="36" y="323" width="9" height="17" fill="#ffffff" />
|
||||
<text x="0" y="342" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs">▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ </text>
|
||||
<text x="0" y="359" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs"> compress Compresses the context by replacing it… </text>
|
||||
<text x="0" y="376" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs"> directory Manage workspace directories </text>
|
||||
<text x="0" y="393" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs"> footer Configure which items appear in the fo… </text>
|
||||
<text x="0" y="410" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs"> quit Exit the cli </text>
|
||||
<text x="0" y="427" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs"> stats Check session stats. Usage: /stats [se… </text>
|
||||
<text x="0" y="444" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs"> tasks Toggle background tasks view </text>
|
||||
<text x="0" y="461" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs"> about Show version info </text>
|
||||
<text x="0" y="478" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs"> agents Manage agents </text>
|
||||
<text x="0" y="495" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs"> ▼ </text>
|
||||
<text x="0" y="512" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs"> (1/38) </text>
|
||||
<text x="0" y="529" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs"> workspace (/directory) sandbox /model </text>
|
||||
<text x="0" y="546" fill="#ffffff" textLength="1080" lengthAdjust="spacingAndGlyphs"> ~ no sandbox test-model </text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.8 KiB |
@@ -0,0 +1,38 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`SuggestionsDisplay UX Journey > should visually show the suggestions display when / is typed 1`] = `
|
||||
"
|
||||
▝▜▄ Gemini CLI vtest-version
|
||||
▝▜▄
|
||||
▗▟▀ Authenticated with gemini-api-key /auth
|
||||
▝▀
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
1. Create GEMINI.md files to customize your interactions
|
||||
2. /help for more information
|
||||
3. Ask coding questions, edit code or run commands
|
||||
4. Be specific for the best results
|
||||
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────
|
||||
Shift+Tab to accept edits
|
||||
|
||||
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
> /
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
compress Compresses the context by replacing it…
|
||||
directory Manage workspace directories
|
||||
footer Configure which items appear in the fo…
|
||||
quit Exit the cli
|
||||
stats Check session stats. Usage: /stats [se…
|
||||
tasks Toggle background tasks view
|
||||
about Show version info
|
||||
agents Manage agents
|
||||
▼
|
||||
(1/38)
|
||||
workspace (/directory) sandbox /model
|
||||
~ no sandbox test-model
|
||||
"
|
||||
`;
|
||||
@@ -9,13 +9,14 @@ import { useCompletion } from './useCompletion.js';
|
||||
import type { TextBuffer } from '../components/shared/text-buffer.js';
|
||||
import type { Suggestion } from '../components/SuggestionsDisplay.js';
|
||||
|
||||
function useDebouncedValue<T>(value: T, delay = 200): T {
|
||||
function useDebouncedValue<T>(value: T, delay = 200, enabled = true): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const handle = setTimeout(() => setDebounced(value), delay);
|
||||
return () => clearTimeout(handle);
|
||||
}, [value, delay]);
|
||||
return debounced;
|
||||
}, [value, delay, enabled]);
|
||||
return enabled ? debounced : value;
|
||||
}
|
||||
|
||||
export interface UseReverseSearchCompletionReturn {
|
||||
@@ -48,7 +49,11 @@ export function useReverseSearchCompletion(
|
||||
setVisibleStartIndex,
|
||||
} = useCompletion();
|
||||
|
||||
const debouncedQuery = useDebouncedValue(buffer.text, 100);
|
||||
const debouncedQuery = useDebouncedValue(
|
||||
buffer.text,
|
||||
100,
|
||||
reverseSearchActive,
|
||||
);
|
||||
|
||||
// incremental search
|
||||
const prevQueryRef = useRef<string>('');
|
||||
|
||||
@@ -72,7 +72,9 @@ beforeEach(() => {
|
||||
|
||||
if (
|
||||
relevantStack.includes('OverflowContext.tsx') ||
|
||||
relevantStack.includes('useTimedMessage.ts')
|
||||
relevantStack.includes('useTimedMessage.ts') ||
|
||||
relevantStack.includes('useSlashCompletion.ts') ||
|
||||
relevantStack.includes('slashCommandProcessor.ts')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user