Compare commits

..

3 Commits

Author SHA1 Message Date
Christian Gunderman a863a34a01 Fix hang at interactive prompt creating with vite. 2026-03-09 10:34:14 -07:00
Jacob Richman d012929a28 Code review comments as a pr (#21209) 2026-03-08 08:36:54 +00:00
Jenna Inouye 97dfbd4598 Docs: Add documentation for notifications (experimental)(macOS) (#21163) 2026-03-08 06:31:24 +00:00
34 changed files with 1436 additions and 617 deletions
+58
View File
@@ -0,0 +1,58 @@
# Notifications (experimental)
Gemini CLI can send system notifications to alert you when a session completes
or when it needs your attention, such as when it's waiting for you to approve a
tool call.
> **Note:** This is a preview feature currently under active development.
> Preview features may be available on the **Preview** channel or may need to be
> enabled under `/settings`.
Notifications are particularly useful when running long-running tasks or using
[Plan Mode](./plan-mode.md), letting you switch to other windows while Gemini
CLI works in the background.
## Requirements
Currently, system notifications are only supported on macOS.
### Terminal support
The CLI uses the OSC 9 terminal escape sequence to trigger system notifications.
This is supported by several modern terminal emulators. If your terminal does
not support OSC 9 notifications, Gemini CLI falls back to a system alert sound
to get your attention.
## Enable notifications
Notifications are disabled by default. You can enable them using the `/settings`
command or by updating your `settings.json` file.
1. Open the settings dialog by typing `/settings` in an interactive session.
2. Navigate to the **General** category.
3. Toggle the **Enable Notifications** setting to **On**.
Alternatively, add the following to your `settings.json`:
```json
{
"general": {
"enableNotifications": true
}
}
```
## Types of notifications
Gemini CLI sends notifications for the following events:
- **Action required:** Triggered when the model is waiting for user input or
tool approval. This helps you know when the CLI has paused and needs you to
intervene.
- **Session complete:** Triggered when a session finishes successfully. This is
useful for tracking the completion of automated tasks.
## Next steps
- Start planning with [Plan Mode](./plan-mode.md).
- Configure your experience with other [settings](./settings.md).
+5
View File
@@ -106,6 +106,11 @@
{ "label": "MCP servers", "slug": "docs/tools/mcp-server" },
{ "label": "Model routing", "slug": "docs/cli/model-routing" },
{ "label": "Model selection", "slug": "docs/cli/model" },
{
"label": "Notifications",
"badge": "🔬",
"slug": "docs/cli/notifications"
},
{ "label": "Plan mode", "badge": "🔬", "slug": "docs/cli/plan-mode" },
{
"label": "Subagents",
+26
View File
@@ -71,4 +71,30 @@ describe('interactive_commands', () => {
).toMatch(/(?:^|\s)(--yes|-y|--template\s+\S+)(?:\s|$|\\|")/);
},
});
/**
* Validates that the agent does not hang when creating a vite app.
*/
evalTest('ALWAYS_PASSES', {
name: 'should not hang when creating a vite app',
prompt: 'create a hello world app with vite',
files: {
//'.npmrc': 'cache=./.npm-cache',
},
assert: async (rig, result) => {
await rig.waitForToolCall('run_shell_command');
const logs = rig.readToolLogs();
const viteCall = logs.find(
(l) =>
l.toolRequest.name === 'run_shell_command' &&
l.toolRequest.args.toLowerCase().includes('vite'),
);
expect(viteCall, 'Agent should have called vite').toBeDefined();
expect(
viteCall?.toolRequest.success,
'Vite tool call should finish successfully without hanging',
).toBe(true);
},
});
});
+1
View File
@@ -606,6 +606,7 @@ const mockUIActions: UIActions = {
handleFolderTrustSelect: vi.fn(),
setIsPolicyUpdateDialogOpen: vi.fn(),
setConstrainHeight: vi.fn(),
onEscapePromptChange: vi.fn(),
refreshStatic: vi.fn(),
handleFinalSubmit: vi.fn(),
handleClearScreen: vi.fn(),
+6 -7
View File
@@ -9,7 +9,6 @@ import type React from 'react';
import { renderWithProviders } from '../test-utils/render.js';
import { Text, useIsScreenReaderEnabled, type DOMElement } from 'ink';
import { App } from './App.js';
import { TransientMessageType } from '../utils/events.js';
import { type UIState } from './contexts/UIStateContext.js';
import { StreamingState } from './types.js';
import { makeFakeConfig, CoreToolCallStatus } from '@google/gemini-cli-core';
@@ -171,16 +170,16 @@ describe('App', () => {
});
it.each([
{ key: 'C' },
{ key: 'D' },
{ key: 'C', stateKey: 'ctrlCPressedOnce' },
{ key: 'D', stateKey: 'ctrlDPressedOnce' },
])(
'should show Ctrl+$key exit prompt when dialogs are visible and warning is present',
async ({ key }) => {
'should show Ctrl+$key exit prompt when dialogs are visible and $stateKey is true',
async ({ key, stateKey }) => {
const uiState = {
...mockUIState,
dialogsVisible: true,
transientMessage: { message: `Press Ctrl+${key} again to exit.`, type: TransientMessageType.Warning },
} as unknown as UIState;
[stateKey]: true,
} as UIState;
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<App />,
+27 -34
View File
@@ -192,18 +192,11 @@ vi.mock('../utils/terminalNotifications.js', () => ({
vi.mock('./hooks/useTerminalTheme.js', () => ({
useTerminalTheme: vi.fn(),
}));
vi.mock('./hooks/useAlternateBuffer.js', () => ({
useAlternateBuffer: vi.fn().mockReturnValue(false),
useLegacyNonAlternateBufferMode: vi.fn().mockReturnValue(false),
isAlternateBufferEnabled: vi.fn().mockReturnValue(false),
}));
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
import { useTerminalTheme } from './hooks/useTerminalTheme.js';
import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js';
import { useFocus } from './hooks/useFocus.js';
import { TransientMessageType } from '../utils/events.js';
// Mock external utilities
vi.mock('../utils/events.js');
@@ -2147,19 +2140,19 @@ describe('AppContainer State Management', () => {
vi.advanceTimersByTime(0);
});
expect(capturedUIState.transientMessage).toBeNull();
expect(capturedUIState.queueErrorMessage).toBeNull();
act(() => {
capturedUIActions.setQueueErrorMessage('Test error');
});
rerender(getAppContainer());
await waitFor(() => expect(capturedUIState.transientMessage?.message).toBe('Test error'));
expect(capturedUIState.queueErrorMessage).toBe('Test error');
act(() => {
vi.advanceTimersByTime(3000);
});
rerender(getAppContainer());
expect(capturedUIState.transientMessage).toBeNull();
expect(capturedUIState.queueErrorMessage).toBeNull();
unmount();
});
@@ -2173,7 +2166,7 @@ describe('AppContainer State Management', () => {
capturedUIActions.setQueueErrorMessage('First error');
});
rerender(getAppContainer());
await waitFor(() => expect(capturedUIState.transientMessage?.message).toBe('First error'));
expect(capturedUIState.queueErrorMessage).toBe('First error');
act(() => {
vi.advanceTimersByTime(1500);
@@ -2183,20 +2176,20 @@ describe('AppContainer State Management', () => {
capturedUIActions.setQueueErrorMessage('Second error');
});
rerender(getAppContainer());
await waitFor(() => expect(capturedUIState.transientMessage?.message).toBe('Second error'));
expect(capturedUIState.queueErrorMessage).toBe('Second error');
act(() => {
vi.advanceTimersByTime(2000);
});
rerender(getAppContainer());
await waitFor(() => expect(capturedUIState.transientMessage?.message).toBe('Second error'));
expect(capturedUIState.queueErrorMessage).toBe('Second error');
// 5. Advance time past the 3 second timeout from the second message
act(() => {
vi.advanceTimersByTime(1000);
});
rerender(getAppContainer());
expect(capturedUIState.transientMessage).toBeNull();
expect(capturedUIState.queueErrorMessage).toBeNull();
unmount();
});
});
@@ -3396,21 +3389,21 @@ describe('AppContainer State Management', () => {
await waitFor(() => {
// Should show hint because we are in Standard Mode (default settings) and have overflow
expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent);
expect(capturedUIState.showIsExpandableHint).toBe(true);
});
// Advance just before the timeout
act(() => {
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS - 100);
});
expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent);
expect(capturedUIState.showIsExpandableHint).toBe(true);
// Advance to hit the timeout mark
act(() => {
vi.advanceTimersByTime(100);
});
await waitFor(() => {
expect(capturedUIState.transientMessage).toBeNull();
expect(capturedUIState.showIsExpandableHint).toBe(false);
});
unmount!();
@@ -3430,14 +3423,14 @@ describe('AppContainer State Management', () => {
});
await waitFor(() => {
expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent);
expect(capturedUIState.showIsExpandableHint).toBe(true);
});
// 2. Advance half the duration
act(() => {
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2);
});
expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent);
expect(capturedUIState.showIsExpandableHint).toBe(true);
// 3. Trigger second overflow (this should reset the timer)
act(() => {
@@ -3451,7 +3444,7 @@ describe('AppContainer State Management', () => {
});
await waitFor(() => {
expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent);
expect(capturedUIState.showIsExpandableHint).toBe(true);
});
// 4. Advance enough that the ORIGINAL timer would have expired
@@ -3460,14 +3453,14 @@ describe('AppContainer State Management', () => {
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 + 100 - 1);
});
// The hint should STILL be visible because the timer reset at step 3
expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent);
expect(capturedUIState.showIsExpandableHint).toBe(true);
// 5. Advance to the end of the NEW timer
act(() => {
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 - 100);
});
await waitFor(() => {
expect(capturedUIState.transientMessage).toBeNull();
expect(capturedUIState.showIsExpandableHint).toBe(false);
});
unmount!();
@@ -3492,14 +3485,14 @@ describe('AppContainer State Management', () => {
});
await waitFor(() => {
expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent);
expect(capturedUIState.showIsExpandableHint).toBe(true);
});
// Advance half the duration
act(() => {
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2);
});
expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent);
expect(capturedUIState.showIsExpandableHint).toBe(true);
// Simulate Ctrl+O
act(() => {
@@ -3517,7 +3510,7 @@ describe('AppContainer State Management', () => {
});
// We expect it to still be true because Ctrl+O should have reset the timer
expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent);
expect(capturedUIState.showIsExpandableHint).toBe(true);
// Advance remaining time to reach the new timeout
act(() => {
@@ -3525,7 +3518,7 @@ describe('AppContainer State Management', () => {
});
await waitFor(() => {
expect(capturedUIState.transientMessage).toBeNull();
expect(capturedUIState.showIsExpandableHint).toBe(false);
});
unmount!();
@@ -3550,14 +3543,14 @@ describe('AppContainer State Management', () => {
});
await waitFor(() => {
expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent);
expect(capturedUIState.showIsExpandableHint).toBe(true);
});
// Advance half the duration
act(() => {
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2);
});
expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent);
expect(capturedUIState.showIsExpandableHint).toBe(true);
// First toggle 'on' (expanded)
act(() => {
@@ -3571,7 +3564,7 @@ describe('AppContainer State Management', () => {
act(() => {
vi.advanceTimersByTime(1000);
});
expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent);
expect(capturedUIState.showIsExpandableHint).toBe(true);
// Second toggle 'off' (collapsed)
act(() => {
@@ -3585,7 +3578,7 @@ describe('AppContainer State Management', () => {
act(() => {
vi.advanceTimersByTime(1000);
});
expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent);
expect(capturedUIState.showIsExpandableHint).toBe(true);
// Third toggle 'on' (expanded)
act(() => {
@@ -3600,7 +3593,7 @@ describe('AppContainer State Management', () => {
act(() => {
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS - 100);
});
expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent);
expect(capturedUIState.showIsExpandableHint).toBe(true);
// Wait 0.1s more to hit exactly the timeout since the last toggle.
// It should hide now.
@@ -3608,7 +3601,7 @@ describe('AppContainer State Management', () => {
vi.advanceTimersByTime(100);
});
await waitFor(() => {
expect(capturedUIState.transientMessage).toBeNull();
expect(capturedUIState.showIsExpandableHint).toBe(false);
});
unmount!();
@@ -3642,9 +3635,9 @@ describe('AppContainer State Management', () => {
capturedOverflowActions.addOverflowingId('test-id');
});
// Should NOT show hint because we are in Alternate Buffer Mode
// Should NOW show hint because we are in Alternate Buffer Mode
await waitFor(() => {
expect(capturedUIState.transientMessage).toBeNull();
expect(capturedUIState.showIsExpandableHint).toBe(true);
});
unmount!();
+84 -165
View File
@@ -4,13 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useLegacyNonAlternateBufferMode } from './hooks/useAlternateBuffer.js';
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
useMemo,
useState,
@@ -20,7 +13,6 @@ import {
useLayoutEffect,
} from 'react';
import {
Box,
type DOMElement,
measureElement,
useApp,
@@ -128,18 +120,12 @@ import { useFocus } from './hooks/useFocus.js';
import { useKeypress, type Key } from './hooks/useKeypress.js';
import { KeypressPriority } from './contexts/KeypressContext.js';
import { keyMatchers, Command } from './keyMatchers.js';
import { formatCommand } from './utils/keybindingUtils.js';
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js';
import { useFolderTrust } from './hooks/useFolderTrust.js';
import { useIdeTrustListener } from './hooks/useIdeTrustListener.js';
import { type IdeIntegrationNudgeResult } from './IdeIntegrationNudge.js';
import {
appEvents,
AppEvent,
TransientMessageType,
type TransientMessagePayload,
} from '../utils/events.js';
import { appEvents, AppEvent, TransientMessageType } from '../utils/events.js';
import { type UpdateObject } from './utils/updateCheck.js';
import { setUpdateHandler } from '../utils/handleAutoUpdate.js';
import { registerCleanup, runExitCleanup } from '../utils/cleanup.js';
@@ -244,89 +230,6 @@ export const AppContainer = (props: AppContainerProps) => {
useMemoryMonitor(historyManager);
const isAlternateBuffer = config.getUseAlternateBuffer();
const rootUiRef = useRef<DOMElement>(null);
const isLegacyNonAlternateBufferMode =
useLegacyNonAlternateBufferMode(rootUiRef, isAlternateBuffer);
const [transientMessage, setTransientMessageInternal] = useTimedMessage<{
message: string;
type: TransientMessageType;
}>(WARNING_PROMPT_DURATION_MS, isLegacyNonAlternateBufferMode);
const currentlyShowingTypeRef = useRef<TransientMessageType | null>(null);
const [transientMessageQueue, setTransientMessageQueue] = useState<
TransientMessagePayload[]
>([]);
const showTransientMessage = useCallback(
(payload: TransientMessagePayload | null, durationMs?: number) => {
if (payload === null) {
setTransientMessageInternal(null);
setTransientMessageQueue([]);
currentlyShowingTypeRef.current = null;
return;
}
if (currentlyShowingTypeRef.current === payload.type) {
setTransientMessageInternal(
{ message: payload.message, type: payload.type },
durationMs,
);
return;
}
setTransientMessageQueue((prev) => [...prev, { ...payload, durationMs }]);
},
[setTransientMessageInternal],
);
useEffect(() => {
if (isLegacyNonAlternateBufferMode) {
return;
}
if (transientMessageQueue.length > 0 && !transientMessage) {
const [next, ...rest] = transientMessageQueue;
currentlyShowingTypeRef.current = next.type;
setTransientMessageInternal(
{ message: next.message, type: next.type },
next.durationMs,
);
setTransientMessageQueue(rest);
} else if (!transientMessage) {
currentlyShowingTypeRef.current = null;
}
}, [
transientMessageQueue,
transientMessage,
setTransientMessageInternal,
isLegacyNonAlternateBufferMode,
]);
const handleSetConstrainHeight = useCallback(
(value: boolean) => {
setConstrainHeight(value);
showTransientMessage(
{
message: `Ctrl+O to ${!value ? 'show more' : 'collapse'} lines of the last response`,
type: TransientMessageType.Accent,
},
EXPAND_HINT_DURATION_MS,
);
},
[showTransientMessage],
);
const handleSetQueueErrorMessage = useCallback(
(message: string | null) => {
showTransientMessage(
message ? { message, type: TransientMessageType.Error } : null,
QUEUE_ERROR_DISPLAY_DURATION_MS,
);
},
[showTransientMessage],
);
const [corgiMode, setCorgiMode] = useState(false);
const [forceRerenderKey, setForceRerenderKey] = useState(0);
const [debugMessage, setDebugMessage] = useState<string>('');
@@ -362,9 +265,16 @@ export const AppContainer = (props: AppContainerProps) => {
() => isWorkspaceTrusted(settings.merged).isTrusted,
);
const [queueErrorMessage, setQueueErrorMessage] = useTimedMessage<string>(
QUEUE_ERROR_DISPLAY_DURATION_MS,
);
const [newAgents, setNewAgents] = useState<AgentDefinition[] | null>(null);
const [constrainHeight, setConstrainHeight] = useState<boolean>(true);
const [expandHintTrigger, triggerExpandHint] = useTimedMessage<boolean>(
EXPAND_HINT_DURATION_MS,
);
const showIsExpandableHint = Boolean(expandHintTrigger);
const overflowState = useOverflowState();
const overflowingIdsSize = overflowState?.overflowingIds.size ?? 0;
const hasOverflowState = overflowingIdsSize > 0 || !constrainHeight;
@@ -381,22 +291,10 @@ export const AppContainer = (props: AppContainerProps) => {
* to avoid noise, but the user can still trigger it manually with Ctrl+O.
*/
useEffect(() => {
if (hasOverflowState && !isAlternateBuffer) {
showTransientMessage(
{
message: `Ctrl+O to ${constrainHeight ? 'show more' : 'collapse'} lines of the last response`,
type: TransientMessageType.Accent,
},
EXPAND_HINT_DURATION_MS,
);
if (hasOverflowState) {
triggerExpandHint(true);
}
}, [
hasOverflowState,
isAlternateBuffer,
constrainHeight,
showTransientMessage,
overflowingIdsSize,
]);
}, [hasOverflowState, overflowingIdsSize, triggerExpandHint]);
const [defaultBannerText, setDefaultBannerText] = useState('');
const [warningBannerText, setWarningBannerText] = useState('');
@@ -517,6 +415,7 @@ export const AppContainer = (props: AppContainerProps) => {
// Layout measurements
const mainControlsRef = useRef<DOMElement>(null);
// For performance profiling only
const rootUiRef = useRef<DOMElement>(null);
const lastTitleRef = useRef<string | null>(null);
const staticExtraHeight = 3;
@@ -1361,7 +1260,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
async (submittedValue: string) => {
reset();
// Explicitly hide the expansion hint and clear its x-second timer when a new turn begins.
showTransientMessage(null);
triggerExpandHint(null);
if (!constrainHeight) {
setConstrainHeight(true);
if (!isAlternateBuffer) {
@@ -1423,24 +1322,24 @@ Logging in with Google... Restarting Gemini CLI to continue.
submitQuery,
isMcpReady,
streamingState,
messageQueue.length,
pendingSlashCommandHistoryItems,
pendingGeminiHistoryItems,
config,
constrainHeight,
handleSetConstrainHeight,
setConstrainHeight,
isAlternateBuffer,
isAgentConfigDialogOpen,
refreshStatic,
reset,
handleHintSubmit,
showTransientMessage,
triggerExpandHint,
],
);
const handleClearScreen = useCallback(() => {
reset();
// Explicitly hide the expansion hint and clear its x-second timer when clearing the screen.
showTransientMessage(null);
triggerExpandHint(null);
historyManager.clearItems();
clearConsoleMessagesState();
refreshStatic();
@@ -1449,7 +1348,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
clearConsoleMessagesState,
refreshStatic,
reset,
showTransientMessage,
triggerExpandHint,
]);
const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit);
@@ -1572,39 +1471,39 @@ Logging in with Google... Restarting Gemini CLI to continue.
const [renderMarkdown, setRenderMarkdown] = useState<boolean>(true);
const handleExitRepeat = useCallback(
(count: number, message: string) => {
(count: number) => {
if (count > 2) {
recordExitFail(config);
}
if (count > 1) {
void handleSlashCommand('/quit', undefined, undefined, false);
} else if (count === 1) {
showTransientMessage({
message,
type: TransientMessageType.Warning,
});
}
},
[config, handleSlashCommand, showTransientMessage],
[config, handleSlashCommand],
);
const { handlePress: handleCtrlCPress } =
const { pressCount: ctrlCPressCount, handlePress: handleCtrlCPress } =
useRepeatedKeyPress({
windowMs: WARNING_PROMPT_DURATION_MS,
onRepeat: (count) => handleExitRepeat(count, 'Press Ctrl+C again to exit.'),
onRepeat: handleExitRepeat,
});
const { handlePress: handleCtrlDPress } =
const { pressCount: ctrlDPressCount, handlePress: handleCtrlDPress } =
useRepeatedKeyPress({
windowMs: WARNING_PROMPT_DURATION_MS,
onRepeat: (count) => handleExitRepeat(count, 'Press Ctrl+D again to exit.'),
onRepeat: handleExitRepeat,
});
const [ideContextState, setIdeContextState] = useState<
IdeContext | undefined
>();
const [showIdeRestartPrompt, setShowIdeRestartPrompt] = useState(false);
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
const [showIdeRestartPrompt, setShowIdeRestartPrompt] = useState(false);
const [transientMessage, showTransientMessage] = useTimedMessage<{
text: string;
type: TransientMessageType;
}>(WARNING_PROMPT_DURATION_MS);
const {
isFolderTrustDialogOpen,
@@ -1633,14 +1532,20 @@ Logging in with Google... Restarting Gemini CLI to continue.
message: string;
type: TransientMessageType;
}) => {
showTransientMessage({ message: payload.message, type: payload.type }, payload.durationMs);
showTransientMessage({ text: payload.message, type: payload.type });
};
const handleSelectionWarning = () => {
showTransientMessage({ message: 'Press Ctrl-S to enter selection mode to copy text.', type: TransientMessageType.Warning });
showTransientMessage({
text: 'Press Ctrl-S to enter selection mode to copy text.',
type: TransientMessageType.Warning,
});
};
const handlePasteTimeout = () => {
showTransientMessage({ message: 'Paste Timed out. Possibly due to slow connection.', type: TransientMessageType.Warning });
showTransientMessage({
text: 'Paste Timed out. Possibly due to slow connection.',
type: TransientMessageType.Warning,
});
};
appEvents.on(AppEvent.TransientMessage, handleTransientMessage);
@@ -1659,7 +1564,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
const handleWarning = useCallback(
(message: string) => {
showTransientMessage({ message, type: TransientMessageType.Warning });
showTransientMessage({
text: message,
type: TransientMessageType.Warning,
});
},
[showTransientMessage],
);
@@ -1712,6 +1620,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
};
}, [config]);
const handleEscapePromptChange = useCallback((showPrompt: boolean) => {
setShowEscapePrompt(showPrompt);
}, []);
const handleIdePromptComplete = useCallback(
(result: IdeIntegrationNudgeResult) => {
@@ -1769,17 +1680,20 @@ Logging in with Google... Restarting Gemini CLI to continue.
keyMatchers[Command.TOGGLE_COPY_MODE](key) &&
!isAlternateBuffer
) {
showTransientMessage({ message: 'Use Ctrl+O to expand and collapse blocks of content.', type: TransientMessageType.Warning });
showTransientMessage({
text: 'Use Ctrl+O to expand and collapse blocks of content.',
type: TransientMessageType.Warning,
});
return true;
}
let enteringConstrainHeightMode = false;
if (!constrainHeight) {
enteringConstrainHeightMode = true;
setConstrainHeight(true);
if (keyMatchers[Command.SHOW_MORE_LINES](key)) {
handleSetConstrainHeight(true);
} else {
setConstrainHeight(true);
// If the user manually collapses the view, show the hint and reset the x-second timer.
triggerExpandHint(true);
}
if (!isAlternateBuffer) {
refreshStatic();
@@ -1809,15 +1723,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
} else if (keyMatchers[Command.TOGGLE_MARKDOWN](key)) {
setRenderMarkdown((prev) => {
const newValue = !prev;
showTransientMessage(
{
message: newValue
? 'rendered markdown mode'
: `raw markdown mode (${formatCommand(Command.TOGGLE_MARKDOWN)} to toggle)`,
type: TransientMessageType.Accent,
},
EXPAND_HINT_DURATION_MS,
);
// Force re-render of static content
refreshStatic();
return newValue;
});
@@ -1834,7 +1740,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
keyMatchers[Command.SHOW_MORE_LINES](key) &&
!enteringConstrainHeightMode
) {
handleSetConstrainHeight(false);
setConstrainHeight(false);
// If the user manually expands the view, show the hint and reset the x-second timer.
triggerExpandHint(true);
if (!isAlternateBuffer) {
refreshStatic();
}
@@ -1852,7 +1760,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
if (lastOutputTimeRef.current === capturedTime) {
setEmbeddedShellFocused(false);
} else {
showTransientMessage({ message: 'Use Shift+Tab to unfocus', type: TransientMessageType.Warning });
showTransientMessage({
text: 'Use Shift+Tab to unfocus',
type: TransientMessageType.Warning,
});
}
}, 150);
return false;
@@ -1910,7 +1821,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
},
[
constrainHeight,
handleSetConstrainHeight,
setConstrainHeight,
setShowErrorDetails,
config,
ideContextState,
@@ -1925,6 +1836,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
refreshStatic,
setCopyModeEnabled,
tabFocusTimeoutRef,
isAlternateBuffer,
shortcutsHelpVisible,
backgroundCurrentShell,
toggleBackgroundShell,
@@ -1935,7 +1847,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
showTransientMessage,
settings.merged.general.devtools,
showErrorDetails,
showTransientMessage,
triggerExpandHint,
],
);
@@ -2289,6 +2201,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
filteredConsoleMessages,
ideContextState,
renderMarkdown,
ctrlCPressedOnce: ctrlCPressCount >= 1,
ctrlDPressedOnce: ctrlDPressCount >= 1,
showEscapePrompt,
shortcutsHelpVisible,
cleanUiDetailsVisible,
isFocused,
@@ -2297,6 +2212,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
historyRemountKey,
activeHooks,
messageQueue,
queueErrorMessage,
showApprovalModeIndicator,
allowPlanMode,
currentModel,
@@ -2337,6 +2253,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
showDebugProfiler,
customDialog,
copyModeEnabled,
transientMessage,
bannerData,
bannerVisible,
terminalBackgroundColor: config.getTerminalBackground(),
@@ -2347,6 +2264,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
isBackgroundShellListOpen,
adminSettingsChanged,
newAgents,
showIsExpandableHint,
hintMode:
config.isModelSteeringEnabled() &&
isToolExecuting([
@@ -2354,9 +2272,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
...pendingGeminiHistoryItems,
]),
hintBuffer: '',
transientMessage: transientMessage
? { message: transientMessage.message, type: transientMessage.type }
: null,
}),
[
isThemeDialogOpen,
@@ -2376,6 +2291,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
isSettingsDialogOpen,
isSessionBrowserOpen,
isModelDialogOpen,
isAgentConfigDialogOpen,
selectedAgentName,
selectedAgentDisplayName,
selectedAgentDefinition,
@@ -2413,6 +2329,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
filteredConsoleMessages,
ideContextState,
renderMarkdown,
ctrlCPressCount,
ctrlDPressCount,
showEscapePrompt,
shortcutsHelpVisible,
cleanUiDetailsVisible,
isFocused,
@@ -2421,6 +2340,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
historyRemountKey,
activeHooks,
messageQueue,
queueErrorMessage,
showApprovalModeIndicator,
allowPlanMode,
userTier,
@@ -2472,7 +2392,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
backgroundShells,
adminSettingsChanged,
newAgents,
transientMessage,
showIsExpandableHint,
],
);
@@ -2503,8 +2423,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleIdePromptComplete,
handleFolderTrustSelect,
setIsPolicyUpdateDialogOpen,
handleSetConstrainHeight,
setConstrainHeight: handleSetConstrainHeight,
setConstrainHeight,
onEscapePromptChange: handleEscapePromptChange,
refreshStatic,
handleFinalSubmit,
handleClearScreen,
@@ -2517,7 +2437,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
closeSessionBrowser,
handleResumeSession,
handleDeleteSession,
setQueueErrorMessage: handleSetQueueErrorMessage,
setQueueErrorMessage,
popAllMessages,
handleApiKeySubmit,
handleApiKeyCancel,
@@ -2595,7 +2515,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleIdePromptComplete,
handleFolderTrustSelect,
setIsPolicyUpdateDialogOpen,
handleSetConstrainHeight,
setConstrainHeight,
handleEscapePromptChange,
refreshStatic,
handleFinalSubmit,
handleClearScreen,
@@ -2607,7 +2528,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
closeSessionBrowser,
handleResumeSession,
handleDeleteSession,
handleSetQueueErrorMessage,
setQueueErrorMessage,
popAllMessages,
handleApiKeySubmit,
handleApiKeyCancel,
@@ -2653,10 +2574,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
}}
>
<ToolActionsProvider config={config} toolCalls={allToolCalls}>
<ShellFocusContext.Provider value={embeddedShellFocused}>
<Box ref={rootUiRef} flexDirection="column">
<App key={`app-${forceRerenderKey}`} />
</Box>
<ShellFocusContext.Provider value={isFocused}>
<App key={`app-${forceRerenderKey}`} />
</ShellFocusContext.Provider>
</ToolActionsProvider>
</AppContext.Provider>
@@ -63,7 +63,13 @@ vi.mock('./StatusDisplay.js', () => ({
vi.mock('./ToastDisplay.js', () => ({
ToastDisplay: () => <Text>ToastDisplay</Text>,
shouldShowToast: (uiState: UIState) => Boolean(uiState.transientMessage),
shouldShowToast: (uiState: UIState) =>
uiState.ctrlCPressedOnce ||
Boolean(uiState.transientMessage) ||
uiState.ctrlDPressedOnce ||
(uiState.showEscapePrompt &&
(uiState.buffer.text.length > 0 || uiState.history.length > 0)) ||
Boolean(uiState.queueErrorMessage),
}));
vi.mock('./ContextSummaryDisplay.js', () => ({
@@ -169,6 +175,9 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
thought: '',
currentLoadingPhrase: '',
elapsedTime: 0,
ctrlCPressedOnce: false,
ctrlDPressedOnce: false,
showEscapePrompt: false,
shortcutsHelpVisible: false,
cleanUiDetailsVisible: true,
ideContextState: null,
@@ -530,7 +539,10 @@ describe('Composer', () => {
describe('Context and Status Display', () => {
it('shows StatusDisplay and ApprovalModeIndicator in normal state', async () => {
const uiState = createMockUIState({
});
ctrlCPressedOnce: false,
ctrlDPressedOnce: false,
showEscapePrompt: false,
});
const { lastFrame } = await renderComposer(uiState);
@@ -542,7 +554,7 @@ describe('Composer', () => {
it('shows ToastDisplay and hides ApprovalModeIndicator when a toast is present', async () => {
const uiState = createMockUIState({
transientMessage: { message: 'test', type: TransientMessageType.Warning },
ctrlCPressedOnce: true,
});
const { lastFrame } = await renderComposer(uiState);
@@ -555,7 +567,10 @@ describe('Composer', () => {
it('shows ToastDisplay for other toast types', async () => {
const uiState = createMockUIState({
transientMessage: { message: 'Warning', type: TransientMessageType.Warning },
transientMessage: {
text: 'Warning',
type: TransientMessageType.Warning,
},
});
const { lastFrame } = await renderComposer(uiState);
@@ -631,6 +646,26 @@ describe('Composer', () => {
expect(lastFrame()).toMatch(/ShellModeIndic[\s\S]*tor/);
});
it('shows RawMarkdownIndicator when renderMarkdown is false', async () => {
const uiState = createMockUIState({
renderMarkdown: false,
});
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toContain('raw markdown mode');
});
it('does not show RawMarkdownIndicator when renderMarkdown is true', async () => {
const uiState = createMockUIState({
renderMarkdown: true,
});
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('raw markdown mode');
});
it.each([
[ApprovalMode.YOLO, 'YOLO'],
[ApprovalMode.PLAN, 'plan'],
@@ -680,7 +715,18 @@ describe('Composer', () => {
expect(output).not.toContain('ShortcutsHint');
});
it('shows Esc rewind prompt in minimal mode without showing full UI', async () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
showEscapePrompt: true,
history: [{ id: 1, type: 'user', text: 'msg' }],
});
const { lastFrame } = await renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('ToastDisplay');
expect(output).not.toContain('ContextSummaryDisplay');
});
it('shows context usage bleed-through when over 60%', async () => {
const model = 'gemini-2.5-pro';
@@ -17,6 +17,7 @@ import { ToastDisplay, shouldShowToast } from './ToastDisplay.js';
import { ApprovalModeIndicator } from './ApprovalModeIndicator.js';
import { ShellModeIndicator } from './ShellModeIndicator.js';
import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
import { RawMarkdownIndicator } from './RawMarkdownIndicator.js';
import { ShortcutsHint } from './ShortcutsHint.js';
import { ShortcutsHelp } from './ShortcutsHelp.js';
import { InputPrompt } from './InputPrompt.js';
@@ -113,6 +114,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
suggestionsVisible && suggestionsPosition === 'above';
const showApprovalIndicator =
!uiState.shellModeActive && !hideUiDetailsForSuggestions;
const showRawMarkdownIndicator = !uiState.renderMarkdown;
let modeBleedThrough: { text: string; color: string } | null = null;
switch (showApprovalModeIndicator) {
case ApprovalMode.YOLO:
@@ -376,6 +378,26 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
<ShellModeIndicator />
</Box>
)}
{showRawMarkdownIndicator && (
<Box
marginLeft={
(showApprovalIndicator ||
uiState.shellModeActive) &&
!isNarrow
? 1
: 0
}
marginTop={
(showApprovalIndicator ||
uiState.shellModeActive) &&
!isNarrow
? 1
: 0
}
>
<RawMarkdownIndicator />
</Box>
)}
</>
)}
</Box>
@@ -426,6 +448,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
shellModeActive={uiState.shellModeActive}
setShellModeActive={uiActions.setShellModeActive}
approvalMode={showApprovalModeIndicator}
onEscapePromptChange={uiActions.onEscapePromptChange}
focus={isFocused}
vimHandleInput={uiActions.vimHandleInput}
isEmbeddedShellFocused={uiState.embeddedShellFocused}
@@ -8,7 +8,6 @@ import { render } from '../../test-utils/render.js';
import { ExitWarning } from './ExitWarning.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useUIState, type UIState } from '../contexts/UIStateContext.js';
import { TransientMessageType } from '../../utils/events.js';
vi.mock('../contexts/UIStateContext.js');
@@ -22,7 +21,8 @@ describe('ExitWarning', () => {
it('renders nothing by default', async () => {
mockUseUIState.mockReturnValue({
dialogsVisible: false,
transientMessage: null,
ctrlCPressedOnce: false,
ctrlDPressedOnce: false,
} as unknown as UIState);
const { lastFrame, waitUntilReady, unmount } = render(<ExitWarning />);
await waitUntilReady();
@@ -30,41 +30,35 @@ describe('ExitWarning', () => {
unmount();
});
it('renders warning when transient message is a warning and dialogs visible', async () => {
it('renders Ctrl+C warning when pressed once and dialogs visible', async () => {
mockUseUIState.mockReturnValue({
dialogsVisible: true,
transientMessage: {
message: 'Test Warning',
type: TransientMessageType.Warning,
},
ctrlCPressedOnce: true,
ctrlDPressedOnce: false,
} as unknown as UIState);
const { lastFrame, waitUntilReady, unmount } = render(<ExitWarning />);
await waitUntilReady();
expect(lastFrame()).toContain('Test Warning');
expect(lastFrame()).toContain('Press Ctrl+C again to exit');
unmount();
});
it('renders nothing if transient message is not a warning', async () => {
it('renders Ctrl+D warning when pressed once and dialogs visible', async () => {
mockUseUIState.mockReturnValue({
dialogsVisible: true,
transientMessage: {
message: 'Test Hint',
type: TransientMessageType.Hint,
},
ctrlCPressedOnce: false,
ctrlDPressedOnce: true,
} as unknown as UIState);
const { lastFrame, waitUntilReady, unmount } = render(<ExitWarning />);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
expect(lastFrame()).toContain('Press Ctrl+D again to exit');
unmount();
});
it('renders nothing if dialogs are not visible', async () => {
mockUseUIState.mockReturnValue({
dialogsVisible: false,
transientMessage: {
message: 'Test Warning',
type: TransientMessageType.Warning,
},
ctrlCPressedOnce: true,
ctrlDPressedOnce: true,
} as unknown as UIState);
const { lastFrame, waitUntilReady, unmount } = render(<ExitWarning />);
await waitUntilReady();
+14 -16
View File
@@ -8,24 +8,22 @@ import type React from 'react';
import { Box, Text } from 'ink';
import { useUIState } from '../contexts/UIStateContext.js';
import { theme } from '../semantic-colors.js';
import { TransientMessageType } from '../../utils/events.js';
export const ExitWarning: React.FC = () => {
const uiState = useUIState();
if (!uiState.dialogsVisible) {
return null;
}
return (
<>
{uiState.dialogsVisible && uiState.ctrlCPressedOnce && (
<Box marginTop={1}>
<Text color={theme.status.warning}>Press Ctrl+C again to exit.</Text>
</Box>
)}
if (
uiState.transientMessage?.type === TransientMessageType.Warning &&
uiState.transientMessage.message
) {
return (
<Box marginTop={1}>
<Text color={theme.status.warning}>{uiState.transientMessage.message}</Text>
</Box>
);
}
return null;
{uiState.dialogsVisible && uiState.ctrlDPressedOnce && (
<Box marginTop={1}>
<Text color={theme.status.warning}>Press Ctrl+D again to exit.</Text>
</Box>
)}
</>
);
};
+32 -5
View File
@@ -132,9 +132,7 @@ describe('<Footer />', () => {
const output = lastFrame();
expect(output).toBeDefined();
// Should contain some part of the path, likely shortened
expect(output).toContain(
path.join('directories', 'to', 'make', 'it', 'long'),
);
expect(output).toContain(path.join('make', 'it'));
unmount();
});
@@ -149,9 +147,38 @@ describe('<Footer />', () => {
await waitUntilReady();
const output = lastFrame();
expect(output).toBeDefined();
expect(output).toContain(
path.join('directories', 'to', 'make', 'it', 'long'),
expect(output).toContain(path.join('make', 'it'));
unmount();
});
it('should not truncate high-priority items on narrow terminals (regression)', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 60,
uiState: {
sessionStats: mockSessionStats,
},
settings: createMockSettings({
general: {
vimMode: true,
},
ui: {
footer: {
showLabels: true,
items: ['workspace', 'model-name'],
},
},
}),
},
);
await waitUntilReady();
const output = lastFrame();
// [INSERT] is high priority and should be fully visible
// (Note: VimModeProvider defaults to 'INSERT' mode when enabled)
expect(output).toContain('[INSERT]');
// Other items should be present but might be shortened
expect(output).toContain('gemini-pro');
unmount();
});
});
+44 -19
View File
@@ -103,6 +103,9 @@ export interface FooterRowItem {
key: string;
header: string;
element: React.ReactNode;
flexGrow?: number;
flexShrink?: number;
isFocused?: boolean;
}
const COLUMN_GAP = 3;
@@ -123,10 +126,20 @@ export const FooterRow: React.FC<{
}
elements.push(
<Box key={item.key} flexDirection="column">
<Box
key={item.key}
flexDirection="column"
flexGrow={item.flexGrow ?? 0}
flexShrink={item.flexShrink ?? 1}
backgroundColor={item.isFocused ? theme.background.focus : undefined}
>
{showLabels && (
<Box height={1}>
<Text color={theme.ui.comment}>{item.header}</Text>
<Text
color={item.isFocused ? theme.text.primary : theme.ui.comment}
>
{item.header}
</Text>
</Box>
)}
<Box height={1}>{item.element}</Box>
@@ -138,6 +151,7 @@ export const FooterRow: React.FC<{
<Box
flexDirection="row"
flexWrap="nowrap"
width="100%"
columnGap={showLabels ? COLUMN_GAP : 0}
>
{elements}
@@ -408,41 +422,50 @@ export const Footer: React.FC = () => {
}
// --- Width Fitting Logic ---
let currentWidth = 2; // Initial padding
const columnsToRender: FooterColumn[] = [];
let droppedAny = false;
let currentUsedWidth = 2; // Initial padding
for (let i = 0; i < potentialColumns.length; i++) {
const col = potentialColumns[i];
const gap = columnsToRender.length > 0 ? (showLabels ? COLUMN_GAP : 3) : 0; // Use 3 for dot separator width
for (const col of potentialColumns) {
const gap = columnsToRender.length > 0 ? (showLabels ? COLUMN_GAP : 3) : 0;
const budgetWidth = col.id === 'workspace' ? 20 : col.width;
if (
col.isHighPriority ||
currentWidth + gap + budgetWidth <= terminalWidth - 2
currentUsedWidth + gap + budgetWidth <= terminalWidth - 2
) {
columnsToRender.push(col);
currentWidth += gap + budgetWidth;
currentUsedWidth += gap + budgetWidth;
} else {
droppedAny = true;
}
}
const totalBudgeted = columnsToRender.reduce(
(sum, c, idx) =>
sum +
(c.id === 'workspace' ? 20 : c.width) +
(idx > 0 ? (showLabels ? COLUMN_GAP : 3) : 0),
2,
);
const excessSpace = Math.max(0, terminalWidth - totalBudgeted);
const rowItems: FooterRowItem[] = columnsToRender.map((col) => {
const maxWidth = col.id === 'workspace' ? 20 + excessSpace : col.width;
const isWorkspace = col.id === 'workspace';
// Calculate exact space available for growth to prevent over-estimation truncation
const otherItemsWidth = columnsToRender
.filter((c) => c.id !== 'workspace')
.reduce((sum, c) => sum + c.width, 0);
const numItems = columnsToRender.length + (droppedAny ? 1 : 0);
const numGaps = numItems > 1 ? numItems - 1 : 0;
const gapsWidth = numGaps * (showLabels ? COLUMN_GAP : 3);
const ellipsisWidth = droppedAny ? 1 : 0;
const availableForWorkspace = Math.max(
20,
terminalWidth - 2 - gapsWidth - otherItemsWidth - ellipsisWidth,
);
const estimatedWidth = isWorkspace ? availableForWorkspace : col.width;
return {
key: col.id,
header: col.header,
element: col.element(maxWidth),
element: col.element(estimatedWidth),
flexGrow: isWorkspace ? 1 : 0,
flexShrink: isWorkspace ? 1 : 0,
};
});
@@ -451,6 +474,8 @@ export const Footer: React.FC = () => {
key: 'ellipsis',
header: '',
element: <Text color={theme.ui.comment}></Text>,
flexGrow: 0,
flexShrink: 0,
});
}
@@ -24,13 +24,14 @@ describe('<FooterConfigDialog />', () => {
it('renders correctly with default settings', async () => {
const settings = createMockSettings();
const { lastFrame, waitUntilReady } = renderWithProviders(
const renderResult = renderWithProviders(
<FooterConfigDialog onClose={mockOnClose} />,
{ settings },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
await renderResult.waitUntilReady();
expect(renderResult.lastFrame()).toMatchSnapshot();
await expect(renderResult).toMatchSvgSnapshot();
});
it('toggles an item when enter is pressed', async () => {
@@ -66,7 +67,7 @@ describe('<FooterConfigDialog />', () => {
);
await waitUntilReady();
// Initial order: workspace, branch, ...
// Initial order: workspace, git-branch, ...
const output = lastFrame();
const cwdIdx = output.indexOf('] workspace');
const branchIdx = output.indexOf('] git-branch');
@@ -108,22 +109,40 @@ describe('<FooterConfigDialog />', () => {
it('highlights the active item in the preview', async () => {
const settings = createMockSettings();
const { lastFrame, stdin, waitUntilReady } = renderWithProviders(
const renderResult = renderWithProviders(
<FooterConfigDialog onClose={mockOnClose} />,
{ settings },
);
const { lastFrame, stdin, waitUntilReady } = renderResult;
await waitUntilReady();
expect(lastFrame()).toContain('~/project/path');
// Move focus down to 'git-branch'
// Move focus down to 'code-changes' (which has colored elements)
for (let i = 0; i < 8; i++) {
act(() => {
stdin.write('\u001b[B'); // Down arrow
});
}
await waitFor(() => {
// The selected indicator should be next to 'code-changes'
expect(lastFrame()).toMatch(/> \[ \] code-changes/);
});
// Toggle it on
act(() => {
stdin.write('\u001b[B'); // Down arrow
stdin.write('\r');
});
await waitFor(() => {
expect(lastFrame()).toContain('main');
// It should now be checked and appear in the preview
expect(lastFrame()).toMatch(/> \[✓\] code-changes/);
expect(lastFrame()).toContain('+12 -4');
});
await expect(renderResult).toMatchSvgSnapshot();
});
it('shows an empty preview when all items are deselected', async () => {
@@ -134,20 +153,64 @@ describe('<FooterConfigDialog />', () => {
);
await waitUntilReady();
for (let i = 0; i < 10; i++) {
// Default items are the first 5. We toggle them off.
for (let i = 0; i < 5; i++) {
act(() => {
stdin.write('\r'); // Toggle off
});
act(() => {
stdin.write('\r'); // Toggle (deselect)
stdin.write('\u001b[B'); // Down arrow
});
}
await waitFor(
() => {
const output = lastFrame();
expect(output).toContain('Preview:');
expect(output).not.toContain('~/project/path');
expect(output).not.toContain('docker');
},
{ timeout: 2000 },
);
});
it('moves item correctly after trying to move up at the top', async () => {
const settings = createMockSettings();
const { lastFrame, stdin, waitUntilReady } = renderWithProviders(
<FooterConfigDialog onClose={mockOnClose} />,
{ settings },
);
await waitUntilReady();
// Default initial items in mock settings are 'git-branch', 'workspace', ...
await waitFor(() => {
const output = lastFrame();
expect(output).toContain('Preview:');
expect(output).not.toContain('~/project/path');
expect(output).not.toContain('docker');
expect(output).not.toContain('gemini-2.5-pro');
expect(output).not.toContain('1.2k left');
expect(output).toContain('] git-branch');
expect(output).toContain('] workspace');
});
const output = lastFrame();
const branchIdx = output.indexOf('] git-branch');
const workspaceIdx = output.indexOf('] workspace');
expect(workspaceIdx).toBeLessThan(branchIdx);
// Try to move workspace up (left arrow) while it's at the top
act(() => {
stdin.write('\u001b[D'); // Left arrow
});
// Move workspace down (right arrow)
act(() => {
stdin.write('\u001b[C'); // Right arrow
});
await waitFor(() => {
const outputAfter = lastFrame();
const bIdxAfter = outputAfter.indexOf('] git-branch');
const wIdxAfter = outputAfter.indexOf('] workspace');
// workspace should now be after git-branch
expect(bIdxAfter).toBeLessThan(wIdxAfter);
});
});
});
@@ -5,119 +5,75 @@
*/
import type React from 'react';
import { useCallback, useMemo, useReducer } from 'react';
import { useCallback, useMemo, useReducer, useState } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { useSettingsStore } from '../contexts/SettingsContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useKeypress, type Key } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import { FooterRow, type FooterRowItem } from './Footer.js';
import { ALL_ITEMS, resolveFooterState } from '../../config/footerItems.js';
import { SettingScope } from '../../config/settings.js';
import { BaseSelectionList } from './shared/BaseSelectionList.js';
import type { SelectionListItem } from '../hooks/useSelectionList.js';
import { DialogFooter } from './shared/DialogFooter.js';
interface FooterConfigDialogProps {
onClose?: () => void;
}
interface FooterConfigItem {
key: string;
id: string;
label: string;
description?: string;
type: 'config' | 'labels-toggle' | 'reset';
}
interface FooterConfigState {
orderedIds: string[];
selectedIds: Set<string>;
activeIndex: number;
scrollOffset: number;
}
type FooterConfigAction =
| { type: 'MOVE_UP'; itemCount: number; maxToShow: number }
| { type: 'MOVE_DOWN'; itemCount: number; maxToShow: number }
| {
type: 'MOVE_LEFT';
items: Array<{ key: string }>;
}
| {
type: 'MOVE_RIGHT';
items: Array<{ key: string }>;
}
| { type: 'TOGGLE_ITEM'; items: Array<{ key: string }> }
| { type: 'SET_STATE'; payload: Partial<FooterConfigState> }
| { type: 'RESET_INDEX' };
| { type: 'MOVE_ITEM'; id: string; direction: number }
| { type: 'TOGGLE_ITEM'; id: string }
| { type: 'SET_STATE'; payload: Partial<FooterConfigState> };
function footerConfigReducer(
state: FooterConfigState,
action: FooterConfigAction,
): FooterConfigState {
switch (action.type) {
case 'MOVE_UP': {
const { itemCount, maxToShow } = action;
const totalSlots = itemCount + 2; // +1 for showLabels, +1 for reset
const newIndex =
state.activeIndex > 0 ? state.activeIndex - 1 : totalSlots - 1;
let newOffset = state.scrollOffset;
if (newIndex < itemCount) {
if (newIndex === itemCount - 1) {
newOffset = Math.max(0, itemCount - maxToShow);
} else if (newIndex < state.scrollOffset) {
newOffset = newIndex;
}
}
return { ...state, activeIndex: newIndex, scrollOffset: newOffset };
}
case 'MOVE_DOWN': {
const { itemCount, maxToShow } = action;
const totalSlots = itemCount + 2;
const newIndex =
state.activeIndex < totalSlots - 1 ? state.activeIndex + 1 : 0;
let newOffset = state.scrollOffset;
if (newIndex === 0) {
newOffset = 0;
} else if (
newIndex < itemCount &&
newIndex >= state.scrollOffset + maxToShow
case 'MOVE_ITEM': {
const currentIndex = state.orderedIds.indexOf(action.id);
const newIndex = currentIndex + action.direction;
if (
currentIndex === -1 ||
newIndex < 0 ||
newIndex >= state.orderedIds.length
) {
newOffset = newIndex - maxToShow + 1;
return state;
}
return { ...state, activeIndex: newIndex, scrollOffset: newOffset };
}
case 'MOVE_LEFT':
case 'MOVE_RIGHT': {
const direction = action.type === 'MOVE_LEFT' ? -1 : 1;
const currentItem = action.items[state.activeIndex];
if (!currentItem) return state;
const currentId = currentItem.key;
const currentIndex = state.orderedIds.indexOf(currentId);
const newIndex = currentIndex + direction;
if (newIndex < 0 || newIndex >= state.orderedIds.length) return state;
const newOrderedIds = [...state.orderedIds];
[newOrderedIds[currentIndex], newOrderedIds[newIndex]] = [
newOrderedIds[newIndex],
newOrderedIds[currentIndex],
];
return { ...state, orderedIds: newOrderedIds, activeIndex: newIndex };
return { ...state, orderedIds: newOrderedIds };
}
case 'TOGGLE_ITEM': {
const isSystemFocused = state.activeIndex >= action.items.length;
if (isSystemFocused) return state;
const item = action.items[state.activeIndex];
if (!item) return state;
const nextSelected = new Set(state.selectedIds);
if (nextSelected.has(item.key)) {
nextSelected.delete(item.key);
if (nextSelected.has(action.id)) {
nextSelected.delete(action.id);
} else {
nextSelected.add(item.key);
nextSelected.add(action.id);
}
return { ...state, selectedIds: nextSelected };
}
case 'SET_STATE':
return { ...state, ...action.payload };
case 'RESET_INDEX':
return { ...state, activeIndex: 0, scrollOffset: 0 };
default:
return state;
}
@@ -127,40 +83,54 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
onClose,
}) => {
const { settings, setSetting } = useSettingsStore();
const maxItemsToShow = 10;
const { constrainHeight, terminalHeight, staticExtraHeight } = useUIState();
const [state, dispatch] = useReducer(footerConfigReducer, undefined, () =>
resolveFooterState(settings.merged),
);
const [state, dispatch] = useReducer(footerConfigReducer, undefined, () => ({
...resolveFooterState(settings.merged),
activeIndex: 0,
scrollOffset: 0,
}));
const { orderedIds, selectedIds } = state;
const [focusKey, setFocusKey] = useState<string | undefined>(orderedIds[0]);
const { orderedIds, selectedIds, activeIndex, scrollOffset } = state;
// Prepare items
const listItems = useMemo(
() =>
orderedIds
.map((id: string) => {
const item = ALL_ITEMS.find((i) => i.id === id);
if (!item) return null;
return {
const listItems = useMemo((): Array<SelectionListItem<FooterConfigItem>> => {
const items: Array<SelectionListItem<FooterConfigItem>> = orderedIds
.map((id: string) => {
const item = ALL_ITEMS.find((i) => i.id === id);
if (!item) return null;
return {
key: id,
value: {
key: id,
id,
label: item.id,
description: item.description as string,
};
})
.filter((i): i is NonNullable<typeof i> => i !== null),
[orderedIds],
);
type: 'config' as const,
},
};
})
.filter((i): i is NonNullable<typeof i> => i !== null);
const maxLabelWidth = useMemo(
() => listItems.reduce((max, item) => Math.max(max, item.label.length), 0),
[listItems],
);
items.push({
key: 'show-labels',
value: {
key: 'show-labels',
id: 'show-labels',
label: 'Show footer labels',
type: 'labels-toggle',
},
});
const isResetFocused = activeIndex === listItems.length + 1;
const isShowLabelsFocused = activeIndex === listItems.length;
items.push({
key: 'reset',
value: {
key: 'reset',
id: 'reset',
label: 'Reset to default footer',
type: 'reset',
},
});
return items;
}, [orderedIds]);
const handleSaveAndClose = useCallback(() => {
const finalItems = orderedIds.filter((id: string) => selectedIds.has(id));
@@ -179,14 +149,9 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
const handleResetToDefaults = useCallback(() => {
setSetting(SettingScope.User, 'ui.footer.items', undefined);
dispatch({
type: 'SET_STATE',
payload: {
...resolveFooterState(settings.merged),
activeIndex: 0,
scrollOffset: 0,
},
});
const newState = resolveFooterState(settings.merged);
dispatch({ type: 'SET_STATE', payload: newState });
setFocusKey(newState.orderedIds[0]);
}, [setSetting, settings.merged]);
const handleToggleLabels = useCallback(() => {
@@ -194,6 +159,23 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
setSetting(SettingScope.User, 'ui.footer.showLabels', !current);
}, [setSetting, settings.merged.ui.footer.showLabels]);
const handleSelect = useCallback(
(item: FooterConfigItem) => {
if (item.type === 'config') {
dispatch({ type: 'TOGGLE_ITEM', id: item.id });
} else if (item.type === 'labels-toggle') {
handleToggleLabels();
} else if (item.type === 'reset') {
handleResetToDefaults();
}
},
[handleResetToDefaults, handleToggleLabels],
);
const handleHighlight = useCallback((item: FooterConfigItem) => {
setFocusKey(item.key);
}, []);
useKeypress(
(key: Key) => {
if (keyMatchers[Command.ESCAPE](key)) {
@@ -201,43 +183,18 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
return true;
}
if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) {
dispatch({
type: 'MOVE_UP',
itemCount: listItems.length,
maxToShow: maxItemsToShow,
});
return true;
}
if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) {
dispatch({
type: 'MOVE_DOWN',
itemCount: listItems.length,
maxToShow: maxItemsToShow,
});
return true;
}
if (keyMatchers[Command.MOVE_LEFT](key)) {
dispatch({ type: 'MOVE_LEFT', items: listItems });
return true;
if (focusKey && orderedIds.includes(focusKey)) {
dispatch({ type: 'MOVE_ITEM', id: focusKey, direction: -1 });
return true;
}
}
if (keyMatchers[Command.MOVE_RIGHT](key)) {
dispatch({ type: 'MOVE_RIGHT', items: listItems });
return true;
}
if (keyMatchers[Command.RETURN](key) || key.name === 'space') {
if (isResetFocused) {
handleResetToDefaults();
} else if (isShowLabelsFocused) {
handleToggleLabels();
} else {
dispatch({ type: 'TOGGLE_ITEM', items: listItems });
if (focusKey && orderedIds.includes(focusKey)) {
dispatch({ type: 'MOVE_ITEM', id: focusKey, direction: 1 });
return true;
}
return true;
}
return false;
@@ -245,17 +202,11 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
{ isActive: true, priority: true },
);
const visibleItems = listItems.slice(
scrollOffset,
scrollOffset + maxItemsToShow,
);
const activeId = listItems[activeIndex]?.key;
const showLabels = settings.merged.ui.footer.showLabels !== false;
// Preview logic
const previewContent = useMemo(() => {
if (isResetFocused) {
if (focusKey === 'reset') {
return (
<Text color={theme.ui.comment} italic>
Default footer (uses legacy settings)
@@ -269,8 +220,9 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
if (itemsToPreview.length === 0) return null;
const itemColor = showLabels ? theme.text.primary : theme.ui.comment;
const getColor = (id: string, defaultColor?: string) =>
id === activeId ? 'white' : defaultColor || itemColor;
defaultColor || itemColor;
// Mock data for preview (headers come from ALL_ITEMS)
const mockData: Record<string, React.ReactNode> = {
@@ -312,16 +264,43 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
key: id,
header: ALL_ITEMS.find((i) => i.id === id)?.header ?? id,
element: mockData[id],
flexGrow: 1,
isFocused: id === focusKey,
}));
return (
<Box overflow="hidden" flexWrap="nowrap">
<Box flexShrink={0}>
<FooterRow items={rowItems} showLabels={showLabels} />
</Box>
<Box overflow="hidden" flexWrap="nowrap" width="100%">
<FooterRow items={rowItems} showLabels={showLabels} />
</Box>
);
}, [orderedIds, selectedIds, activeId, isResetFocused, showLabels]);
}, [orderedIds, selectedIds, focusKey, showLabels]);
const availableTerminalHeight = constrainHeight
? terminalHeight - staticExtraHeight
: Number.MAX_SAFE_INTEGER;
const BORDER_HEIGHT = 2; // Outer round border
const STATIC_ELEMENTS = 13; // Text, margins, preview box, dialog footer
// Default padding adds 2 lines (top and bottom)
let includePadding = true;
if (availableTerminalHeight < BORDER_HEIGHT + 2 + STATIC_ELEMENTS + 6) {
includePadding = false;
}
const effectivePaddingY = includePadding ? 2 : 0;
const availableListSpace = Math.max(
0,
availableTerminalHeight -
BORDER_HEIGHT -
effectivePaddingY -
STATIC_ELEMENTS,
);
const maxItemsToShow = Math.max(
1,
Math.min(listItems.length, Math.floor(availableListSpace / 2)),
);
return (
<Box
@@ -329,7 +308,7 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
borderStyle="round"
borderColor={theme.border.default}
paddingX={2}
paddingY={1}
paddingY={includePadding ? 1 : 0}
width="100%"
>
<Text bold>Configure Footer{'\n'}</Text>
@@ -337,59 +316,65 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
Select which items to display in the footer.
</Text>
<Box flexDirection="column" marginTop={1} minHeight={maxItemsToShow}>
{visibleItems.length === 0 ? (
<Text color={theme.text.secondary}>No items found.</Text>
) : (
visibleItems.map((item, idx) => {
const index = scrollOffset + idx;
const isFocused = index === activeIndex;
const isChecked = selectedIds.has(item.key);
<Box flexDirection="column" marginTop={1} flexGrow={1}>
<BaseSelectionList<FooterConfigItem>
items={listItems}
onSelect={handleSelect}
onHighlight={handleHighlight}
focusKey={focusKey}
showNumbers={false}
maxItemsToShow={maxItemsToShow}
showScrollArrows={true}
selectedIndicator=">"
renderItem={(item, { isSelected, titleColor }) => {
const configItem = item.value;
const isChecked =
configItem.type === 'config'
? selectedIds.has(configItem.id)
: configItem.type === 'labels-toggle'
? showLabels
: false;
return (
<Box key={item.key} flexDirection="row">
<Text color={isFocused ? theme.status.success : undefined}>
{isFocused ? '> ' : ' '}
</Text>
<Text
color={isFocused ? theme.status.success : theme.text.primary}
>
[{isChecked ? '✓' : ' '}]{' '}
{item.label.padEnd(maxLabelWidth + 1)}
</Text>
<Text color={theme.text.secondary}> {item.description}</Text>
<Box flexDirection="column" minHeight={2}>
<Box flexDirection="row">
{configItem.type !== 'reset' && (
<Text
color={
isChecked ? theme.status.success : theme.text.secondary
}
>
[{isChecked ? '✓' : ' '}]
</Text>
)}
<Text
color={
configItem.type === 'reset' && isSelected
? theme.status.warning
: titleColor
}
>
{configItem.type !== 'reset' ? ' ' : ''}
{configItem.label}
</Text>
</Box>
{configItem.description && (
<Text color={theme.text.secondary} wrap="wrap">
{' '}
{configItem.description}
</Text>
)}
</Box>
);
})
)}
}}
/>
</Box>
<Box marginTop={1} flexDirection="column">
<Box flexDirection="row">
<Text color={isShowLabelsFocused ? theme.status.success : undefined}>
{isShowLabelsFocused ? '> ' : ' '}
</Text>
<Text color={isShowLabelsFocused ? theme.status.success : undefined}>
[{showLabels ? '✓' : ' '}] Show footer labels
</Text>
</Box>
<Box flexDirection="row">
<Text color={isResetFocused ? theme.status.warning : undefined}>
{isResetFocused ? '> ' : ' '}
</Text>
<Text
color={isResetFocused ? theme.status.warning : theme.text.secondary}
>
Reset to default footer
</Text>
</Box>
</Box>
<Box marginTop={1} flexDirection="column">
<Text color={theme.text.secondary}>
/ navigate · / reorder · enter/space select · esc close
</Text>
</Box>
<DialogFooter
primaryAction="Enter to select"
navigationActions="↑/↓ to navigate · ←/→ to reorder"
cancelAction="Esc to close"
/>
<Box
marginTop={1}
@@ -399,7 +384,9 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
flexDirection="column"
>
<Text bold>Preview:</Text>
<Box flexDirection="row">{previewContent}</Box>
<Box flexDirection="row" width="100%">
{previewContent}
</Box>
</Box>
</Box>
);
@@ -0,0 +1,48 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import { RawMarkdownIndicator } from './RawMarkdownIndicator.js';
import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
describe('RawMarkdownIndicator', () => {
const originalPlatform = process.platform;
beforeEach(() => vi.stubEnv('FORCE_GENERIC_KEYBINDING_HINTS', ''));
afterEach(() => {
Object.defineProperty(process, 'platform', {
value: originalPlatform,
});
vi.unstubAllEnvs();
});
it('renders correct key binding for darwin', async () => {
Object.defineProperty(process, 'platform', {
value: 'darwin',
});
const { lastFrame, waitUntilReady, unmount } = render(
<RawMarkdownIndicator />,
);
await waitUntilReady();
expect(lastFrame()).toContain('raw markdown mode');
expect(lastFrame()).toContain('Option+M to toggle');
unmount();
});
it('renders correct key binding for other platforms', async () => {
Object.defineProperty(process, 'platform', {
value: 'linux',
});
const { lastFrame, waitUntilReady, unmount } = render(
<RawMarkdownIndicator />,
);
await waitUntilReady();
expect(lastFrame()).toContain('raw markdown mode');
expect(lastFrame()).toContain('Alt+M to toggle');
unmount();
});
});
@@ -0,0 +1,23 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { formatCommand } from '../utils/keybindingUtils.js';
import { Command } from '../../config/keyBindings.js';
export const RawMarkdownIndicator: React.FC = () => {
const modKey = formatCommand(Command.TOGGLE_MARKDOWN);
return (
<Box>
<Text>
raw markdown mode
<Text color={theme.text.secondary}> ({modKey} to toggle) </Text>
</Text>
</Box>
);
};
@@ -28,20 +28,84 @@ describe('ToastDisplay', () => {
describe('shouldShowToast', () => {
const baseState: Partial<UIState> = {
ctrlCPressedOnce: false,
transientMessage: null,
ctrlDPressedOnce: false,
showEscapePrompt: false,
buffer: { text: '' } as TextBuffer,
history: [] as HistoryItem[],
queueErrorMessage: null,
showIsExpandableHint: false,
};
it('returns false for default state', () => {
expect(shouldShowToast(baseState as UIState)).toBe(false);
});
it('returns true when showIsExpandableHint is true', () => {
expect(
shouldShowToast({
...baseState,
showIsExpandableHint: true,
} as UIState),
).toBe(true);
});
it('returns true when ctrlCPressedOnce is true', () => {
expect(
shouldShowToast({ ...baseState, ctrlCPressedOnce: true } as UIState),
).toBe(true);
});
it('returns true when transientMessage is present', () => {
expect(
shouldShowToast({
...baseState,
transientMessage: { message: 'test', type: TransientMessageType.Hint },
transientMessage: { text: 'test', type: TransientMessageType.Hint },
} as UIState),
).toBe(true);
});
it('returns true when ctrlDPressedOnce is true', () => {
expect(
shouldShowToast({ ...baseState, ctrlDPressedOnce: true } as UIState),
).toBe(true);
});
it('returns true when showEscapePrompt is true and buffer is NOT empty', () => {
expect(
shouldShowToast({
...baseState,
showEscapePrompt: true,
buffer: { text: 'some text' } as TextBuffer,
} as UIState),
).toBe(true);
});
it('returns true when showEscapePrompt is true and history is NOT empty', () => {
expect(
shouldShowToast({
...baseState,
showEscapePrompt: true,
history: [{ id: '1' } as unknown as HistoryItem],
} as UIState),
).toBe(true);
});
it('returns false when showEscapePrompt is true but buffer and history are empty', () => {
expect(
shouldShowToast({
...baseState,
showEscapePrompt: true,
} as UIState),
).toBe(false);
});
it('returns true when queueErrorMessage is present', () => {
expect(
shouldShowToast({
...baseState,
queueErrorMessage: 'error',
} as UIState),
).toBe(true);
});
@@ -53,10 +117,18 @@ describe('ToastDisplay', () => {
expect(lastFrame({ allowEmpty: true })).toBe('');
});
it('renders Ctrl+C prompt', async () => {
const { lastFrame, waitUntilReady } = renderToastDisplay({
ctrlCPressedOnce: true,
});
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders warning message', async () => {
const { lastFrame, waitUntilReady } = renderToastDisplay({
transientMessage: {
message: 'This is a warning',
text: 'This is a warning',
type: TransientMessageType.Warning,
},
});
@@ -67,7 +139,7 @@ describe('ToastDisplay', () => {
it('renders hint message', async () => {
const { lastFrame, waitUntilReady } = renderToastDisplay({
transientMessage: {
message: 'This is a hint',
text: 'This is a hint',
type: TransientMessageType.Hint,
},
});
@@ -75,36 +147,59 @@ describe('ToastDisplay', () => {
expect(lastFrame()).toMatchSnapshot();
});
it('renders Error transient message', async () => {
it('renders Ctrl+D prompt', async () => {
const { lastFrame, waitUntilReady } = renderToastDisplay({
transientMessage: {
message: 'Error Message',
type: TransientMessageType.Error,
},
ctrlDPressedOnce: true,
});
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders Hint transient message', async () => {
it('renders Escape prompt when buffer is empty', async () => {
const { lastFrame, waitUntilReady } = renderToastDisplay({
transientMessage: {
message: 'Hint Message',
type: TransientMessageType.Hint,
},
showEscapePrompt: true,
history: [{ id: 1, type: 'user', text: 'test' }] as HistoryItem[],
});
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders Accent transient message', async () => {
it('renders Escape prompt when buffer is NOT empty', async () => {
const { lastFrame, waitUntilReady } = renderToastDisplay({
transientMessage: {
message: 'Accent Message',
type: TransientMessageType.Accent,
},
showEscapePrompt: true,
buffer: { text: 'some text' } as TextBuffer,
});
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders Queue Error Message', async () => {
const { lastFrame, waitUntilReady } = renderToastDisplay({
queueErrorMessage: 'Queue Error',
});
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders expansion hint when showIsExpandableHint is true', async () => {
const { lastFrame, waitUntilReady } = renderToastDisplay({
showIsExpandableHint: true,
constrainHeight: true,
});
await waitUntilReady();
expect(lastFrame()).toContain(
'Press Ctrl+O to show more lines of the last response',
);
});
it('renders collapse hint when showIsExpandableHint is true and constrainHeight is false', async () => {
const { lastFrame, waitUntilReady } = renderToastDisplay({
showIsExpandableHint: true,
constrainHeight: false,
});
await waitUntilReady();
expect(lastFrame()).toContain(
'Ctrl+O to collapse lines of the last response',
);
});
});
+46 -16
View File
@@ -11,45 +11,75 @@ import { useUIState, type UIState } from '../contexts/UIStateContext.js';
import { TransientMessageType } from '../../utils/events.js';
export function shouldShowToast(uiState: UIState): boolean {
return Boolean(uiState.transientMessage);
return (
uiState.ctrlCPressedOnce ||
Boolean(uiState.transientMessage) ||
uiState.ctrlDPressedOnce ||
(uiState.showEscapePrompt &&
(uiState.buffer.text.length > 0 || uiState.history.length > 0)) ||
Boolean(uiState.queueErrorMessage) ||
uiState.showIsExpandableHint
);
}
export const ToastDisplay: React.FC = () => {
const uiState = useUIState();
if (
uiState.transientMessage?.type === TransientMessageType.Warning &&
uiState.transientMessage.message
) {
if (uiState.ctrlCPressedOnce) {
return (
<Text color={theme.status.warning}>{uiState.transientMessage.message}</Text>
<Text color={theme.status.warning}>Press Ctrl+C again to exit.</Text>
);
}
if (
uiState.transientMessage?.type === TransientMessageType.Error &&
uiState.transientMessage.message
uiState.transientMessage?.type === TransientMessageType.Warning &&
uiState.transientMessage.text
) {
return (
<Text color={theme.status.error}>{uiState.transientMessage.message}</Text>
<Text color={theme.status.warning}>{uiState.transientMessage.text}</Text>
);
}
if (uiState.ctrlDPressedOnce) {
return (
<Text color={theme.status.warning}>Press Ctrl+D again to exit.</Text>
);
}
if (uiState.showEscapePrompt) {
const isPromptEmpty = uiState.buffer.text.length === 0;
const hasHistory = uiState.history.length > 0;
if (isPromptEmpty && !hasHistory) {
return null;
}
return (
<Text color={theme.text.secondary}>
Press Esc again to {isPromptEmpty ? 'rewind' : 'clear prompt'}.
</Text>
);
}
if (
uiState.transientMessage?.type === TransientMessageType.Hint &&
uiState.transientMessage.message
uiState.transientMessage.text
) {
return (
<Text color={theme.text.secondary}>{uiState.transientMessage.message}</Text>
<Text color={theme.text.secondary}>{uiState.transientMessage.text}</Text>
);
}
if (
uiState.transientMessage?.type === TransientMessageType.Accent &&
uiState.transientMessage.message
) {
if (uiState.queueErrorMessage) {
return <Text color={theme.status.error}>{uiState.queueErrorMessage}</Text>;
}
if (uiState.showIsExpandableHint) {
const action = uiState.constrainHeight ? 'show more' : 'collapse';
return (
<Text color={theme.text.accent}>{uiState.transientMessage.message}</Text>
<Text color={theme.text.accent}>
Press Ctrl+O to {action} lines of the last response
</Text>
);
}
@@ -1,26 +1,26 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<Footer /> > displays "Limit reached" message when remaining is 0 1`] = `
" workspace (/directory) sandbox /model /stats
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro limit reached
" workspace (/directory) sandbox /model /stats
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro limit reached
"
`;
exports[`<Footer /> > displays the usage indicator when usage is low 1`] = `
" workspace (/directory) sandbox /model /stats
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 85%
" workspace (/directory) sandbox /model /stats
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 85%
"
`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer in narrow terminal (baseline narrow) > complete-footer-narrow 1`] = `
" workspace (/directory) sandbox /model context
...me/more/directories/to/make/it/long no sandbox gemini-pro 14%
" workspace (/directory) sandbox /model context
...me/more/directories/to/make/it/long no sandbox gemini-pro 14%
"
`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer with all sections visible (baseline) > complete-footer-wide 1`] = `
" workspace (/directory) sandbox /model context
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 14% used
" workspace (/directory) sandbox /model context
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 14% used
"
`;
@@ -33,13 +33,13 @@ exports[`<Footer /> > footer configuration filtering (golden snapshots) > render
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with all optional sections hidden (minimal footer) > footer-minimal 1`] = `""`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with only model info hidden (partial filtering) > footer-no-model 1`] = `
" workspace (/directory) sandbox
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox
" workspace (/directory) sandbox
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox
"
`;
exports[`<Footer /> > hides the usage indicator when usage is not near limit 1`] = `
" workspace (/directory) sandbox /model /stats
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 15%
" workspace (/directory) sandbox /model /stats
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 15%
"
`;
@@ -0,0 +1,159 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="700" viewBox="0 0 920 700">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="700" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#333333" textLength="900" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────────╮</text>
<text x="0" y="19" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="19" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="36" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs" font-weight="bold">Configure Footer</text>
<text x="891" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="53" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="53" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="70" fill="#afafaf" textLength="396" lengthAdjust="spacingAndGlyphs">Select which items to display in the footer.</text>
<text x="891" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="87" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="87" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="104" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="104" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">[✓]</text>
<text x="72" y="104" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs"> workspace</text>
<text x="891" y="104" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="121" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="121" fill="#afafaf" textLength="234" lengthAdjust="spacingAndGlyphs"> Current working directory</text>
<text x="891" y="121" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="138" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="138" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">[✓]</text>
<text x="72" y="138" fill="#ffffff" textLength="99" lengthAdjust="spacingAndGlyphs"> git-branch</text>
<text x="891" y="138" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="155" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="155" fill="#afafaf" textLength="477" lengthAdjust="spacingAndGlyphs"> Current git branch name (not shown when unavailable)</text>
<text x="891" y="155" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="172" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="172" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">[✓]</text>
<text x="72" y="172" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs"> sandbox</text>
<text x="891" y="172" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="189" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="189" fill="#afafaf" textLength="297" lengthAdjust="spacingAndGlyphs"> Sandbox type and trust indicator</text>
<text x="891" y="189" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="206" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="206" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">[✓]</text>
<text x="72" y="206" fill="#ffffff" textLength="99" lengthAdjust="spacingAndGlyphs"> model-name</text>
<text x="891" y="206" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="223" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="223" fill="#afafaf" textLength="225" lengthAdjust="spacingAndGlyphs"> Current model identifier</text>
<text x="891" y="223" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="240" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="240" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">[✓]</text>
<text x="72" y="240" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs"> quota</text>
<text x="891" y="240" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="257" fill="#afafaf" textLength="540" lengthAdjust="spacingAndGlyphs"> Remaining usage on daily limit (not shown when unavailable)</text>
<text x="891" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="274" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="274" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
<text x="72" y="274" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs"> context-used</text>
<text x="891" y="274" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="291" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="291" fill="#afafaf" textLength="306" lengthAdjust="spacingAndGlyphs"> Percentage of context window used</text>
<text x="891" y="291" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="308" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="308" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
<text x="72" y="308" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs"> memory-usage</text>
<text x="891" y="308" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="325" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="325" fill="#afafaf" textLength="279" lengthAdjust="spacingAndGlyphs"> Memory used by the application</text>
<text x="891" y="325" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="342" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="342" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
<text x="72" y="342" fill="#ffffff" textLength="99" lengthAdjust="spacingAndGlyphs"> session-id</text>
<text x="891" y="342" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="359" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="359" fill="#afafaf" textLength="378" lengthAdjust="spacingAndGlyphs"> Unique identifier for the current session</text>
<text x="891" y="359" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="376" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="27" y="374" width="9" height="17" fill="#001a00" />
<text x="27" y="376" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">&gt;</text>
<rect x="36" y="374" width="9" height="17" fill="#001a00" />
<rect x="45" y="374" width="27" height="17" fill="#001a00" />
<text x="45" y="376" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">[✓]</text>
<rect x="72" y="374" width="117" height="17" fill="#001a00" />
<text x="72" y="376" fill="#00cd00" textLength="117" lengthAdjust="spacingAndGlyphs"> code-changes</text>
<rect x="189" y="374" width="684" height="17" fill="#001a00" />
<text x="891" y="376" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="393" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="27" y="391" width="18" height="17" fill="#001a00" />
<rect x="45" y="391" width="513" height="17" fill="#001a00" />
<text x="45" y="393" fill="#afafaf" textLength="513" lengthAdjust="spacingAndGlyphs"> Lines added/removed in the session (not shown when zero)</text>
<rect x="558" y="391" width="315" height="17" fill="#001a00" />
<text x="891" y="393" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="410" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="410" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
<text x="72" y="410" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs"> token-count</text>
<text x="891" y="410" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="427" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="427" fill="#afafaf" textLength="495" lengthAdjust="spacingAndGlyphs"> Total tokens used in the session (not shown when zero)</text>
<text x="891" y="427" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="444" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="444" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">[✓]</text>
<text x="72" y="444" fill="#ffffff" textLength="171" lengthAdjust="spacingAndGlyphs"> Show footer labels</text>
<text x="891" y="444" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="461" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="478" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Reset to default footer</text>
<text x="891" y="478" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="495" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="512" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="529" fill="#afafaf" textLength="585" lengthAdjust="spacingAndGlyphs">Enter to select · ↑/↓ to navigate · ←/→ to reorder · Esc to close</text>
<text x="891" y="529" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="546" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="563" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="563" fill="#333333" textLength="846" lengthAdjust="spacingAndGlyphs">┌────────────────────────────────────────────────────────────────────────────────────────────┐</text>
<text x="891" y="563" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="580" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="580" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="580" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs" font-weight="bold">Preview:</text>
<text x="864" y="580" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="580" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="597" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="597" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="597" fill="#afafaf" textLength="198" lengthAdjust="spacingAndGlyphs">workspace (/directory)</text>
<text x="288" y="597" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">branch</text>
<text x="396" y="597" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs">sandbox</text>
<text x="504" y="597" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/model</text>
<text x="675" y="597" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/stats</text>
<rect x="783" y="595" width="36" height="17" fill="#001a00" />
<text x="783" y="597" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">diff</text>
<rect x="819" y="595" width="36" height="17" fill="#001a00" />
<text x="864" y="597" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="597" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="614" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="614" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="614" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">~/project/path</text>
<text x="288" y="614" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">main</text>
<text x="396" y="614" fill="#00cd00" textLength="54" lengthAdjust="spacingAndGlyphs">docker</text>
<text x="504" y="614" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">gemini-2.5-pro</text>
<text x="675" y="614" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">97%</text>
<rect x="783" y="612" width="27" height="17" fill="#001a00" />
<text x="783" y="614" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">+12</text>
<rect x="810" y="612" width="9" height="17" fill="#001a00" />
<rect x="819" y="612" width="18" height="17" fill="#001a00" />
<text x="819" y="614" fill="#ff87af" textLength="18" lengthAdjust="spacingAndGlyphs">-4</text>
<rect x="837" y="612" width="18" height="17" fill="#001a00" />
<text x="864" y="614" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="614" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="631" fill="#333333" textLength="846" lengthAdjust="spacingAndGlyphs">└────────────────────────────────────────────────────────────────────────────────────────────┘</text>
<text x="891" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="665" fill="#333333" textLength="900" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────────╯</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 16 KiB

@@ -0,0 +1,154 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="700" viewBox="0 0 920 700">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="700" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#333333" textLength="900" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────────╮</text>
<text x="0" y="19" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="19" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="36" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs" font-weight="bold">Configure Footer</text>
<text x="891" y="36" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="53" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="53" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="70" fill="#afafaf" textLength="396" lengthAdjust="spacingAndGlyphs">Select which items to display in the footer.</text>
<text x="891" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="87" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="87" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="104" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="27" y="102" width="9" height="17" fill="#001a00" />
<text x="27" y="104" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">&gt;</text>
<rect x="36" y="102" width="9" height="17" fill="#001a00" />
<rect x="45" y="102" width="27" height="17" fill="#001a00" />
<text x="45" y="104" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">[✓]</text>
<rect x="72" y="102" width="90" height="17" fill="#001a00" />
<text x="72" y="104" fill="#00cd00" textLength="90" lengthAdjust="spacingAndGlyphs"> workspace</text>
<rect x="162" y="102" width="711" height="17" fill="#001a00" />
<text x="891" y="104" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="121" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="27" y="119" width="18" height="17" fill="#001a00" />
<rect x="45" y="119" width="234" height="17" fill="#001a00" />
<text x="45" y="121" fill="#afafaf" textLength="234" lengthAdjust="spacingAndGlyphs"> Current working directory</text>
<rect x="279" y="119" width="594" height="17" fill="#001a00" />
<text x="891" y="121" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="138" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="138" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">[✓]</text>
<text x="72" y="138" fill="#ffffff" textLength="99" lengthAdjust="spacingAndGlyphs"> git-branch</text>
<text x="891" y="138" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="155" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="155" fill="#afafaf" textLength="477" lengthAdjust="spacingAndGlyphs"> Current git branch name (not shown when unavailable)</text>
<text x="891" y="155" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="172" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="172" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">[✓]</text>
<text x="72" y="172" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs"> sandbox</text>
<text x="891" y="172" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="189" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="189" fill="#afafaf" textLength="297" lengthAdjust="spacingAndGlyphs"> Sandbox type and trust indicator</text>
<text x="891" y="189" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="206" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="206" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">[✓]</text>
<text x="72" y="206" fill="#ffffff" textLength="99" lengthAdjust="spacingAndGlyphs"> model-name</text>
<text x="891" y="206" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="223" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="223" fill="#afafaf" textLength="225" lengthAdjust="spacingAndGlyphs"> Current model identifier</text>
<text x="891" y="223" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="240" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="240" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">[✓]</text>
<text x="72" y="240" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs"> quota</text>
<text x="891" y="240" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="257" fill="#afafaf" textLength="540" lengthAdjust="spacingAndGlyphs"> Remaining usage on daily limit (not shown when unavailable)</text>
<text x="891" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="274" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="274" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
<text x="72" y="274" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs"> context-used</text>
<text x="891" y="274" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="291" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="291" fill="#afafaf" textLength="306" lengthAdjust="spacingAndGlyphs"> Percentage of context window used</text>
<text x="891" y="291" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="308" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="308" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
<text x="72" y="308" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs"> memory-usage</text>
<text x="891" y="308" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="325" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="325" fill="#afafaf" textLength="279" lengthAdjust="spacingAndGlyphs"> Memory used by the application</text>
<text x="891" y="325" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="342" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="342" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
<text x="72" y="342" fill="#ffffff" textLength="99" lengthAdjust="spacingAndGlyphs"> session-id</text>
<text x="891" y="342" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="359" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="359" fill="#afafaf" textLength="378" lengthAdjust="spacingAndGlyphs"> Unique identifier for the current session</text>
<text x="891" y="359" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="376" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="376" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
<text x="72" y="376" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs"> code-changes</text>
<text x="891" y="376" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="393" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="393" fill="#afafaf" textLength="513" lengthAdjust="spacingAndGlyphs"> Lines added/removed in the session (not shown when zero)</text>
<text x="891" y="393" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="410" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="410" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
<text x="72" y="410" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs"> token-count</text>
<text x="891" y="410" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="427" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="427" fill="#afafaf" textLength="495" lengthAdjust="spacingAndGlyphs"> Total tokens used in the session (not shown when zero)</text>
<text x="891" y="427" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="444" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="444" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">[✓]</text>
<text x="72" y="444" fill="#ffffff" textLength="171" lengthAdjust="spacingAndGlyphs"> Show footer labels</text>
<text x="891" y="444" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="461" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="478" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Reset to default footer</text>
<text x="891" y="478" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="495" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="512" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="529" fill="#afafaf" textLength="585" lengthAdjust="spacingAndGlyphs">Enter to select · ↑/↓ to navigate · ←/→ to reorder · Esc to close</text>
<text x="891" y="529" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="546" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="563" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="563" fill="#333333" textLength="846" lengthAdjust="spacingAndGlyphs">┌────────────────────────────────────────────────────────────────────────────────────────────┐</text>
<text x="891" y="563" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="580" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="580" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="580" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs" font-weight="bold">Preview:</text>
<text x="864" y="580" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="580" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="597" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="597" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="45" y="595" width="198" height="17" fill="#001a00" />
<text x="45" y="597" fill="#ffffff" textLength="198" lengthAdjust="spacingAndGlyphs">workspace (/directory)</text>
<rect x="243" y="595" width="45" height="17" fill="#001a00" />
<text x="315" y="597" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">branch</text>
<text x="432" y="597" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs">sandbox</text>
<text x="567" y="597" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/model</text>
<text x="756" y="597" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/stats</text>
<text x="864" y="597" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="597" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="614" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="614" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="45" y="612" width="126" height="17" fill="#001a00" />
<text x="45" y="614" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">~/project/path</text>
<rect x="171" y="612" width="117" height="17" fill="#001a00" />
<text x="315" y="614" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">main</text>
<text x="432" y="614" fill="#00cd00" textLength="54" lengthAdjust="spacingAndGlyphs">docker</text>
<text x="567" y="614" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">gemini-2.5-pro</text>
<text x="756" y="614" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">97%</text>
<text x="864" y="614" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="614" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="631" fill="#333333" textLength="846" lengthAdjust="spacingAndGlyphs">└────────────────────────────────────────────────────────────────────────────────────────────┘</text>
<text x="891" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="665" fill="#333333" textLength="900" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────────╯</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 16 KiB

@@ -1,5 +1,48 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<FooterConfigDialog /> > highlights the active item in the preview 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Configure Footer │
│ │
│ Select which items to display in the footer. │
│ │
│ [✓] workspace │
│ Current working directory │
│ [✓] git-branch │
│ Current git branch name (not shown when unavailable) │
│ [✓] sandbox │
│ Sandbox type and trust indicator │
│ [✓] model-name │
│ Current model identifier │
│ [✓] quota │
│ Remaining usage on daily limit (not shown when unavailable) │
│ [ ] context-used │
│ Percentage of context window used │
│ [ ] memory-usage │
│ Memory used by the application │
│ [ ] session-id │
│ Unique identifier for the current session │
│ > [✓] code-changes │
│ Lines added/removed in the session (not shown when zero) │
│ [ ] token-count │
│ Total tokens used in the session (not shown when zero) │
│ [✓] Show footer labels │
│ │
│ Reset to default footer │
│ │
│ │
│ Enter to select · ↑/↓ to navigate · ←/→ to reorder · Esc to close │
│ │
│ ┌────────────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ Preview: │ │
│ │ workspace (/directory) branch sandbox /model /stats diff │ │
│ │ ~/project/path main docker gemini-2.5-pro 97% +12 -4 │ │
│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<FooterConfigDialog /> > renders correctly with default settings 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
@@ -7,28 +50,82 @@ exports[`<FooterConfigDialog /> > renders correctly with default settings 1`] =
│ │
│ Select which items to display in the footer. │
│ │
│ > [✓] workspace Current working directory
[✓] git-branch Current git branch name (not shown when unavailable)
│ [✓] sandbox Sandbox type and trust indicator
[✓] model-name Current model identifier
│ [✓] quota Remaining usage on daily limit (not shown when unavailable)
[ ] context-used Percentage of context window used
│ [ ] memory-usage Memory used by the application
[ ] session-id Unique identifier for the current session
│ [ ] code-changes Lines added/removed in the session (not shown when zero)
[ ] token-count Total tokens used in the session (not shown when zero)
│ > [✓] workspace
Current working directory
│ [✓] git-branch
Current git branch name (not shown when unavailable)
│ [✓] sandbox
Sandbox type and trust indicator
│ [] model-name
Current model identifier
│ [] quota
Remaining usage on daily limit (not shown when unavailable)
[ ] context-used
│ Percentage of context window used │
│ [ ] memory-usage │
│ Memory used by the application │
│ [ ] session-id │
│ Unique identifier for the current session │
│ [ ] code-changes │
│ Lines added/removed in the session (not shown when zero) │
│ [ ] token-count │
│ Total tokens used in the session (not shown when zero) │
│ [✓] Show footer labels │
│ │
│ Reset to default footer │
│ │
↑/↓ navigate · ←/→ reorder · enter/space select · esc close
│ Enter to select · ↑/↓ to navigate · ←/→ to reorder · Esc to close │
│ │
│ ┌────────────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ Preview: │ │
│ │ workspace (/directory) branch sandbox /model /stats │ │
│ │ ~/project/path main docker gemini-2.5-pro 97% │ │
│ │ workspace (/directory) branch sandbox /model /stats │ │
│ │ ~/project/path main docker gemini-2.5-pro 97% │ │
│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`<FooterConfigDialog /> > renders correctly with default settings 2`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Configure Footer │
│ │
│ Select which items to display in the footer. │
│ │
│ > [✓] workspace │
│ Current working directory │
│ [✓] git-branch │
│ Current git branch name (not shown when unavailable) │
│ [✓] sandbox │
│ Sandbox type and trust indicator │
│ [✓] model-name │
│ Current model identifier │
│ [✓] quota │
│ Remaining usage on daily limit (not shown when unavailable) │
│ [ ] context-used │
│ Percentage of context window used │
│ [ ] memory-usage │
│ Memory used by the application │
│ [ ] session-id │
│ Unique identifier for the current session │
│ [ ] code-changes │
│ Lines added/removed in the session (not shown when zero) │
│ [ ] token-count │
│ Total tokens used in the session (not shown when zero) │
│ [✓] Show footer labels │
│ │
│ Reset to default footer │
│ │
│ │
│ Enter to select · ↑/↓ to navigate · ←/→ to reorder · Esc to close │
│ │
│ ┌────────────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ Preview: │ │
│ │ workspace (/directory) branch sandbox /model /stats │ │
│ │ ~/project/path main docker gemini-2.5-pro 97% │ │
│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -1,17 +1,27 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ToastDisplay > renders Accent transient message 1`] = `
"Accent Message
exports[`ToastDisplay > renders Ctrl+C prompt 1`] = `
"Press Ctrl+C again to exit.
"
`;
exports[`ToastDisplay > renders Error transient message 1`] = `
"Error Message
exports[`ToastDisplay > renders Ctrl+D prompt 1`] = `
"Press Ctrl+D again to exit.
"
`;
exports[`ToastDisplay > renders Hint transient message 1`] = `
"Hint Message
exports[`ToastDisplay > renders Escape prompt when buffer is NOT empty 1`] = `
"Press Esc again to clear prompt.
"
`;
exports[`ToastDisplay > renders Escape prompt when buffer is empty 1`] = `
"Press Esc again to rewind.
"
`;
exports[`ToastDisplay > renders Queue Error Message 1`] = `
"Queue Error
"
`;
@@ -33,6 +33,7 @@ export interface BaseSelectionListProps<
wrapAround?: boolean;
focusKey?: string;
priority?: boolean;
selectedIndicator?: string;
renderItem: (item: TItem, context: RenderItemContext) => React.ReactNode;
}
@@ -65,6 +66,7 @@ export function BaseSelectionList<
wrapAround = true,
focusKey,
priority,
selectedIndicator = '●',
renderItem,
}: BaseSelectionListProps<T, TItem>): React.JSX.Element {
const { activeIndex } = useSelectionList({
@@ -148,7 +150,7 @@ export function BaseSelectionList<
color={isSelected ? theme.ui.focus : theme.text.primary}
aria-hidden
>
{isSelected ? '●' : ' '}
{isSelected ? selectedIndicator : ' '}
</Text>
</Box>
@@ -55,6 +55,7 @@ export interface UIActions {
handleFolderTrustSelect: (choice: FolderTrustChoice) => void;
setIsPolicyUpdateDialogOpen: (value: boolean) => void;
setConstrainHeight: (value: boolean) => void;
onEscapePromptChange: (show: boolean) => void;
refreshStatic: () => void;
handleFinalSubmit: (value: string) => Promise<void>;
handleClearScreen: () => void;
@@ -5,7 +5,6 @@
*/
import { createContext, useContext } from 'react';
import { type TransientMessageType } from '../../utils/events.js';
import type {
HistoryItem,
ThoughtSummary,
@@ -32,6 +31,7 @@ import type {
FolderDiscoveryResults,
PolicyUpdateConfirmationRequest,
} from '@google/gemini-cli-core';
import { type TransientMessageType } from '../../utils/events.js';
import type { DOMElement } from 'ink';
import type { SessionStatsState } from '../contexts/SessionContext.js';
import type { ExtensionUpdateState } from '../state/extensions.js';
@@ -161,6 +161,9 @@ export interface UIState {
filteredConsoleMessages: ConsoleMessageItem[];
ideContextState: IdeContext | undefined;
renderMarkdown: boolean;
ctrlCPressedOnce: boolean;
ctrlDPressedOnce: boolean;
showEscapePrompt: boolean;
shortcutsHelpVisible: boolean;
cleanUiDetailsVisible: boolean;
elapsedTime: number;
@@ -168,6 +171,7 @@ export interface UIState {
historyRemountKey: number;
activeHooks: ActiveHook[];
messageQueue: string[];
queueErrorMessage: string | null;
showApprovalModeIndicator: ApprovalMode;
allowPlanMode: boolean;
// Quota-related state
@@ -216,10 +220,11 @@ export interface UIState {
isBackgroundShellListOpen: boolean;
adminSettingsChanged: boolean;
newAgents: AgentDefinition[] | null;
showIsExpandableHint: boolean;
hintMode: boolean;
hintBuffer: string;
transientMessage: {
message: string;
text: string;
type: TransientMessageType;
} | null;
}
@@ -4,11 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useLayoutEffect, type RefObject } from 'react';
import { useConfig } from '../contexts/ConfigContext.js';
import type { Config } from '@google/gemini-cli-core';
import { type DOMElement, measureElement } from 'ink';
import { useTerminalSize } from './useTerminalSize.js';
export const isAlternateBufferEnabled = (config: Config): boolean =>
config.getUseAlternateBuffer();
@@ -18,28 +15,3 @@ export const useAlternateBuffer = (): boolean => {
const config = useConfig();
return isAlternateBufferEnabled(config);
};
export const useLegacyNonAlternateBufferMode = (
rootUiRef: RefObject<DOMElement | null>,
isAlternateBuffer: boolean,
): boolean => {
const { rows: terminalHeight } = useTerminalSize();
const [isOverflowing, setIsOverflowing] = useState(false);
useLayoutEffect(() => {
if (isAlternateBuffer || !rootUiRef.current) {
if (isOverflowing) setIsOverflowing(false);
return;
}
const measurement = measureElement(rootUiRef.current);
// If the interactive UI is taller than the terminal height, we have a problem.
const currentlyOverflowing = measurement.height >= terminalHeight;
if (currentlyOverflowing !== isOverflowing) {
setIsOverflowing(currentlyOverflowing);
}
}, [isAlternateBuffer, rootUiRef, terminalHeight, isOverflowing]);
return !isAlternateBuffer && isOverflowing;
};
@@ -81,6 +81,8 @@ describe('useSelectionList', () => {
isFocused?: boolean;
showNumbers?: boolean;
wrapAround?: boolean;
focusKey?: string;
priority?: boolean;
}) => {
let hookResult: ReturnType<typeof useSelectionList>;
function TestComponent(props: typeof initialProps) {
@@ -771,6 +773,67 @@ describe('useSelectionList', () => {
});
});
describe('Programmatic Focus (focusKey)', () => {
it('should change the activeIndex when a valid focusKey is provided', async () => {
const { result, rerender, waitUntilReady } =
await renderSelectionListHook({
items,
onSelect: mockOnSelect,
});
expect(result.current.activeIndex).toBe(0);
await rerender({ focusKey: 'C' });
await waitUntilReady();
expect(result.current.activeIndex).toBe(2);
});
it('should ignore a focusKey that does not exist', async () => {
const { result, rerender, waitUntilReady } =
await renderSelectionListHook({
items,
onSelect: mockOnSelect,
});
expect(result.current.activeIndex).toBe(0);
await rerender({ focusKey: 'UNKNOWN' });
await waitUntilReady();
expect(result.current.activeIndex).toBe(0);
});
it('should ignore a focusKey that points to a disabled item', async () => {
const { result, rerender, waitUntilReady } =
await renderSelectionListHook({
items, // B is disabled
onSelect: mockOnSelect,
});
expect(result.current.activeIndex).toBe(0);
await rerender({ focusKey: 'B' });
await waitUntilReady();
expect(result.current.activeIndex).toBe(0);
});
it('should handle clearing the focusKey', async () => {
const { result, rerender, waitUntilReady } =
await renderSelectionListHook({
items,
onSelect: mockOnSelect,
focusKey: 'C',
});
expect(result.current.activeIndex).toBe(2);
await rerender({ focusKey: undefined });
await waitUntilReady();
// Should remain at 2
expect(result.current.activeIndex).toBe(2);
// We can then change it again to something else
await rerender({ focusKey: 'D' });
await waitUntilReady();
expect(result.current.activeIndex).toBe(3);
});
});
describe('Reactivity (Dynamic Updates)', () => {
it('should update activeIndex when initialIndex prop changes', async () => {
const { result, rerender } = await renderSelectionListHook({
@@ -213,8 +213,7 @@ function selectionListReducer(
case 'INITIALIZE': {
const { initialIndex, items, wrapAround } = action.payload;
const activeKey =
initialIndex === state.initialIndex &&
state.activeIndex !== state.initialIndex
initialIndex === state.initialIndex
? state.items[state.activeIndex]?.key
: undefined;
+21 -26
View File
@@ -7,41 +7,36 @@
import { useState, useCallback, useRef, useEffect } from 'react';
/**
* A hook to manage a state value that automatically resets to null after a specified duration.
* Provides a function to manually set the value and reset the timer.
* Can be paused to prevent the timer from expiring.
* A hook to manage a state value that automatically resets to null after a duration.
* Useful for transient UI messages, hints, or warnings.
*/
export function useTimedMessage<T>(
defaultDurationMs: number,
isPaused: boolean = false,
) {
export function useTimedMessage<T>(durationMs: number) {
const [message, setMessage] = useState<T | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const currentDurationRef = useRef<number>(defaultDurationMs);
const startTimer = useCallback(() => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
if (!isPaused && message !== null) {
timeoutRef.current = setTimeout(() => {
setMessage(null);
}, currentDurationRef.current);
}
}, [isPaused, message]);
const showMessage = useCallback(
(msg: T | null, durationMs?: number) => {
(msg: T | null) => {
setMessage(msg);
currentDurationRef.current = durationMs ?? defaultDurationMs;
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
if (msg !== null) {
timeoutRef.current = setTimeout(() => {
setMessage(null);
}, durationMs);
}
},
[defaultDurationMs],
[durationMs],
);
useEffect(() => {
startTimer();
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, [startTimer]);
useEffect(
() => () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
},
[],
);
return [message, showMessage] as const;
}
-3
View File
@@ -9,14 +9,11 @@ import { EventEmitter } from 'node:events';
export enum TransientMessageType {
Warning = 'warning',
Hint = 'hint',
Error = 'error',
Accent = 'accent',
}
export interface TransientMessagePayload {
message: string;
type: TransientMessageType;
durationMs?: number;
}
export enum AppEvent {
+9 -4
View File
@@ -214,6 +214,9 @@ Use the following guidelines to optimize your search and read patterns.
- **Navigating:** read the minimum required to not require additional turns spent reading the file.
</examples>
## Shell Usage
- **Non-Interactive Mandate:** You MUST ALWAYS use non-interactive flags (e.g., \`--yes\`, \`-y\`, \`--no-interactive\`) with any command that might prompt for user input or installation confirmation. This is especially CRITICAL for \`npm create\`, \`npm init\`, and \`npx\`. **Placement is key:** For \`npm create\` and \`npm init\`, you MUST place the non-interactive flag (\`--yes\`) BEFORE any \`--\` argument separator, AND you MUST include tool-specific non-interactive flags like \`--no-interactive\` AFTER the separator to avoid modern prompts (e.g., \`npm create vite@latest --yes -- --template react-ts --no-interactive\`). Failure to include BOTH flags correctly will cause the environment to hang indefinitely.
## Engineering Standards
- **Contextual Precedence:** Instructions found in ${formattedFilenames} files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
@@ -688,7 +691,7 @@ function newApplicationSteps(options: PrimaryWorkflowsOptions): string {
- **Mobile:** Compose Multiplatform or Flutter.
- **Games:** HTML/CSS/JS (Three.js for 3D).
- **CLIs:** Python or Go.
3. **Implementation:** Autonomously implement each feature per the approved plan. When starting, scaffold the application using ${formatToolName(SHELL_TOOL_NAME)}. For interactive scaffolding tools (like create-react-app, create-vite, or npm create), you MUST use the corresponding non-interactive flag (e.g. '--yes', '-y', or specific template flags) to prevent the environment from hanging waiting for user input. For visual assets, utilize **platform-native primitives** (e.g., stylized shapes, gradients, icons). Never link to external services or assume local paths for assets that have not been created.
3. **Implementation:** Autonomously implement each feature per the approved plan. When starting, scaffold the application using ${formatToolName(SHELL_TOOL_NAME)}. For interactive scaffolding tools (like create-react-app, create-vite, or npm create), you MUST use BOTH the non-interactive installation flag (\`--yes\` or \`-y\`) AND any tool-specific non-interactive flags (like \`--no-interactive\`). **Place \`--yes\` BEFORE the \`--\` separator** and any tool flags like \`--no-interactive\` AFTER it (e.g. \`npm create vite@latest --yes -- --template react-ts --no-interactive\`) to prevent the environment from hanging waiting for user input. For visual assets, utilize **platform-native primitives** (e.g., stylized shapes, gradients, icons). Never link to external services or assume local paths for assets that have not been created.
4. **Verify:** Review work against the original request. Fix bugs and deviations. **Build the application and ensure there are no compile errors.**`.trim();
}
@@ -698,15 +701,17 @@ function toolUsageInteractive(
): string {
if (interactive) {
const focusHint = interactiveShellEnabled
? ' If you choose to execute an interactive command consider letting the user know they can press `tab` to focus into the shell to provide input.'
? ' If you choose to execute an interactive command consider letting the user know they can press `ctrl + f` to focus into the shell to provide input.'
: '';
return `
- **Background Processes:** To run a command in the background, set the \`${SHELL_PARAM_IS_BACKGROUND}\` parameter to true. If unsure, ask the user.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim).${focusHint}`;
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim).${focusHint}
- **Proactive Non-Interaction:** Always use non-interactive flags (e.g., \`--yes\` or \`-y\`) with tools that might prompt for confirmation, such as \`npm create\`, \`npm init\`, or \`npx\`. **CRITICAL:** Failure to use \`--yes\` with these tools will cause the environment to hang indefinitely. Do not rely on \`--help\` as even help commands can prompt for installation.`;
}
return `
- **Background Processes:** To run a command in the background, set the \`${SHELL_PARAM_IS_BACKGROUND}\` parameter to true.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim).`;
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim).
- **Proactive Non-Interaction:** Always use non-interactive flags (e.g., \`--yes\` or \`-y\`) with tools that might prompt for confirmation, such as \`npm create\`, \`npm init\`, or \`npx\`. **CRITICAL:** Failure to use \`--yes\` with these tools will cause the environment to hang indefinitely. Do not rely on \`--help\` as even help commands can prompt for installation.`;
}
function toolUsageRememberingFacts(
+1 -1
View File
@@ -557,7 +557,7 @@ export class TestRig {
return {
...cleanEnv,
GEMINI_CLI_HOME: this.homeDir!,
GEMINI_PTY_INFO: 'child_process',
GEMINI_PTY_INFO: 'node-pty',
...extraEnv,
};
}