mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-29 23:41:29 -07:00
feat(ui): implement refreshed UX for Composer layout
- Promotes refreshed multi-row status area and footer as the default experience. - Stabilizes Composer row heights to prevent layout 'jitter' during typing and model turns. - Unifies active hook status and model loading indicators into a single, stable Row 1. - Refactors settings to use backward-compatible 'Hide' booleans (ui.hideStatusTips, ui.hideStatusWit). - Removes vestigial context usage bleed-through logic in minimal mode to align with global UX direction. - Relocates toast notifications to the top status row for improved visibility. - Updates all CLI UI snapshots and architectural tests to reflect the stabilized layout.
This commit is contained in:
@@ -2196,23 +2196,88 @@ describe('Settings Loading and Merging', () => {
|
||||
SettingScope.User,
|
||||
'ui',
|
||||
expect.objectContaining({
|
||||
accessibility: expect.objectContaining({
|
||||
enableLoadingPhrases: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Check that enableLoadingPhrases: false was further migrated to loadingPhrases: 'off'
|
||||
expect(setValueSpy).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'ui',
|
||||
expect.objectContaining({
|
||||
loadingPhrases: 'off',
|
||||
accessibility: {},
|
||||
hideStatusTips: true,
|
||||
hideStatusWit: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should migrate enableLoadingPhrases: false to loadingPhrases: off', () => {
|
||||
it('should migrate hideIntroTips to hideTips', () => {
|
||||
const userSettingsContent = {
|
||||
ui: {
|
||||
hideIntroTips: true,
|
||||
},
|
||||
};
|
||||
|
||||
const loadedSettings = createMockSettings(userSettingsContent);
|
||||
const setValueSpy = vi.spyOn(loadedSettings, 'setValue');
|
||||
|
||||
migrateDeprecatedSettings(loadedSettings);
|
||||
|
||||
expect(setValueSpy).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'ui',
|
||||
expect.objectContaining({
|
||||
hideTips: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ input: 'all', expectedHideTips: false, expectedHideWit: false },
|
||||
{ input: 'tips', expectedHideTips: false, expectedHideWit: true },
|
||||
{ input: 'witty', expectedHideTips: true, expectedHideWit: false },
|
||||
{ input: 'off', expectedHideTips: true, expectedHideWit: true },
|
||||
])(
|
||||
'should migrate statusHints $input to hideStatusTips: $expectedHideTips, hideStatusWit: $expectedHideWit',
|
||||
({ input, expectedHideTips, expectedHideWit }) => {
|
||||
const userSettingsContent = {
|
||||
ui: {
|
||||
statusHints: input,
|
||||
},
|
||||
};
|
||||
|
||||
const loadedSettings = createMockSettings(userSettingsContent);
|
||||
const setValueSpy = vi.spyOn(loadedSettings, 'setValue');
|
||||
|
||||
migrateDeprecatedSettings(loadedSettings);
|
||||
|
||||
expect(setValueSpy).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'ui',
|
||||
expect.objectContaining({
|
||||
hideStatusTips: expectedHideTips,
|
||||
hideStatusWit: expectedHideWit,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('should migrate showStatusTips/showStatusWit to hideStatusTips/hideStatusWit', () => {
|
||||
const userSettingsContent = {
|
||||
ui: {
|
||||
showStatusTips: true,
|
||||
showStatusWit: false,
|
||||
},
|
||||
};
|
||||
|
||||
const loadedSettings = createMockSettings(userSettingsContent);
|
||||
const setValueSpy = vi.spyOn(loadedSettings, 'setValue');
|
||||
|
||||
migrateDeprecatedSettings(loadedSettings);
|
||||
|
||||
expect(setValueSpy).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'ui',
|
||||
expect.objectContaining({
|
||||
hideStatusTips: false,
|
||||
hideStatusWit: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should migrate enableLoadingPhrases: false to hideStatusTips/hideStatusWit: true', () => {
|
||||
const userSettingsContent = {
|
||||
ui: {
|
||||
accessibility: {
|
||||
@@ -2230,12 +2295,13 @@ describe('Settings Loading and Merging', () => {
|
||||
SettingScope.User,
|
||||
'ui',
|
||||
expect.objectContaining({
|
||||
loadingPhrases: 'off',
|
||||
hideStatusTips: true,
|
||||
hideStatusWit: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not migrate enableLoadingPhrases: true to loadingPhrases', () => {
|
||||
it('should not migrate enableLoadingPhrases: true to hideStatusTips/hideStatusWit', () => {
|
||||
const userSettingsContent = {
|
||||
ui: {
|
||||
accessibility: {
|
||||
@@ -2249,18 +2315,20 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
migrateDeprecatedSettings(loadedSettings);
|
||||
|
||||
// Should not set loadingPhrases when enableLoadingPhrases is true
|
||||
// Should not set hideStatusTips/hideStatusWit when enableLoadingPhrases is true
|
||||
const uiCalls = setValueSpy.mock.calls.filter((call) => call[1] === 'ui');
|
||||
for (const call of uiCalls) {
|
||||
const uiValue = call[2] as Record<string, unknown>;
|
||||
expect(uiValue).not.toHaveProperty('loadingPhrases');
|
||||
expect(uiValue).not.toHaveProperty('hideStatusTips');
|
||||
expect(uiValue).not.toHaveProperty('hideStatusWit');
|
||||
}
|
||||
});
|
||||
|
||||
it('should not overwrite existing loadingPhrases during migration', () => {
|
||||
it('should not overwrite existing hideStatusTips/hideStatusWit during migration', () => {
|
||||
const userSettingsContent = {
|
||||
ui: {
|
||||
loadingPhrases: 'witty',
|
||||
hideStatusTips: false,
|
||||
hideStatusWit: false,
|
||||
accessibility: {
|
||||
enableLoadingPhrases: false,
|
||||
},
|
||||
@@ -2272,12 +2340,15 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
migrateDeprecatedSettings(loadedSettings);
|
||||
|
||||
// Should not overwrite existing loadingPhrases
|
||||
// Should not overwrite existing hideStatusTips/hideStatusWit
|
||||
const uiCalls = setValueSpy.mock.calls.filter((call) => call[1] === 'ui');
|
||||
for (const call of uiCalls) {
|
||||
const uiValue = call[2] as Record<string, unknown>;
|
||||
if (uiValue['loadingPhrases'] !== undefined) {
|
||||
expect(uiValue['loadingPhrases']).toBe('witty');
|
||||
if (uiValue['hideStatusTips'] !== undefined) {
|
||||
expect(uiValue['hideStatusTips']).toBe(false);
|
||||
}
|
||||
if (uiValue['hideStatusWit'] !== undefined) {
|
||||
expect(uiValue['hideStatusWit']).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -167,10 +167,10 @@ export interface SummarizeToolOutputSettings {
|
||||
tokenBudget?: number;
|
||||
}
|
||||
|
||||
export type LoadingPhrasesMode = 'tips' | 'witty' | 'all' | 'off';
|
||||
export type StatusHintsMode = 'tips' | 'witty' | 'all' | 'off';
|
||||
|
||||
export interface AccessibilitySettings {
|
||||
/** @deprecated Use ui.loadingPhrases instead. */
|
||||
/** @deprecated Use ui.statusHints instead. */
|
||||
enableLoadingPhrases?: boolean;
|
||||
screenReader?: boolean;
|
||||
}
|
||||
@@ -847,11 +847,11 @@ export function migrateDeprecatedSettings(
|
||||
const oldValue = settings[oldKey];
|
||||
const newValue = settings[newKey];
|
||||
|
||||
if (typeof oldValue === 'boolean') {
|
||||
if (oldValue === true || oldValue === false) {
|
||||
if (foundDeprecated) {
|
||||
foundDeprecated.push(prefix ? `${prefix}.${oldKey}` : oldKey);
|
||||
}
|
||||
if (typeof newValue === 'boolean') {
|
||||
if (newValue === true || newValue === false) {
|
||||
// Both exist, trust the new one
|
||||
if (removeDeprecated) {
|
||||
delete settings[oldKey];
|
||||
@@ -911,6 +911,91 @@ export function migrateDeprecatedSettings(
|
||||
const uiSettings = settings.ui as Record<string, unknown> | undefined;
|
||||
if (uiSettings) {
|
||||
const newUi = { ...uiSettings };
|
||||
let uiModified = false;
|
||||
|
||||
// Migrate hideIntroTips → hideTips (backward compatibility)
|
||||
if (newUi['hideIntroTips'] === true || newUi['hideIntroTips'] === false) {
|
||||
foundDeprecated.push('ui.hideIntroTips');
|
||||
if (newUi['hideTips'] === undefined) {
|
||||
newUi['hideTips'] = newUi['hideIntroTips'];
|
||||
uiModified = true;
|
||||
}
|
||||
if (removeDeprecated) {
|
||||
delete newUi['hideIntroTips'];
|
||||
uiModified = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate loadingPhrases/statusHints (enums) → hideStatusTips/hideStatusWit (booleans)
|
||||
const oldHintSetting = newUi['statusHints'] ?? newUi['loadingPhrases'];
|
||||
if (oldHintSetting !== undefined) {
|
||||
if (newUi['loadingPhrases'] !== undefined) {
|
||||
foundDeprecated.push('ui.loadingPhrases');
|
||||
}
|
||||
if (newUi['statusHints'] !== undefined) {
|
||||
foundDeprecated.push('ui.statusHints');
|
||||
}
|
||||
|
||||
if (
|
||||
newUi['hideStatusTips'] === undefined &&
|
||||
newUi['hideStatusWit'] === undefined
|
||||
) {
|
||||
switch (oldHintSetting) {
|
||||
case 'all':
|
||||
newUi['hideStatusTips'] = false;
|
||||
newUi['hideStatusWit'] = false;
|
||||
uiModified = true;
|
||||
break;
|
||||
case 'tips':
|
||||
newUi['hideStatusTips'] = false;
|
||||
newUi['hideStatusWit'] = true;
|
||||
uiModified = true;
|
||||
break;
|
||||
case 'witty':
|
||||
newUi['hideStatusTips'] = true;
|
||||
newUi['hideStatusWit'] = false;
|
||||
uiModified = true;
|
||||
break;
|
||||
case 'off':
|
||||
newUi['hideStatusTips'] = true;
|
||||
newUi['hideStatusWit'] = true;
|
||||
uiModified = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (removeDeprecated) {
|
||||
if (newUi['loadingPhrases'] !== undefined) {
|
||||
delete newUi['loadingPhrases'];
|
||||
uiModified = true;
|
||||
}
|
||||
if (newUi['statusHints'] !== undefined) {
|
||||
delete newUi['statusHints'];
|
||||
uiModified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle the recently added (now deprecated) showStatusTips and showStatusWit
|
||||
uiModified =
|
||||
migrateBoolean(
|
||||
newUi,
|
||||
'showStatusTips',
|
||||
'hideStatusTips',
|
||||
'ui',
|
||||
foundDeprecated,
|
||||
) || uiModified;
|
||||
uiModified =
|
||||
migrateBoolean(
|
||||
newUi,
|
||||
'showStatusWit',
|
||||
'hideStatusWit',
|
||||
'ui',
|
||||
foundDeprecated,
|
||||
) || uiModified;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const accessibilitySettings = newUi['accessibility'] as
|
||||
| Record<string, unknown>
|
||||
@@ -928,26 +1013,34 @@ export function migrateDeprecatedSettings(
|
||||
)
|
||||
) {
|
||||
newUi['accessibility'] = newAccessibility;
|
||||
loadedSettings.setValue(scope, 'ui', newUi);
|
||||
if (!settingsFile.readOnly) {
|
||||
anyModified = true;
|
||||
}
|
||||
uiModified = true;
|
||||
}
|
||||
|
||||
// Migrate enableLoadingPhrases: false → loadingPhrases: 'off'
|
||||
// Migrate enableLoadingPhrases: false → hideStatusTips/hideStatusWit: true
|
||||
const enableLP = newAccessibility['enableLoadingPhrases'];
|
||||
if (
|
||||
typeof enableLP === 'boolean' &&
|
||||
newUi['loadingPhrases'] === undefined
|
||||
) {
|
||||
if (!enableLP) {
|
||||
newUi['loadingPhrases'] = 'off';
|
||||
loadedSettings.setValue(scope, 'ui', newUi);
|
||||
if (!settingsFile.readOnly) {
|
||||
anyModified = true;
|
||||
}
|
||||
}
|
||||
if (enableLP === true || enableLP === false) {
|
||||
foundDeprecated.push('ui.accessibility.enableLoadingPhrases');
|
||||
if (
|
||||
!enableLP &&
|
||||
newUi['hideStatusTips'] === undefined &&
|
||||
newUi['hideStatusWit'] === undefined
|
||||
) {
|
||||
newUi['hideStatusTips'] = true;
|
||||
newUi['hideStatusWit'] = true;
|
||||
uiModified = true;
|
||||
}
|
||||
if (removeDeprecated) {
|
||||
delete newAccessibility['enableLoadingPhrases'];
|
||||
newUi['accessibility'] = newAccessibility;
|
||||
uiModified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (uiModified) {
|
||||
loadedSettings.setValue(scope, 'ui', newUi);
|
||||
if (!settingsFile.readOnly) {
|
||||
anyModified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,19 +83,6 @@ describe('SettingsSchema', () => {
|
||||
).toBe('boolean');
|
||||
});
|
||||
|
||||
it('should have loadingPhrases enum property', () => {
|
||||
const definition = getSettingsSchema().ui?.properties?.loadingPhrases;
|
||||
expect(definition).toBeDefined();
|
||||
expect(definition?.type).toBe('enum');
|
||||
expect(definition?.default).toBe('tips');
|
||||
expect(definition?.options?.map((o) => o.value)).toEqual([
|
||||
'tips',
|
||||
'witty',
|
||||
'all',
|
||||
'off',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should have errorVerbosity enum property', () => {
|
||||
const definition = getSettingsSchema().ui?.properties?.errorVerbosity;
|
||||
expect(definition).toBeDefined();
|
||||
@@ -381,7 +368,7 @@ describe('SettingsSchema', () => {
|
||||
).toBe(true);
|
||||
expect(
|
||||
getSettingsSchema().ui.properties.showShortcutsHint.description,
|
||||
).toBe('Show the "? for shortcuts" hint above the input.');
|
||||
).toBe("Show basic shortcut help ('?') when the status line is idle.");
|
||||
});
|
||||
|
||||
it('should have enableNotifications setting in schema', () => {
|
||||
|
||||
@@ -533,13 +533,24 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
hideTips: {
|
||||
type: 'boolean',
|
||||
label: 'Hide Tips',
|
||||
label: 'Hide Startup Tips',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Hide helpful tips in the UI',
|
||||
description:
|
||||
'Hide the introductory tips shown at the top of the screen.',
|
||||
showInDialog: true,
|
||||
},
|
||||
hideIntroTips: {
|
||||
type: 'boolean',
|
||||
label: 'Hide Intro Tips',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'@deprecated Use ui.hideTips instead. Hide the intro tips in the header.',
|
||||
showInDialog: false,
|
||||
},
|
||||
escapePastedAtSymbols: {
|
||||
type: 'boolean',
|
||||
label: 'Escape Pasted @ Symbols',
|
||||
@@ -556,7 +567,8 @@ const SETTINGS_SCHEMA = {
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
description: 'Show the "? for shortcuts" hint above the input.',
|
||||
description:
|
||||
"Show basic shortcut help ('?') when the status line is idle.",
|
||||
showInDialog: true,
|
||||
},
|
||||
hideBanner: {
|
||||
@@ -739,6 +751,42 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Show the spinner during operations.',
|
||||
showInDialog: true,
|
||||
},
|
||||
hideStatusTips: {
|
||||
type: 'boolean',
|
||||
label: 'Hide Footer Tips',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'Hide helpful tips in the footer while the model is working.',
|
||||
showInDialog: true,
|
||||
},
|
||||
hideStatusWit: {
|
||||
type: 'boolean',
|
||||
label: 'Hide Footer Wit',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
description:
|
||||
'Hide witty loading phrases in the footer while the model is working.',
|
||||
showInDialog: true,
|
||||
},
|
||||
statusHints: {
|
||||
type: 'enum',
|
||||
label: 'Status Line Hints',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: 'tips',
|
||||
description:
|
||||
'@deprecated Use ui.hideStatusTips and ui.hideStatusWit instead. What to show in the status line: tips, witty comments, both, or off (fallback to shortcuts help).',
|
||||
showInDialog: false,
|
||||
options: [
|
||||
{ value: 'tips', label: 'Tips' },
|
||||
{ value: 'witty', label: 'Witty' },
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'off', label: 'Off' },
|
||||
],
|
||||
},
|
||||
loadingPhrases: {
|
||||
type: 'enum',
|
||||
label: 'Loading Phrases',
|
||||
@@ -746,8 +794,8 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: false,
|
||||
default: 'tips',
|
||||
description:
|
||||
'What to show while the model is working: tips, witty comments, both, or nothing.',
|
||||
showInDialog: true,
|
||||
'@deprecated Use ui.hideStatusTips and ui.hideStatusWit instead. What to show in the status line: tips, witty comments, both, or off (fallback to shortcuts help).',
|
||||
showInDialog: false,
|
||||
options: [
|
||||
{ value: 'tips', label: 'Tips' },
|
||||
{ value: 'witty', label: 'Witty' },
|
||||
|
||||
@@ -177,6 +177,16 @@ export class AppRig {
|
||||
);
|
||||
this.sessionId = `test-session-${uniqueId}`;
|
||||
activeRigs.set(this.sessionId, this);
|
||||
|
||||
// Pre-create the persistent state file to bypass the terminal setup prompt
|
||||
const geminiDir = path.join(this.testDir, '.gemini');
|
||||
if (!fs.existsSync(geminiDir)) {
|
||||
fs.mkdirSync(geminiDir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(geminiDir, 'state.json'),
|
||||
JSON.stringify({ terminalSetupPromptShown: true }),
|
||||
);
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
@@ -708,7 +718,7 @@ export class AppRig {
|
||||
);
|
||||
}
|
||||
|
||||
async waitForIdle(timeout = 20000) {
|
||||
async waitForIdle(timeout = 30000) {
|
||||
await this.waitForOutput('Type your message', timeout);
|
||||
}
|
||||
|
||||
|
||||
@@ -1399,7 +1399,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
!isResuming &&
|
||||
!!slashCommands &&
|
||||
(streamingState === StreamingState.Idle ||
|
||||
streamingState === StreamingState.Responding) &&
|
||||
streamingState === StreamingState.Responding ||
|
||||
streamingState === StreamingState.WaitingForConfirmation) &&
|
||||
!proQuotaRequest;
|
||||
|
||||
const [controlsHeight, setControlsHeight] = useState(0);
|
||||
@@ -1666,15 +1667,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
[handleSlashCommand, settings],
|
||||
);
|
||||
|
||||
const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator({
|
||||
streamingState,
|
||||
shouldShowFocusHint,
|
||||
retryStatus,
|
||||
loadingPhrasesMode: settings.merged.ui.loadingPhrases,
|
||||
customWittyPhrases: settings.merged.ui.customWittyPhrases,
|
||||
errorVerbosity: settings.merged.ui.errorVerbosity,
|
||||
});
|
||||
|
||||
const handleGlobalKeypress = useCallback(
|
||||
(key: Key): boolean => {
|
||||
// Debug log keystrokes if enabled
|
||||
@@ -2064,6 +2056,47 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
!!emptyWalletRequest ||
|
||||
!!customDialog;
|
||||
|
||||
const showStatusTips = !settings.merged.ui.hideStatusTips;
|
||||
const showStatusWit = !settings.merged.ui.hideStatusWit;
|
||||
|
||||
const showLoadingIndicator =
|
||||
(!embeddedShellFocused || isBackgroundShellVisible) &&
|
||||
streamingState === StreamingState.Responding &&
|
||||
!hasPendingActionRequired;
|
||||
|
||||
let estimatedStatusLength = 0;
|
||||
if (activeHooks.length > 0 && settings.merged.hooksConfig.notifications) {
|
||||
const hookLabel =
|
||||
activeHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook';
|
||||
const hookNames = activeHooks
|
||||
.map(
|
||||
(h) =>
|
||||
h.name +
|
||||
(h.index && h.total && h.total > 1 ? ` (${h.index}/${h.total})` : ''),
|
||||
)
|
||||
.join(', ');
|
||||
estimatedStatusLength = hookLabel.length + hookNames.length + 10;
|
||||
} else if (showLoadingIndicator) {
|
||||
const thoughtText = thought?.subject || 'Waiting for model...';
|
||||
estimatedStatusLength = thoughtText.length + 25;
|
||||
} else if (hasPendingActionRequired) {
|
||||
estimatedStatusLength = 35;
|
||||
}
|
||||
|
||||
const maxLength = terminalWidth - estimatedStatusLength - 5;
|
||||
|
||||
const { elapsedTime, currentLoadingPhrase, currentTip, currentWittyPhrase } =
|
||||
useLoadingIndicator({
|
||||
streamingState,
|
||||
shouldShowFocusHint,
|
||||
retryStatus,
|
||||
showTips: showStatusTips,
|
||||
showWit: showStatusWit,
|
||||
customWittyPhrases: settings.merged.ui.customWittyPhrases,
|
||||
errorVerbosity: settings.merged.ui.errorVerbosity,
|
||||
maxLength,
|
||||
});
|
||||
|
||||
const allowPlanMode =
|
||||
config.isPlanEnabled() &&
|
||||
streamingState === StreamingState.Idle &&
|
||||
@@ -2250,6 +2283,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isFocused,
|
||||
elapsedTime,
|
||||
currentLoadingPhrase,
|
||||
currentTip,
|
||||
currentWittyPhrase,
|
||||
historyRemountKey,
|
||||
activeHooks,
|
||||
messageQueue,
|
||||
@@ -2378,6 +2413,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isFocused,
|
||||
elapsedTime,
|
||||
currentLoadingPhrase,
|
||||
currentTip,
|
||||
currentWittyPhrase,
|
||||
historyRemountKey,
|
||||
activeHooks,
|
||||
messageQueue,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
renderWithProviders,
|
||||
persistentStateMock,
|
||||
} from '../../test-utils/render.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { AppHeader } from './AppHeader.js';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { makeFakeConfig } from '@google/gemini-cli-core';
|
||||
@@ -268,4 +269,23 @@ describe('<AppHeader />', () => {
|
||||
expect(session2.lastFrame()).not.toContain('Tips');
|
||||
session2.unmount();
|
||||
});
|
||||
|
||||
it('should NOT render Tips when ui.hideTips is true', async () => {
|
||||
const mockConfig = makeFakeConfig();
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<AppHeader version="1.0.0" />,
|
||||
{
|
||||
config: mockConfig,
|
||||
settings: {
|
||||
merged: {
|
||||
ui: { hideTips: true },
|
||||
},
|
||||
} as unknown as LoadedSettings,
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
expect(lastFrame()).not.toContain('Tips');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,13 +17,6 @@ import {
|
||||
import { ConfigContext } from '../contexts/ConfigContext.js';
|
||||
import { SettingsContext } from '../contexts/SettingsContext.js';
|
||||
import { createMockSettings } from '../../test-utils/settings.js';
|
||||
// Mock VimModeContext hook
|
||||
vi.mock('../contexts/VimModeContext.js', () => ({
|
||||
useVimMode: vi.fn(() => ({
|
||||
vimEnabled: false,
|
||||
vimMode: 'INSERT',
|
||||
})),
|
||||
}));
|
||||
import {
|
||||
ApprovalMode,
|
||||
tokenLimit,
|
||||
@@ -36,6 +29,21 @@ import type { LoadedSettings } from '../../config/settings.js';
|
||||
import type { SessionMetrics } from '../contexts/SessionContext.js';
|
||||
import type { TextBuffer } from './shared/text-buffer.js';
|
||||
|
||||
// Mock VimModeContext hook
|
||||
vi.mock('../contexts/VimModeContext.js', () => ({
|
||||
useVimMode: vi.fn(() => ({
|
||||
vimEnabled: false,
|
||||
vimMode: 'INSERT',
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/useTerminalSize.js', () => ({
|
||||
useTerminalSize: vi.fn(() => ({
|
||||
columns: 100,
|
||||
rows: 24,
|
||||
})),
|
||||
}));
|
||||
|
||||
const composerTestControls = vi.hoisted(() => ({
|
||||
suggestionsVisible: false,
|
||||
isAlternateBuffer: false,
|
||||
@@ -58,18 +66,9 @@ vi.mock('./LoadingIndicator.js', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('./StatusDisplay.js', () => ({
|
||||
StatusDisplay: () => <Text>StatusDisplay</Text>,
|
||||
}));
|
||||
|
||||
vi.mock('./ToastDisplay.js', () => ({
|
||||
ToastDisplay: () => <Text>ToastDisplay</Text>,
|
||||
shouldShowToast: (uiState: UIState) =>
|
||||
uiState.ctrlCPressedOnce ||
|
||||
Boolean(uiState.transientMessage) ||
|
||||
uiState.ctrlDPressedOnce ||
|
||||
(uiState.showEscapePrompt &&
|
||||
(uiState.buffer.text.length > 0 || uiState.history.length > 0)) ||
|
||||
Boolean(uiState.queueErrorMessage),
|
||||
StatusDisplay: ({ hideContextSummary }: { hideContextSummary: boolean }) => (
|
||||
<Text>StatusDisplay{hideContextSummary ? ' (hidden summary)' : ''}</Text>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('./ContextSummaryDisplay.js', () => ({
|
||||
@@ -81,17 +80,15 @@ vi.mock('./HookStatusDisplay.js', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('./ApprovalModeIndicator.js', () => ({
|
||||
ApprovalModeIndicator: () => <Text>ApprovalModeIndicator</Text>,
|
||||
ApprovalModeIndicator: ({ approvalMode }: { approvalMode: ApprovalMode }) => (
|
||||
<Text>ApprovalModeIndicator: {approvalMode}</Text>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('./ShellModeIndicator.js', () => ({
|
||||
ShellModeIndicator: () => <Text>ShellModeIndicator</Text>,
|
||||
}));
|
||||
|
||||
vi.mock('./ShortcutsHint.js', () => ({
|
||||
ShortcutsHint: () => <Text>ShortcutsHint</Text>,
|
||||
}));
|
||||
|
||||
vi.mock('./ShortcutsHelp.js', () => ({
|
||||
ShortcutsHelp: () => <Text>ShortcutsHelp</Text>,
|
||||
}));
|
||||
@@ -174,6 +171,8 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
||||
isFocused: true,
|
||||
thought: '',
|
||||
currentLoadingPhrase: '',
|
||||
currentTip: '',
|
||||
currentWittyPhrase: '',
|
||||
elapsedTime: 0,
|
||||
ctrlCPressedOnce: false,
|
||||
ctrlDPressedOnce: false,
|
||||
@@ -202,6 +201,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
||||
activeHooks: [],
|
||||
isBackgroundShellVisible: false,
|
||||
embeddedShellFocused: false,
|
||||
showIsExpandableHint: false,
|
||||
quota: {
|
||||
userTier: undefined,
|
||||
stats: undefined,
|
||||
@@ -248,7 +248,7 @@ const createMockConfig = (overrides = {}): Config =>
|
||||
|
||||
const renderComposer = async (
|
||||
uiState: UIState,
|
||||
settings = createMockSettings(),
|
||||
settings = createMockSettings({ ui: {} }),
|
||||
config = createMockConfig(),
|
||||
uiActions = createMockUIActions(),
|
||||
) => {
|
||||
@@ -257,7 +257,7 @@ const renderComposer = async (
|
||||
<SettingsContext.Provider value={settings as unknown as LoadedSettings}>
|
||||
<UIStateContext.Provider value={uiState}>
|
||||
<UIActionsContext.Provider value={uiActions}>
|
||||
<Composer />
|
||||
<Composer isFocused={true} />
|
||||
</UIActionsContext.Provider>
|
||||
</UIStateContext.Provider>
|
||||
</SettingsContext.Provider>
|
||||
@@ -385,10 +385,12 @@ describe('Composer', () => {
|
||||
const { lastFrame } = await renderComposer(uiState, settings);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('LoadingIndicator: Thinking...');
|
||||
// In Refreshed UX, we don't force 'Thinking...' label in renderStatusNode
|
||||
// It uses the subject directly
|
||||
expect(output).toContain('LoadingIndicator: Thinking about code');
|
||||
});
|
||||
|
||||
it('hides shortcuts hint while loading', async () => {
|
||||
it('shows shortcuts hint while loading', async () => {
|
||||
const uiState = createMockUIState({
|
||||
streamingState: StreamingState.Responding,
|
||||
elapsedTime: 1,
|
||||
@@ -399,7 +401,8 @@ describe('Composer', () => {
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('LoadingIndicator');
|
||||
expect(output).not.toContain('ShortcutsHint');
|
||||
expect(output).toContain('press tab twice for more');
|
||||
expect(output).not.toContain('? for shortcuts');
|
||||
});
|
||||
|
||||
it('renders LoadingIndicator with thought when loadingPhrases is off', async () => {
|
||||
@@ -455,9 +458,8 @@ describe('Composer', () => {
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).not.toContain('LoadingIndicator');
|
||||
expect(output).not.toContain('esc to cancel');
|
||||
const output = lastFrame({ allowEmpty: true });
|
||||
expect(output).toBe('');
|
||||
});
|
||||
|
||||
it('renders LoadingIndicator when embedded shell is focused but background shell is visible', async () => {
|
||||
@@ -560,8 +562,10 @@ describe('Composer', () => {
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('ToastDisplay');
|
||||
expect(output).not.toContain('ApprovalModeIndicator');
|
||||
expect(output).toContain('Press Ctrl+C again to exit.');
|
||||
// In Refreshed UX, Row 1 shows toast, and Row 2 shows ApprovalModeIndicator/StatusDisplay
|
||||
// They are no longer mutually exclusive.
|
||||
expect(output).toContain('ApprovalModeIndicator');
|
||||
expect(output).toContain('StatusDisplay');
|
||||
});
|
||||
|
||||
@@ -576,8 +580,8 @@ describe('Composer', () => {
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('ToastDisplay');
|
||||
expect(output).not.toContain('ApprovalModeIndicator');
|
||||
expect(output).toContain('Warning');
|
||||
expect(output).toContain('ApprovalModeIndicator');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -586,15 +590,17 @@ describe('Composer', () => {
|
||||
const uiState = createMockUIState({
|
||||
cleanUiDetailsVisible: false,
|
||||
});
|
||||
const settings = createMockSettings({
|
||||
ui: { showShortcutsHint: false },
|
||||
});
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
const { lastFrame } = await renderComposer(uiState, settings);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('ShortcutsHint');
|
||||
expect(output).not.toContain('press tab twice for more');
|
||||
expect(output).not.toContain('? for shortcuts');
|
||||
expect(output).toContain('InputPrompt');
|
||||
expect(output).not.toContain('Footer');
|
||||
expect(output).not.toContain('ApprovalModeIndicator');
|
||||
expect(output).not.toContain('ContextSummaryDisplay');
|
||||
});
|
||||
|
||||
it('renders InputPrompt when input is active', async () => {
|
||||
@@ -667,12 +673,15 @@ describe('Composer', () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
[ApprovalMode.YOLO, 'YOLO'],
|
||||
[ApprovalMode.PLAN, 'plan'],
|
||||
[ApprovalMode.AUTO_EDIT, 'auto edit'],
|
||||
{ mode: ApprovalMode.YOLO, label: '● YOLO' },
|
||||
{ mode: ApprovalMode.PLAN, label: '● plan' },
|
||||
{
|
||||
mode: ApprovalMode.AUTO_EDIT,
|
||||
label: '● auto edit',
|
||||
},
|
||||
])(
|
||||
'shows minimal mode badge "%s" when clean UI details are hidden',
|
||||
async (mode, label) => {
|
||||
'shows minimal mode badge "$mode" when clean UI details are hidden',
|
||||
async ({ mode, label }) => {
|
||||
const uiState = createMockUIState({
|
||||
cleanUiDetailsVisible: false,
|
||||
showApprovalModeIndicator: mode,
|
||||
@@ -695,7 +704,8 @@ describe('Composer', () => {
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('LoadingIndicator');
|
||||
expect(output).not.toContain('plan');
|
||||
expect(output).not.toContain('ShortcutsHint');
|
||||
expect(output).toContain('press tab twice for more');
|
||||
expect(output).not.toContain('? for shortcuts');
|
||||
});
|
||||
|
||||
it('hides minimal mode badge while action-required state is active', async () => {
|
||||
@@ -710,9 +720,7 @@ describe('Composer', () => {
|
||||
});
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
const output = lastFrame();
|
||||
expect(output).not.toContain('plan');
|
||||
expect(output).not.toContain('ShortcutsHint');
|
||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||
});
|
||||
|
||||
it('shows Esc rewind prompt in minimal mode without showing full UI', async () => {
|
||||
@@ -724,11 +732,11 @@ describe('Composer', () => {
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('ToastDisplay');
|
||||
expect(output).toContain('Press Esc again to rewind.');
|
||||
expect(output).not.toContain('ContextSummaryDisplay');
|
||||
});
|
||||
|
||||
it('shows context usage bleed-through when over 60%', async () => {
|
||||
it('does not show context usage bleed-through when over 60% due to removed functionality', async () => {
|
||||
const model = 'gemini-2.5-pro';
|
||||
const uiState = createMockUIState({
|
||||
cleanUiDetailsVisible: false,
|
||||
@@ -749,7 +757,13 @@ describe('Composer', () => {
|
||||
});
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState, settings);
|
||||
expect(lastFrame()).toContain('%');
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
});
|
||||
|
||||
// StatusDisplay should no longer bleed through in minimal mode
|
||||
expect(lastFrame()).not.toContain('StatusDisplay');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -821,14 +835,20 @@ describe('Composer', () => {
|
||||
|
||||
describe('Shortcuts Hint', () => {
|
||||
it('restores shortcuts hint after 200ms debounce when buffer is empty', async () => {
|
||||
const { lastFrame } = await renderComposer(
|
||||
createMockUIState({
|
||||
buffer: { text: '' } as unknown as TextBuffer,
|
||||
cleanUiDetailsVisible: false,
|
||||
}),
|
||||
);
|
||||
const uiState = createMockUIState({
|
||||
buffer: { text: '' } as unknown as TextBuffer,
|
||||
cleanUiDetailsVisible: false,
|
||||
});
|
||||
|
||||
expect(lastFrame({ allowEmpty: true })).toContain('ShortcutsHint');
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
});
|
||||
|
||||
expect(lastFrame({ allowEmpty: true })).toContain(
|
||||
'press tab twice for more',
|
||||
);
|
||||
});
|
||||
|
||||
it('hides shortcuts hint when text is typed in buffer', async () => {
|
||||
@@ -839,7 +859,8 @@ describe('Composer', () => {
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).not.toContain('ShortcutsHint');
|
||||
expect(lastFrame()).not.toContain('press tab twice for more');
|
||||
expect(lastFrame()).not.toContain('? for shortcuts');
|
||||
});
|
||||
|
||||
it('hides shortcuts hint when showShortcutsHint setting is false', async () => {
|
||||
@@ -852,7 +873,7 @@ describe('Composer', () => {
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState, settings);
|
||||
|
||||
expect(lastFrame()).not.toContain('ShortcutsHint');
|
||||
expect(lastFrame()).not.toContain('? for shortcuts');
|
||||
});
|
||||
|
||||
it('hides shortcuts hint when a action is required (e.g. dialog is open)', async () => {
|
||||
@@ -865,9 +886,10 @@ describe('Composer', () => {
|
||||
),
|
||||
});
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
const { lastFrame, unmount } = await renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).not.toContain('ShortcutsHint');
|
||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('keeps shortcuts hint visible when no action is required', async () => {
|
||||
@@ -877,7 +899,11 @@ describe('Composer', () => {
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).toContain('ShortcutsHint');
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
});
|
||||
|
||||
expect(lastFrame()).toContain('press tab twice for more');
|
||||
});
|
||||
|
||||
it('shows shortcuts hint when full UI details are visible', async () => {
|
||||
@@ -887,10 +913,15 @@ describe('Composer', () => {
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).toContain('ShortcutsHint');
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
});
|
||||
|
||||
// In Refreshed UX, shortcuts hint is in the top multipurpose status row
|
||||
expect(lastFrame()).toContain('? for shortcuts');
|
||||
});
|
||||
|
||||
it('hides shortcuts hint while loading when full UI details are visible', async () => {
|
||||
it('shows shortcuts hint while loading when full UI details are visible', async () => {
|
||||
const uiState = createMockUIState({
|
||||
cleanUiDetailsVisible: true,
|
||||
streamingState: StreamingState.Responding,
|
||||
@@ -898,10 +929,17 @@ describe('Composer', () => {
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).not.toContain('ShortcutsHint');
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
});
|
||||
|
||||
// In experimental layout, status row is visible during loading
|
||||
expect(lastFrame()).toContain('LoadingIndicator');
|
||||
expect(lastFrame()).toContain('? for shortcuts');
|
||||
expect(lastFrame()).not.toContain('press tab twice for more');
|
||||
});
|
||||
|
||||
it('hides shortcuts hint while loading in minimal mode', async () => {
|
||||
it('shows shortcuts hint while loading in minimal mode', async () => {
|
||||
const uiState = createMockUIState({
|
||||
cleanUiDetailsVisible: false,
|
||||
streamingState: StreamingState.Responding,
|
||||
@@ -910,7 +948,14 @@ describe('Composer', () => {
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).not.toContain('ShortcutsHint');
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
});
|
||||
|
||||
// In experimental layout, status row is visible in clean mode while busy
|
||||
expect(lastFrame()).toContain('LoadingIndicator');
|
||||
expect(lastFrame()).toContain('press tab twice for more');
|
||||
expect(lastFrame()).not.toContain('? for shortcuts');
|
||||
});
|
||||
|
||||
it('shows shortcuts help in minimal mode when toggled on', async () => {
|
||||
@@ -935,7 +980,8 @@ describe('Composer', () => {
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).not.toContain('ShortcutsHint');
|
||||
expect(lastFrame()).not.toContain('press tab twice for more');
|
||||
expect(lastFrame()).not.toContain('? for shortcuts');
|
||||
expect(lastFrame()).not.toContain('plan');
|
||||
});
|
||||
|
||||
@@ -963,7 +1009,12 @@ describe('Composer', () => {
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).toContain('ShortcutsHint');
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
});
|
||||
|
||||
// In Refreshed UX, shortcuts hint is in the top status row and doesn't collide with suggestions below
|
||||
expect(lastFrame()).toContain('press tab twice for more');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -991,24 +1042,22 @@ describe('Composer', () => {
|
||||
expect(lastFrame()).not.toContain('ShortcutsHelp');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('hides shortcuts help when action is required', async () => {
|
||||
const uiState = createMockUIState({
|
||||
shortcutsHelpVisible: true,
|
||||
customDialog: (
|
||||
<Box>
|
||||
<Text>Dialog content</Text>
|
||||
<Text>Test Dialog</Text>
|
||||
</Box>
|
||||
),
|
||||
});
|
||||
|
||||
const { lastFrame, unmount } = await renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).not.toContain('ShortcutsHelp');
|
||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Snapshots', () => {
|
||||
it('matches snapshot in idle state', async () => {
|
||||
const uiState = createMockUIState();
|
||||
|
||||
@@ -4,13 +4,25 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
|
||||
import {
|
||||
ApprovalMode,
|
||||
checkExhaustive,
|
||||
CoreToolCallStatus,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||
import { useVimMode } from '../contexts/VimModeContext.js';
|
||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { GENERIC_WORKING_LABEL } from '../textConstants.js';
|
||||
import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js';
|
||||
import { StreamingState, type HistoryItemToolGroup } from '../types.js';
|
||||
import { LoadingIndicator } from './LoadingIndicator.js';
|
||||
import { StatusDisplay } from './StatusDisplay.js';
|
||||
import { ToastDisplay, shouldShowToast } from './ToastDisplay.js';
|
||||
@@ -18,44 +30,32 @@ 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';
|
||||
import { Footer } from './Footer.js';
|
||||
import { ShowMoreLines } from './ShowMoreLines.js';
|
||||
import { QueuedMessageDisplay } from './QueuedMessageDisplay.js';
|
||||
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
|
||||
import { HorizontalLine } from './shared/HorizontalLine.js';
|
||||
import { OverflowProvider } from '../contexts/OverflowContext.js';
|
||||
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||
import { useVimMode } from '../contexts/VimModeContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
import { StreamingState, type HistoryItemToolGroup } from '../types.js';
|
||||
import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js';
|
||||
import { ConfigInitDisplay } from './ConfigInitDisplay.js';
|
||||
import { TodoTray } from './messages/Todo.js';
|
||||
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
|
||||
import { isContextUsageHigh } from '../utils/contextUsage.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
|
||||
export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
const config = useConfig();
|
||||
const settings = useSettings();
|
||||
const isScreenReaderEnabled = useIsScreenReaderEnabled();
|
||||
const uiState = useUIState();
|
||||
const uiActions = useUIActions();
|
||||
const settings = useSettings();
|
||||
const config = useConfig();
|
||||
const { vimEnabled, vimMode } = useVimMode();
|
||||
const inlineThinkingMode = getInlineThinkingMode(settings);
|
||||
const terminalWidth = uiState.terminalWidth;
|
||||
const isScreenReaderEnabled = useIsScreenReaderEnabled();
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
const isNarrow = isNarrowWidth(terminalWidth);
|
||||
const debugConsoleMaxHeight = Math.floor(Math.max(terminalWidth * 0.2, 5));
|
||||
const [suggestionsVisible, setSuggestionsVisible] = useState(false);
|
||||
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const { showApprovalModeIndicator } = uiState;
|
||||
const showTips = !settings.merged.ui.hideStatusTips;
|
||||
const showWit = !settings.merged.ui.hideStatusWit;
|
||||
|
||||
const showUiDetails = uiState.cleanUiDetailsVisible;
|
||||
const suggestionsPosition = isAlternateBuffer ? 'above' : 'below';
|
||||
const hideContextSummary =
|
||||
@@ -84,6 +84,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
Boolean(uiState.quota.proQuotaRequest) ||
|
||||
Boolean(uiState.quota.validationRequest) ||
|
||||
Boolean(uiState.customDialog);
|
||||
|
||||
const isPassiveShortcutsHelpState =
|
||||
uiState.isInputActive &&
|
||||
uiState.streamingState === StreamingState.Idle &&
|
||||
@@ -105,16 +106,32 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
uiState.shortcutsHelpVisible &&
|
||||
uiState.streamingState === StreamingState.Idle &&
|
||||
!hasPendingActionRequired;
|
||||
|
||||
/**
|
||||
* Use the setting if provided, otherwise default to true for the new UX.
|
||||
* This allows tests to override the collapse behavior.
|
||||
*/
|
||||
const shouldCollapseDuringApproval =
|
||||
(settings.merged.ui as Record<string, unknown>)[
|
||||
'collapseDrawerDuringApproval'
|
||||
] !== false;
|
||||
|
||||
if (hasPendingActionRequired && shouldCollapseDuringApproval) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasToast = shouldShowToast(uiState);
|
||||
const showLoadingIndicator =
|
||||
(!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) &&
|
||||
uiState.streamingState === StreamingState.Responding &&
|
||||
!hasPendingActionRequired;
|
||||
|
||||
const hideUiDetailsForSuggestions =
|
||||
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:
|
||||
@@ -137,57 +154,356 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
|
||||
const hideMinimalModeHintWhileBusy =
|
||||
!showUiDetails && (showLoadingIndicator || hasPendingActionRequired);
|
||||
const minimalModeBleedThrough = hideMinimalModeHintWhileBusy
|
||||
? null
|
||||
: modeBleedThrough;
|
||||
const hasMinimalStatusBleedThrough = shouldShowToast(uiState);
|
||||
|
||||
const showMinimalContextBleedThrough =
|
||||
!settings.merged.ui.footer.hideContextPercentage &&
|
||||
isContextUsageHigh(
|
||||
uiState.sessionStats.lastPromptTokenCount,
|
||||
typeof uiState.currentModel === 'string'
|
||||
? uiState.currentModel
|
||||
: undefined,
|
||||
);
|
||||
const hideShortcutsHintForSuggestions = hideUiDetailsForSuggestions;
|
||||
const isModelIdle = uiState.streamingState === StreamingState.Idle;
|
||||
const isBufferEmpty = uiState.buffer.text.length === 0;
|
||||
const canShowShortcutsHint =
|
||||
isModelIdle && isBufferEmpty && !hasPendingActionRequired;
|
||||
const [showShortcutsHintDebounced, setShowShortcutsHintDebounced] =
|
||||
useState(canShowShortcutsHint);
|
||||
// Universal Content Objects
|
||||
const modeContentObj = hideMinimalModeHintWhileBusy ? null : modeBleedThrough;
|
||||
|
||||
useEffect(() => {
|
||||
if (!canShowShortcutsHint) {
|
||||
setShowShortcutsHintDebounced(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
setShowShortcutsHintDebounced(true);
|
||||
}, 200);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [canShowShortcutsHint]);
|
||||
const USER_HOOK_SOURCES = ['user', 'project', 'runtime', 'extensions'];
|
||||
const allHooks = uiState.activeHooks;
|
||||
const hasAnyHooks = allHooks.length > 0;
|
||||
const userVisibleHooks = allHooks.filter(
|
||||
(h) => !h.source || USER_HOOK_SOURCES.includes(h.source),
|
||||
);
|
||||
const hasUserVisibleHooks = userVisibleHooks.length > 0;
|
||||
|
||||
const shouldReserveSpaceForShortcutsHint =
|
||||
settings.merged.ui.showShortcutsHint && !hideShortcutsHintForSuggestions;
|
||||
const showShortcutsHint =
|
||||
shouldReserveSpaceForShortcutsHint && showShortcutsHintDebounced;
|
||||
const showMinimalModeBleedThrough =
|
||||
!hideUiDetailsForSuggestions && Boolean(minimalModeBleedThrough);
|
||||
const showMinimalInlineLoading = !showUiDetails && showLoadingIndicator;
|
||||
const showMinimalBleedThroughRow =
|
||||
!showUiDetails &&
|
||||
(showMinimalModeBleedThrough ||
|
||||
hasMinimalStatusBleedThrough ||
|
||||
showMinimalContextBleedThrough);
|
||||
const showMinimalMetaRow =
|
||||
!showUiDetails &&
|
||||
(showMinimalInlineLoading ||
|
||||
showMinimalBleedThroughRow ||
|
||||
shouldReserveSpaceForShortcutsHint);
|
||||
settings.merged.ui.showShortcutsHint && !hideUiDetailsForSuggestions;
|
||||
|
||||
const isInteractiveShellWaiting = uiState.currentLoadingPhrase?.includes(
|
||||
INTERACTIVE_SHELL_WAITING_PHRASE,
|
||||
);
|
||||
|
||||
/**
|
||||
* Calculate the estimated length of the status message to avoid collisions
|
||||
* with the tips area.
|
||||
*/
|
||||
let estimatedStatusLength = 0;
|
||||
if (hasAnyHooks) {
|
||||
if (hasUserVisibleHooks) {
|
||||
const hookLabel =
|
||||
userVisibleHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook';
|
||||
const hookNames = userVisibleHooks
|
||||
.map(
|
||||
(h) =>
|
||||
h.name +
|
||||
(h.index && h.total && h.total > 1
|
||||
? ` (${h.index}/${h.total})`
|
||||
: ''),
|
||||
)
|
||||
.join(', ');
|
||||
estimatedStatusLength = hookLabel.length + hookNames.length + 10;
|
||||
} else {
|
||||
estimatedStatusLength = GENERIC_WORKING_LABEL.length + 10;
|
||||
}
|
||||
} else if (showLoadingIndicator) {
|
||||
const thoughtText = uiState.thought?.subject || GENERIC_WORKING_LABEL;
|
||||
const inlineWittyLength =
|
||||
showWit && uiState.currentWittyPhrase
|
||||
? uiState.currentWittyPhrase.length + 1
|
||||
: 0;
|
||||
estimatedStatusLength = thoughtText.length + 25 + inlineWittyLength;
|
||||
} else if (hasPendingActionRequired) {
|
||||
estimatedStatusLength = 20;
|
||||
} else if (hasToast) {
|
||||
estimatedStatusLength = 40;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the ambient text (tip) to display.
|
||||
*/
|
||||
const ambientContentStr = (() => {
|
||||
// 1. Proactive Tip (Priority)
|
||||
if (
|
||||
showTips &&
|
||||
uiState.currentTip &&
|
||||
!(
|
||||
isInteractiveShellWaiting &&
|
||||
uiState.currentTip === INTERACTIVE_SHELL_WAITING_PHRASE
|
||||
)
|
||||
) {
|
||||
if (
|
||||
estimatedStatusLength + uiState.currentTip.length + 10 <=
|
||||
terminalWidth
|
||||
) {
|
||||
return uiState.currentTip;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Shortcut Hint (Fallback)
|
||||
if (
|
||||
settings.merged.ui.showShortcutsHint &&
|
||||
!hideUiDetailsForSuggestions &&
|
||||
!hasPendingActionRequired &&
|
||||
uiState.buffer.text.length === 0
|
||||
) {
|
||||
return showUiDetails ? '? for shortcuts' : 'press tab twice for more';
|
||||
}
|
||||
|
||||
return undefined;
|
||||
})();
|
||||
|
||||
const estimatedAmbientLength = ambientContentStr?.length || 0;
|
||||
const willCollideAmbient =
|
||||
estimatedStatusLength + estimatedAmbientLength + 5 > terminalWidth;
|
||||
|
||||
const showAmbientLine =
|
||||
!hasPendingActionRequired &&
|
||||
ambientContentStr &&
|
||||
!willCollideAmbient &&
|
||||
!isNarrow;
|
||||
|
||||
// Mini Mode VIP Flags (Pure Content Triggers)
|
||||
const miniMode_ShowApprovalMode =
|
||||
Boolean(modeContentObj) && !hideUiDetailsForSuggestions;
|
||||
const miniMode_ShowToast = hasToast;
|
||||
const miniMode_ShowShortcuts = shouldReserveSpaceForShortcutsHint;
|
||||
const miniMode_ShowStatus = showLoadingIndicator || hasAnyHooks;
|
||||
const miniMode_ShowAmbient = showAmbientLine;
|
||||
|
||||
// Composite Mini Mode Triggers
|
||||
const showRow1_MiniMode =
|
||||
miniMode_ShowToast ||
|
||||
miniMode_ShowStatus ||
|
||||
miniMode_ShowShortcuts ||
|
||||
miniMode_ShowAmbient;
|
||||
|
||||
const showRow2_MiniMode = miniMode_ShowApprovalMode;
|
||||
|
||||
// Final Display Rules (Stable Footer Architecture)
|
||||
const showRow1 = showUiDetails || showRow1_MiniMode;
|
||||
const showRow2 = showUiDetails || showRow2_MiniMode;
|
||||
|
||||
const showMinimalBleedThroughRow = !showUiDetails && showRow2_MiniMode;
|
||||
|
||||
const renderAmbientNode = () => {
|
||||
if (!ambientContentStr) return null;
|
||||
|
||||
const isShortcutHint =
|
||||
ambientContentStr === '? for shortcuts' ||
|
||||
ambientContentStr === 'press tab twice for more';
|
||||
const color =
|
||||
isShortcutHint && uiState.shortcutsHelpVisible
|
||||
? theme.text.accent
|
||||
: theme.text.secondary;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" justifyContent="flex-end">
|
||||
<Text
|
||||
color={color}
|
||||
wrap="truncate-end"
|
||||
italic={
|
||||
!isShortcutHint && ambientContentStr === uiState.currentWittyPhrase
|
||||
}
|
||||
>
|
||||
{ambientContentStr === uiState.currentTip
|
||||
? `Tip: ${ambientContentStr}`
|
||||
: ambientContentStr}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const renderStatusNode = () => {
|
||||
const allHooks = uiState.activeHooks;
|
||||
if (allHooks.length === 0 && !showLoadingIndicator) return null;
|
||||
|
||||
if (allHooks.length > 0) {
|
||||
const userVisibleHooks = allHooks.filter(
|
||||
(h) => !h.source || USER_HOOK_SOURCES.includes(h.source),
|
||||
);
|
||||
|
||||
let hookText = GENERIC_WORKING_LABEL;
|
||||
if (userVisibleHooks.length > 0) {
|
||||
const label =
|
||||
userVisibleHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook';
|
||||
const displayNames = userVisibleHooks.map((h) => {
|
||||
let name = h.name;
|
||||
if (h.index && h.total && h.total > 1) {
|
||||
name += ` (${h.index}/${h.total})`;
|
||||
}
|
||||
return name;
|
||||
});
|
||||
hookText = `${label}: ${displayNames.join(', ')}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<LoadingIndicator
|
||||
inline
|
||||
showTips={showTips}
|
||||
showWit={showWit}
|
||||
errorVerbosity={settings.merged.ui.errorVerbosity}
|
||||
currentLoadingPhrase={hookText}
|
||||
elapsedTime={uiState.elapsedTime}
|
||||
forceRealStatusOnly={false}
|
||||
wittyPhrase={uiState.currentWittyPhrase}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LoadingIndicator
|
||||
inline
|
||||
showTips={showTips}
|
||||
showWit={showWit}
|
||||
errorVerbosity={settings.merged.ui.errorVerbosity}
|
||||
thought={uiState.thought}
|
||||
elapsedTime={uiState.elapsedTime}
|
||||
forceRealStatusOnly={false}
|
||||
wittyPhrase={uiState.currentWittyPhrase}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const statusNode = renderStatusNode();
|
||||
|
||||
/**
|
||||
* Renders the minimal metadata row content shown when UI details are hidden.
|
||||
*/
|
||||
const renderMinimalMetaRowContent = () => (
|
||||
<Box flexDirection="row" columnGap={1}>
|
||||
{renderStatusNode()}
|
||||
{showMinimalBleedThroughRow && (
|
||||
<Box>
|
||||
{miniMode_ShowApprovalMode && modeContentObj && (
|
||||
<Text color={modeContentObj.color}>● {modeContentObj.text}</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderStatusRow = () => {
|
||||
// Mini Mode Height Reservation (The "Anti-Jitter" line)
|
||||
if (!showUiDetails && !showRow1_MiniMode && !showRow2_MiniMode) {
|
||||
return <Box height={1} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width="100%">
|
||||
{/* Row 1: multipurpose status (thinking, hooks, wit, tips) */}
|
||||
{showRow1 && (
|
||||
<Box
|
||||
width="100%"
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
minHeight={1}
|
||||
>
|
||||
<Box flexDirection="row" flexGrow={1} flexShrink={1}>
|
||||
{!showUiDetails && showRow1_MiniMode ? (
|
||||
renderMinimalMetaRowContent()
|
||||
) : isInteractiveShellWaiting ? (
|
||||
<Box width="100%" marginLeft={1}>
|
||||
<Text color={theme.status.warning}>
|
||||
! Shell awaiting input (Tab to focus)
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
flexDirection="row"
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
flexGrow={1}
|
||||
flexShrink={0}
|
||||
marginLeft={1}
|
||||
>
|
||||
{statusNode}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box flexShrink={0} marginLeft={2} marginRight={isNarrow ? 0 : 1}>
|
||||
{!isNarrow && showAmbientLine && renderAmbientNode()}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Internal Separator Line */}
|
||||
{showRow1 &&
|
||||
showRow2 &&
|
||||
(showUiDetails || (showRow1_MiniMode && showRow2_MiniMode)) && (
|
||||
<Box width="100%">
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderTop
|
||||
borderBottom={false}
|
||||
borderLeft={false}
|
||||
borderRight={false}
|
||||
borderColor={theme.border.default}
|
||||
borderDimColor={true}
|
||||
width="100%"
|
||||
height={1}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Row 2: Mode and Context Summary */}
|
||||
{showRow2 && (
|
||||
<Box
|
||||
width="100%"
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Box flexDirection="row" alignItems="center" marginLeft={1}>
|
||||
{showUiDetails ? (
|
||||
<>
|
||||
{showApprovalIndicator && (
|
||||
<ApprovalModeIndicator
|
||||
approvalMode={showApprovalModeIndicator}
|
||||
allowPlanMode={uiState.allowPlanMode}
|
||||
/>
|
||||
)}
|
||||
{uiState.shellModeActive && (
|
||||
<Box
|
||||
marginLeft={showApprovalIndicator && !isNarrow ? 1 : 0}
|
||||
marginTop={showApprovalIndicator && isNarrow ? 1 : 0}
|
||||
>
|
||||
<ShellModeIndicator />
|
||||
</Box>
|
||||
)}
|
||||
{showRawMarkdownIndicator && (
|
||||
<Box
|
||||
marginLeft={
|
||||
(showApprovalIndicator || uiState.shellModeActive) &&
|
||||
!isNarrow
|
||||
? 1
|
||||
: 0
|
||||
}
|
||||
marginTop={
|
||||
(showApprovalIndicator || uiState.shellModeActive) &&
|
||||
isNarrow
|
||||
? 1
|
||||
: 0
|
||||
}
|
||||
>
|
||||
<RawMarkdownIndicator />
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
miniMode_ShowApprovalMode &&
|
||||
modeContentObj && (
|
||||
<Text color={modeContentObj.color}>
|
||||
● {modeContentObj.text}
|
||||
</Text>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
<Box
|
||||
marginTop={isNarrow ? 1 : 0}
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
marginLeft={isNarrow ? 1 : 0}
|
||||
>
|
||||
{showUiDetails && (
|
||||
<StatusDisplay hideContextSummary={hideContextSummary} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -210,212 +526,16 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
|
||||
{showUiDetails && <TodoTray />}
|
||||
|
||||
<Box width="100%" flexDirection="column">
|
||||
<Box
|
||||
width="100%"
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
justifyContent={isNarrow ? 'flex-start' : 'space-between'}
|
||||
>
|
||||
<Box
|
||||
marginLeft={1}
|
||||
marginRight={isNarrow ? 0 : 1}
|
||||
flexDirection="row"
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
flexGrow={1}
|
||||
>
|
||||
{showUiDetails && showLoadingIndicator && (
|
||||
<LoadingIndicator
|
||||
inline
|
||||
thought={
|
||||
uiState.streamingState ===
|
||||
StreamingState.WaitingForConfirmation
|
||||
? undefined
|
||||
: uiState.thought
|
||||
}
|
||||
currentLoadingPhrase={
|
||||
settings.merged.ui.loadingPhrases === 'off'
|
||||
? undefined
|
||||
: uiState.currentLoadingPhrase
|
||||
}
|
||||
thoughtLabel={
|
||||
inlineThinkingMode === 'full' ? 'Thinking...' : undefined
|
||||
}
|
||||
elapsedTime={uiState.elapsedTime}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Box
|
||||
marginTop={isNarrow ? 1 : 0}
|
||||
flexDirection="column"
|
||||
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
|
||||
minHeight={
|
||||
showUiDetails && shouldReserveSpaceForShortcutsHint ? 1 : 0
|
||||
}
|
||||
>
|
||||
{showUiDetails && showShortcutsHint && <ShortcutsHint />}
|
||||
</Box>
|
||||
</Box>
|
||||
{showMinimalMetaRow && (
|
||||
<Box
|
||||
justifyContent="space-between"
|
||||
width="100%"
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
>
|
||||
<Box
|
||||
marginLeft={1}
|
||||
marginRight={isNarrow ? 0 : 1}
|
||||
flexDirection="row"
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
flexGrow={1}
|
||||
>
|
||||
{showMinimalInlineLoading && (
|
||||
<LoadingIndicator
|
||||
inline
|
||||
thought={
|
||||
uiState.streamingState ===
|
||||
StreamingState.WaitingForConfirmation
|
||||
? undefined
|
||||
: uiState.thought
|
||||
}
|
||||
currentLoadingPhrase={
|
||||
settings.merged.ui.loadingPhrases === 'off'
|
||||
? undefined
|
||||
: uiState.currentLoadingPhrase
|
||||
}
|
||||
thoughtLabel={
|
||||
inlineThinkingMode === 'full' ? 'Thinking...' : undefined
|
||||
}
|
||||
elapsedTime={uiState.elapsedTime}
|
||||
/>
|
||||
)}
|
||||
{showMinimalModeBleedThrough && minimalModeBleedThrough && (
|
||||
<Text color={minimalModeBleedThrough.color}>
|
||||
● {minimalModeBleedThrough.text}
|
||||
</Text>
|
||||
)}
|
||||
{hasMinimalStatusBleedThrough && (
|
||||
<Box
|
||||
marginLeft={
|
||||
showMinimalInlineLoading || showMinimalModeBleedThrough
|
||||
? 1
|
||||
: 0
|
||||
}
|
||||
>
|
||||
<ToastDisplay />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{(showMinimalContextBleedThrough ||
|
||||
shouldReserveSpaceForShortcutsHint) && (
|
||||
<Box
|
||||
marginTop={isNarrow && showMinimalBleedThroughRow ? 1 : 0}
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
|
||||
minHeight={1}
|
||||
>
|
||||
{showMinimalContextBleedThrough && (
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={uiState.sessionStats.lastPromptTokenCount}
|
||||
model={uiState.currentModel}
|
||||
terminalWidth={uiState.terminalWidth}
|
||||
/>
|
||||
)}
|
||||
<Box
|
||||
marginLeft={
|
||||
showMinimalContextBleedThrough && !isNarrow ? 1 : 0
|
||||
}
|
||||
marginTop={showMinimalContextBleedThrough && isNarrow ? 1 : 0}
|
||||
>
|
||||
{showShortcutsHint && <ShortcutsHint />}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{showShortcutsHelp && <ShortcutsHelp />}
|
||||
{showUiDetails && <HorizontalLine />}
|
||||
{showUiDetails && (
|
||||
<Box
|
||||
justifyContent={
|
||||
settings.merged.ui.hideContextSummary
|
||||
? 'flex-start'
|
||||
: 'space-between'
|
||||
}
|
||||
width="100%"
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
>
|
||||
<Box
|
||||
marginLeft={1}
|
||||
marginRight={isNarrow ? 0 : 1}
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
flexGrow={1}
|
||||
>
|
||||
{hasToast ? (
|
||||
<ToastDisplay />
|
||||
) : (
|
||||
<Box
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
>
|
||||
{showApprovalIndicator && (
|
||||
<ApprovalModeIndicator
|
||||
approvalMode={showApprovalModeIndicator}
|
||||
allowPlanMode={uiState.allowPlanMode}
|
||||
/>
|
||||
)}
|
||||
{!showLoadingIndicator && (
|
||||
<>
|
||||
{uiState.shellModeActive && (
|
||||
<Box
|
||||
marginLeft={
|
||||
showApprovalIndicator && !isNarrow ? 1 : 0
|
||||
}
|
||||
marginTop={showApprovalIndicator && isNarrow ? 1 : 0}
|
||||
>
|
||||
<ShellModeIndicator />
|
||||
</Box>
|
||||
)}
|
||||
{showRawMarkdownIndicator && (
|
||||
<Box
|
||||
marginLeft={
|
||||
(showApprovalIndicator ||
|
||||
uiState.shellModeActive) &&
|
||||
!isNarrow
|
||||
? 1
|
||||
: 0
|
||||
}
|
||||
marginTop={
|
||||
(showApprovalIndicator ||
|
||||
uiState.shellModeActive) &&
|
||||
!isNarrow
|
||||
? 1
|
||||
: 0
|
||||
}
|
||||
>
|
||||
<RawMarkdownIndicator />
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{showShortcutsHelp && <ShortcutsHelp />}
|
||||
|
||||
<Box
|
||||
marginTop={isNarrow ? 1 : 0}
|
||||
flexDirection="column"
|
||||
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
|
||||
>
|
||||
{!showLoadingIndicator && (
|
||||
<StatusDisplay hideContextSummary={hideContextSummary} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{(showUiDetails || miniMode_ShowToast) && (
|
||||
<Box minHeight={1} marginLeft={isNarrow ? 0 : 1}>
|
||||
<ToastDisplay />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box width="100%" flexDirection="column">
|
||||
{renderStatusRow()}
|
||||
</Box>
|
||||
|
||||
{showUiDetails && uiState.showErrorDetails && (
|
||||
|
||||
@@ -16,7 +16,7 @@ import { GeminiSpinner } from './GeminiSpinner.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
|
||||
export const ConfigInitDisplay = ({
|
||||
message: initialMessage = 'Initializing...',
|
||||
message: initialMessage = 'Working...',
|
||||
}: {
|
||||
message?: string;
|
||||
}) => {
|
||||
@@ -45,14 +45,14 @@ export const ConfigInitDisplay = ({
|
||||
const suffix = remaining > 0 ? `, +${remaining} more` : '';
|
||||
const mcpMessage = `Connecting to MCP servers... (${connected}/${clients.size}) - Waiting for: ${displayedServers}${suffix}`;
|
||||
setMessage(
|
||||
initialMessage && initialMessage !== 'Initializing...'
|
||||
initialMessage && initialMessage !== 'Working...'
|
||||
? `${initialMessage} (${mcpMessage})`
|
||||
: mcpMessage,
|
||||
);
|
||||
} else {
|
||||
const mcpMessage = `Connecting to MCP servers... (${connected}/${clients.size})`;
|
||||
setMessage(
|
||||
initialMessage && initialMessage !== 'Initializing...'
|
||||
initialMessage && initialMessage !== 'Working...'
|
||||
? `${initialMessage} (${mcpMessage})`
|
||||
: mcpMessage,
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { type ReactNode } from 'react';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { MarkdownDisplay } from '../utils/MarkdownDisplay.js';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
import { DialogFooter } from './shared/DialogFooter.js';
|
||||
|
||||
type ConsentPromptProps = {
|
||||
// If a simple string is given, it will render using markdown by default.
|
||||
@@ -37,7 +38,7 @@ export const ConsentPrompt = (props: ConsentPromptProps) => {
|
||||
) : (
|
||||
prompt
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<RadioButtonSelect
|
||||
items={[
|
||||
{ label: 'Yes', value: true, key: 'Yes' },
|
||||
@@ -45,6 +46,10 @@ export const ConsentPrompt = (props: ConsentPromptProps) => {
|
||||
]}
|
||||
onSelect={onConfirm}
|
||||
/>
|
||||
<DialogFooter
|
||||
primaryAction="Enter to select"
|
||||
navigationActions="↑/↓ to navigate"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -78,32 +78,6 @@ describe('<ContextSummaryDisplay />', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should switch layout at the 80-column breakpoint', async () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
geminiMdFileCount: 1,
|
||||
contextFileNames: ['GEMINI.md'],
|
||||
mcpServers: { 'test-server': { command: 'test' } },
|
||||
ideContext: {
|
||||
workspaceState: {
|
||||
openFiles: [{ path: '/a/b/c', timestamp: Date.now() }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// At 80 columns, should be on one line
|
||||
const { lastFrame: wideFrame, unmount: unmountWide } =
|
||||
await renderWithWidth(80, props);
|
||||
expect(wideFrame().trim().includes('\n')).toBe(false);
|
||||
unmountWide();
|
||||
|
||||
// At 79 columns, should be on multiple lines
|
||||
const { lastFrame: narrowFrame, unmount: unmountNarrow } =
|
||||
await renderWithWidth(79, props);
|
||||
expect(narrowFrame().trim().includes('\n')).toBe(true);
|
||||
expect(narrowFrame().trim().split('\n').length).toBe(4);
|
||||
unmountNarrow();
|
||||
});
|
||||
it('should not render empty parts', async () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
|
||||
@@ -8,8 +8,6 @@ import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { type IdeContext, type MCPServerConfig } from '@google/gemini-cli-core';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
|
||||
|
||||
interface ContextSummaryDisplayProps {
|
||||
geminiMdFileCount: number;
|
||||
@@ -30,8 +28,6 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
||||
skillCount,
|
||||
backgroundProcessCount = 0,
|
||||
}) => {
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
const isNarrow = isNarrowWidth(terminalWidth);
|
||||
const mcpServerCount = Object.keys(mcpServers || {}).length;
|
||||
const blockedMcpServerCount = blockedMcpServers?.length || 0;
|
||||
const openFileCount = ideContext?.workspaceState?.openFiles?.length ?? 0;
|
||||
@@ -44,7 +40,7 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
||||
skillCount === 0 &&
|
||||
backgroundProcessCount === 0
|
||||
) {
|
||||
return <Text> </Text>; // Render an empty space to reserve height
|
||||
return null;
|
||||
}
|
||||
|
||||
const openFilesText = (() => {
|
||||
@@ -113,21 +109,14 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
||||
backgroundText,
|
||||
].filter(Boolean);
|
||||
|
||||
if (isNarrow) {
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
{summaryParts.map((part, index) => (
|
||||
<Text key={index} color={theme.text.secondary}>
|
||||
- {part}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box paddingX={1}>
|
||||
<Text color={theme.text.secondary}>{summaryParts.join(' | ')}</Text>
|
||||
<Box paddingX={1} flexDirection="row" flexWrap="wrap">
|
||||
{summaryParts.map((part, index) => (
|
||||
<Box key={index} flexDirection="row">
|
||||
{index > 0 && <Text color={theme.text.secondary}>{' · '}</Text>}
|
||||
<Text color={theme.text.secondary}>{part}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -23,14 +23,28 @@ interface GeminiRespondingSpinnerProps {
|
||||
*/
|
||||
nonRespondingDisplay?: string;
|
||||
spinnerType?: SpinnerName;
|
||||
/**
|
||||
* If true, we prioritize showing the nonRespondingDisplay (hook icon)
|
||||
* even if the state is Responding.
|
||||
*/
|
||||
isHookActive?: boolean;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export const GeminiRespondingSpinner: React.FC<
|
||||
GeminiRespondingSpinnerProps
|
||||
> = ({ nonRespondingDisplay, spinnerType = 'dots' }) => {
|
||||
> = ({
|
||||
nonRespondingDisplay,
|
||||
spinnerType = 'dots',
|
||||
isHookActive = false,
|
||||
color,
|
||||
}) => {
|
||||
const streamingState = useStreamingContext();
|
||||
const isScreenReaderEnabled = useIsScreenReaderEnabled();
|
||||
if (streamingState === StreamingState.Responding) {
|
||||
|
||||
// If a hook is active, we want to show the hook icon (nonRespondingDisplay)
|
||||
// to be consistent, instead of the rainbow spinner which means "Gemini is talking".
|
||||
if (streamingState === StreamingState.Responding && !isHookActive) {
|
||||
return (
|
||||
<GeminiSpinner
|
||||
spinnerType={spinnerType}
|
||||
@@ -43,7 +57,7 @@ export const GeminiRespondingSpinner: React.FC<
|
||||
return isScreenReaderEnabled ? (
|
||||
<Text>{SCREEN_READER_LOADING}</Text>
|
||||
) : (
|
||||
<Text color={theme.text.primary}>{nonRespondingDisplay}</Text>
|
||||
<Text color={color ?? theme.text.primary}>{nonRespondingDisplay}</Text>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -64,4 +64,30 @@ describe('<HookStatusDisplay />', () => {
|
||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should show generic message when only system/extension hooks are active', async () => {
|
||||
const props = {
|
||||
activeHooks: [
|
||||
{ name: 'ext-hook', eventName: 'BeforeAgent', source: 'extensions' },
|
||||
],
|
||||
};
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<HookStatusDisplay {...props} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Working...');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('matches SVG snapshot for single hook', async () => {
|
||||
const props = {
|
||||
activeHooks: [
|
||||
{ name: 'test-hook', eventName: 'BeforeAgent', source: 'user' },
|
||||
],
|
||||
};
|
||||
const renderResult = render(<HookStatusDisplay {...props} />);
|
||||
await renderResult.waitUntilReady();
|
||||
await expect(renderResult).toMatchSvgSnapshot();
|
||||
renderResult.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
|
||||
import type React from 'react';
|
||||
import { Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { type ActiveHook } from '../types.js';
|
||||
import { GENERIC_WORKING_LABEL } from '../textConstants.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
|
||||
interface HookStatusDisplayProps {
|
||||
activeHooks: ActiveHook[];
|
||||
@@ -20,20 +21,35 @@ export const HookStatusDisplay: React.FC<HookStatusDisplayProps> = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = activeHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook';
|
||||
const displayNames = activeHooks.map((hook) => {
|
||||
let name = hook.name;
|
||||
if (hook.index && hook.total && hook.total > 1) {
|
||||
name += ` (${hook.index}/${hook.total})`;
|
||||
}
|
||||
return name;
|
||||
});
|
||||
// Define which hook sources are considered "user" hooks that should be shown explicitly.
|
||||
const USER_HOOK_SOURCES = ['user', 'project', 'runtime', 'extensions'];
|
||||
|
||||
const text = `${label}: ${displayNames.join(', ')}`;
|
||||
const userHooks = activeHooks.filter(
|
||||
(h) => !h.source || USER_HOOK_SOURCES.includes(h.source),
|
||||
);
|
||||
|
||||
if (userHooks.length > 0) {
|
||||
const label = userHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook';
|
||||
const displayNames = userHooks.map((hook) => {
|
||||
let name = hook.name;
|
||||
if (hook.index && hook.total && hook.total > 1) {
|
||||
name += ` (${hook.index}/${hook.total})`;
|
||||
}
|
||||
return name;
|
||||
});
|
||||
|
||||
const text = `${label}: ${displayNames.join(', ')}`;
|
||||
return (
|
||||
<Text color={theme.text.secondary} italic={true}>
|
||||
{text}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
// If only system/extension hooks are running, show a generic message.
|
||||
return (
|
||||
<Text color={theme.status.warning} wrap="truncate">
|
||||
{text}
|
||||
<Text color={theme.text.secondary} italic={true}>
|
||||
{GENERIC_WORKING_LABEL}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Text } from 'ink';
|
||||
import { LoadingIndicator } from './LoadingIndicator.js';
|
||||
import { StreamingContext } from '../contexts/StreamingContext.js';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { vi } from 'vitest';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import * as useTerminalSize from '../hooks/useTerminalSize.js';
|
||||
|
||||
// Mock GeminiRespondingSpinner
|
||||
@@ -50,7 +50,7 @@ const renderWithContext = (
|
||||
|
||||
describe('<LoadingIndicator />', () => {
|
||||
const defaultProps = {
|
||||
currentLoadingPhrase: 'Loading...',
|
||||
currentLoadingPhrase: 'Thinking...',
|
||||
elapsedTime: 5,
|
||||
};
|
||||
|
||||
@@ -71,8 +71,8 @@ describe('<LoadingIndicator />', () => {
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('MockRespondingSpinner');
|
||||
expect(output).toContain('Loading...');
|
||||
expect(output).toContain('(esc to cancel, 5s)');
|
||||
expect(output).toContain('Thinking...');
|
||||
expect(output).toContain('esc to cancel, 5s');
|
||||
});
|
||||
|
||||
it('should render spinner (static), phrase but no time/cancel when streamingState is WaitingForConfirmation', async () => {
|
||||
@@ -108,7 +108,7 @@ describe('<LoadingIndicator />', () => {
|
||||
|
||||
it('should display the elapsedTime correctly when Responding', async () => {
|
||||
const props = {
|
||||
currentLoadingPhrase: 'Working...',
|
||||
currentLoadingPhrase: 'Thinking...',
|
||||
elapsedTime: 60,
|
||||
};
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithContext(
|
||||
@@ -116,13 +116,13 @@ describe('<LoadingIndicator />', () => {
|
||||
StreamingState.Responding,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('(esc to cancel, 1m)');
|
||||
expect(lastFrame()).toContain('esc to cancel, 1m');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should display the elapsedTime correctly in human-readable format', async () => {
|
||||
const props = {
|
||||
currentLoadingPhrase: 'Working...',
|
||||
currentLoadingPhrase: 'Thinking...',
|
||||
elapsedTime: 125,
|
||||
};
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithContext(
|
||||
@@ -130,7 +130,7 @@ describe('<LoadingIndicator />', () => {
|
||||
StreamingState.Responding,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('(esc to cancel, 2m 5s)');
|
||||
expect(lastFrame()).toContain('esc to cancel, 2m 5s');
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -196,7 +196,7 @@ describe('<LoadingIndicator />', () => {
|
||||
let output = lastFrame();
|
||||
expect(output).toContain('MockRespondingSpinner');
|
||||
expect(output).toContain('Now Responding');
|
||||
expect(output).toContain('(esc to cancel, 2s)');
|
||||
expect(output).toContain('esc to cancel, 2s');
|
||||
|
||||
// Transition to WaitingForConfirmation
|
||||
await act(async () => {
|
||||
@@ -229,7 +229,7 @@ describe('<LoadingIndicator />', () => {
|
||||
it('should display fallback phrase if thought is empty', async () => {
|
||||
const props = {
|
||||
thought: null,
|
||||
currentLoadingPhrase: 'Loading...',
|
||||
currentLoadingPhrase: 'Thinking...',
|
||||
elapsedTime: 5,
|
||||
};
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithContext(
|
||||
@@ -238,7 +238,7 @@ describe('<LoadingIndicator />', () => {
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Loading...');
|
||||
expect(output).toContain('Thinking...');
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -266,7 +266,7 @@ describe('<LoadingIndicator />', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should prepend "Thinking... " if the subject does not start with "Thinking"', async () => {
|
||||
it('should NOT prepend "Thinking... " even if the subject does not start with "Thinking"', async () => {
|
||||
const props = {
|
||||
thought: {
|
||||
subject: 'Planning the response...',
|
||||
@@ -280,7 +280,8 @@ describe('<LoadingIndicator />', () => {
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Thinking... Planning the response...');
|
||||
expect(output).toContain('Planning the response...');
|
||||
expect(output).not.toContain('Thinking... ');
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -299,7 +300,6 @@ describe('<LoadingIndicator />', () => {
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Thinking... ');
|
||||
expect(output).toContain('This should be displayed');
|
||||
expect(output).not.toContain('This should not be displayed');
|
||||
unmount();
|
||||
@@ -349,8 +349,8 @@ describe('<LoadingIndicator />', () => {
|
||||
const output = lastFrame();
|
||||
// Check for single line output
|
||||
expect(output?.trim().includes('\n')).toBe(false);
|
||||
expect(output).toContain('Loading...');
|
||||
expect(output).toContain('(esc to cancel, 5s)');
|
||||
expect(output).toContain('Thinking...');
|
||||
expect(output).toContain('esc to cancel, 5s');
|
||||
expect(output).toContain('Right');
|
||||
unmount();
|
||||
});
|
||||
@@ -373,9 +373,9 @@ describe('<LoadingIndicator />', () => {
|
||||
// 3. Right Content
|
||||
expect(lines).toHaveLength(3);
|
||||
if (lines) {
|
||||
expect(lines[0]).toContain('Loading...');
|
||||
expect(lines[0]).not.toContain('(esc to cancel, 5s)');
|
||||
expect(lines[1]).toContain('(esc to cancel, 5s)');
|
||||
expect(lines[0]).toContain('Thinking...');
|
||||
expect(lines[0]).not.toContain('esc to cancel, 5s');
|
||||
expect(lines[1]).toContain('esc to cancel, 5s');
|
||||
expect(lines[2]).toContain('Right');
|
||||
}
|
||||
unmount();
|
||||
@@ -402,5 +402,66 @@ describe('<LoadingIndicator />', () => {
|
||||
expect(lastFrame()?.includes('\n')).toBe(true);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render witty phrase after cancel and timer hint in wide layout', async () => {
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithContext(
|
||||
<LoadingIndicator
|
||||
elapsedTime={5}
|
||||
wittyPhrase="I am witty"
|
||||
showWit={true}
|
||||
currentLoadingPhrase="Thinking..."
|
||||
/>,
|
||||
StreamingState.Responding,
|
||||
120,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
// Sequence should be: Primary Text -> Cancel/Timer -> Witty Phrase
|
||||
expect(output).toContain('Thinking... (esc to cancel, 5s) I am witty');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render witty phrase after cancel and timer hint in narrow layout', async () => {
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithContext(
|
||||
<LoadingIndicator
|
||||
elapsedTime={5}
|
||||
wittyPhrase="I am witty"
|
||||
showWit={true}
|
||||
currentLoadingPhrase="Thinking..."
|
||||
/>,
|
||||
StreamingState.Responding,
|
||||
79,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
const lines = output?.trim().split('\n');
|
||||
// Expecting 3 lines:
|
||||
// 1. Spinner + Primary Text
|
||||
// 2. Cancel + Timer
|
||||
// 3. Witty Phrase
|
||||
expect(lines).toHaveLength(3);
|
||||
if (lines) {
|
||||
expect(lines[0]).toContain('Thinking...');
|
||||
expect(lines[1]).toContain('esc to cancel, 5s');
|
||||
expect(lines[2]).toContain('I am witty');
|
||||
}
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('should use spinnerIcon when provided', async () => {
|
||||
const props = {
|
||||
currentLoadingPhrase: 'Confirm action',
|
||||
elapsedTime: 10,
|
||||
spinnerIcon: '?',
|
||||
};
|
||||
const { lastFrame, waitUntilReady } = renderWithContext(
|
||||
<LoadingIndicator {...props} />,
|
||||
StreamingState.WaitingForConfirmation,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('?');
|
||||
expect(output).not.toContain('⠏');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,22 +18,34 @@ import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js';
|
||||
|
||||
interface LoadingIndicatorProps {
|
||||
currentLoadingPhrase?: string;
|
||||
wittyPhrase?: string;
|
||||
showWit?: boolean;
|
||||
showTips?: boolean;
|
||||
errorVerbosity?: 'low' | 'full';
|
||||
elapsedTime: number;
|
||||
inline?: boolean;
|
||||
rightContent?: React.ReactNode;
|
||||
thought?: ThoughtSummary | null;
|
||||
thoughtLabel?: string;
|
||||
showCancelAndTimer?: boolean;
|
||||
forceRealStatusOnly?: boolean;
|
||||
spinnerIcon?: string;
|
||||
isHookActive?: boolean;
|
||||
}
|
||||
|
||||
export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
||||
currentLoadingPhrase,
|
||||
wittyPhrase,
|
||||
showWit = false,
|
||||
elapsedTime,
|
||||
inline = false,
|
||||
rightContent,
|
||||
thought,
|
||||
thoughtLabel,
|
||||
showCancelAndTimer = true,
|
||||
forceRealStatusOnly = false,
|
||||
spinnerIcon,
|
||||
isHookActive = false,
|
||||
}) => {
|
||||
const streamingState = useStreamingContext();
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
@@ -54,15 +66,10 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
||||
? currentLoadingPhrase
|
||||
: thought?.subject
|
||||
? (thoughtLabel ?? thought.subject)
|
||||
: currentLoadingPhrase;
|
||||
const hasThoughtIndicator =
|
||||
currentLoadingPhrase !== INTERACTIVE_SHELL_WAITING_PHRASE &&
|
||||
Boolean(thought?.subject?.trim());
|
||||
// Avoid "Thinking... Thinking..." duplication if primaryText already starts with "Thinking"
|
||||
const thinkingIndicator =
|
||||
hasThoughtIndicator && !primaryText?.startsWith('Thinking')
|
||||
? 'Thinking... '
|
||||
: '';
|
||||
: currentLoadingPhrase ||
|
||||
(streamingState === StreamingState.Responding
|
||||
? 'Thinking...'
|
||||
: undefined);
|
||||
|
||||
const cancelAndTimerContent =
|
||||
showCancelAndTimer &&
|
||||
@@ -70,22 +77,35 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
||||
? `(esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)})`
|
||||
: null;
|
||||
|
||||
const wittyPhraseNode =
|
||||
!forceRealStatusOnly &&
|
||||
showWit &&
|
||||
wittyPhrase &&
|
||||
primaryText === 'Thinking...' ? (
|
||||
<Box marginLeft={1}>
|
||||
<Text color={theme.text.secondary} dimColor italic>
|
||||
{wittyPhrase}
|
||||
</Text>
|
||||
</Box>
|
||||
) : null;
|
||||
|
||||
if (inline) {
|
||||
return (
|
||||
<Box>
|
||||
<Box marginRight={1}>
|
||||
<GeminiRespondingSpinner
|
||||
nonRespondingDisplay={
|
||||
streamingState === StreamingState.WaitingForConfirmation
|
||||
spinnerIcon ??
|
||||
(streamingState === StreamingState.WaitingForConfirmation
|
||||
? '⠏'
|
||||
: ''
|
||||
: '')
|
||||
}
|
||||
isHookActive={isHookActive}
|
||||
/>
|
||||
</Box>
|
||||
{primaryText && (
|
||||
<Box flexShrink={1}>
|
||||
<Text color={theme.text.primary} italic wrap="truncate-end">
|
||||
{thinkingIndicator}
|
||||
{primaryText}
|
||||
</Text>
|
||||
{primaryText === INTERACTIVE_SHELL_WAITING_PHRASE && (
|
||||
@@ -102,6 +122,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
||||
<Text color={theme.text.secondary}>{cancelAndTimerContent}</Text>
|
||||
</>
|
||||
)}
|
||||
{wittyPhraseNode}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -118,16 +139,17 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
||||
<Box marginRight={1}>
|
||||
<GeminiRespondingSpinner
|
||||
nonRespondingDisplay={
|
||||
streamingState === StreamingState.WaitingForConfirmation
|
||||
spinnerIcon ??
|
||||
(streamingState === StreamingState.WaitingForConfirmation
|
||||
? '⠏'
|
||||
: ''
|
||||
: '')
|
||||
}
|
||||
isHookActive={isHookActive}
|
||||
/>
|
||||
</Box>
|
||||
{primaryText && (
|
||||
<Box flexShrink={1}>
|
||||
<Text color={theme.text.primary} italic wrap="truncate-end">
|
||||
{thinkingIndicator}
|
||||
{primaryText}
|
||||
</Text>
|
||||
{primaryText === INTERACTIVE_SHELL_WAITING_PHRASE && (
|
||||
@@ -144,6 +166,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
||||
<Text color={theme.text.secondary}>{cancelAndTimerContent}</Text>
|
||||
</>
|
||||
)}
|
||||
{!isNarrow && wittyPhraseNode}
|
||||
</Box>
|
||||
{!isNarrow && <Box flexGrow={1}>{/* Spacer */}</Box>}
|
||||
{!isNarrow && rightContent && <Box>{rightContent}</Box>}
|
||||
@@ -153,6 +176,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
||||
<Text color={theme.text.secondary}>{cancelAndTimerContent}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{isNarrow && wittyPhraseNode}
|
||||
{isNarrow && rightContent && <Box>{rightContent}</Box>}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
|
||||
export const ShortcutsHint: React.FC = () => {
|
||||
const { cleanUiDetailsVisible, shortcutsHelpVisible } = useUIState();
|
||||
|
||||
if (!cleanUiDetailsVisible) {
|
||||
return <Text color={theme.text.secondary}> press tab twice for more </Text>;
|
||||
}
|
||||
|
||||
const highlightColor = shortcutsHelpVisible
|
||||
? theme.text.accent
|
||||
: theme.text.secondary;
|
||||
|
||||
return <Text color={highlightColor}> ? for shortcuts </Text>;
|
||||
};
|
||||
@@ -11,9 +11,8 @@ import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { ContextSummaryDisplay } from './ContextSummaryDisplay.js';
|
||||
import { HookStatusDisplay } from './HookStatusDisplay.js';
|
||||
|
||||
interface StatusDisplayProps {
|
||||
export interface StatusDisplayProps {
|
||||
hideContextSummary: boolean;
|
||||
}
|
||||
|
||||
@@ -28,13 +27,6 @@ export const StatusDisplay: React.FC<StatusDisplayProps> = ({
|
||||
return <Text color={theme.status.error}>|⌐■_■|</Text>;
|
||||
}
|
||||
|
||||
if (
|
||||
uiState.activeHooks.length > 0 &&
|
||||
settings.merged.hooksConfig.notifications
|
||||
) {
|
||||
return <HookStatusDisplay activeHooks={uiState.activeHooks} />;
|
||||
}
|
||||
|
||||
if (!settings.merged.ui.hideContextSummary && !hideContextSummary) {
|
||||
return (
|
||||
<ContextSummaryDisplay
|
||||
|
||||
@@ -77,7 +77,7 @@ export const ToastDisplay: React.FC = () => {
|
||||
if (uiState.showIsExpandableHint) {
|
||||
const action = uiState.constrainHeight ? 'show more' : 'collapse';
|
||||
return (
|
||||
<Text color={theme.text.accent}>
|
||||
<Text color={theme.text.secondary}>
|
||||
Press Ctrl+O to {action} lines of the last response
|
||||
</Text>
|
||||
);
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Composer > Snapshots > matches snapshot in idle state 1`] = `
|
||||
" ShortcutsHint
|
||||
"
|
||||
? for shortcuts
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
ApprovalModeIndicator StatusDisplay
|
||||
ApprovalModeIndicator: default StatusDisplay
|
||||
InputPrompt: Type your message or @path/to/file
|
||||
Footer
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`Composer > Snapshots > matches snapshot in minimal UI mode 1`] = `
|
||||
" ShortcutsHint
|
||||
" press tab twice for more
|
||||
InputPrompt: Type your message or @path/to/file
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`Composer > Snapshots > matches snapshot in minimal UI mode while loading 1`] = `
|
||||
" LoadingIndicator
|
||||
"LoadingIndicator press tab twice for more
|
||||
InputPrompt: Type your message or @path/to/file
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`Composer > Snapshots > matches snapshot in narrow view 1`] = `
|
||||
"
|
||||
ShortcutsHint
|
||||
? for shortcuts
|
||||
────────────────────────────────────────
|
||||
ApprovalModeIndicator
|
||||
|
||||
StatusDisplay
|
||||
ApprovalModeIndicator: StatusDispl
|
||||
default ay
|
||||
InputPrompt: Type your message or
|
||||
@path/to/file
|
||||
Footer
|
||||
@@ -35,9 +35,10 @@ Footer
|
||||
`;
|
||||
|
||||
exports[`Composer > Snapshots > matches snapshot while streaming 1`] = `
|
||||
" LoadingIndicator: Thinking
|
||||
"
|
||||
LoadingIndicator: Thinking ? for shortcuts
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
ApprovalModeIndicator
|
||||
ApprovalModeIndicator: default StatusDisplay
|
||||
InputPrompt: Type your message or @path/to/file
|
||||
Footer
|
||||
"
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
exports[`ConfigInitDisplay > handles empty clients map 1`] = `
|
||||
"
|
||||
Spinner Initializing...
|
||||
Spinner Working...
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ConfigInitDisplay > renders initial state 1`] = `
|
||||
"
|
||||
Spinner Initializing...
|
||||
Spinner Working...
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -18,20 +18,8 @@ Spinner Connecting to MCP servers... (0/5) - Waiting for: s1, s2, s3, +2 more
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ConfigInitDisplay > truncates list of waiting servers if too many 2`] = `
|
||||
"
|
||||
Spinner Connecting to MCP servers... (0/5) - Waiting for: s1, s2, s3, +2 more
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ConfigInitDisplay > updates message on McpClientUpdate event 1`] = `
|
||||
"
|
||||
Spinner Connecting to MCP servers... (1/2) - Waiting for: server2
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ConfigInitDisplay > updates message on McpClientUpdate event 2`] = `
|
||||
"
|
||||
Spinner Connecting to MCP servers... (1/2) - Waiting for: server2
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<ContextSummaryDisplay /> > should not render empty parts 1`] = `
|
||||
" - 1 open file (ctrl+g to view)
|
||||
" 1 open file (ctrl+g to view)
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<ContextSummaryDisplay /> > should render on a single line on a wide screen 1`] = `
|
||||
" 1 open file (ctrl+g to view) | 1 GEMINI.md file | 1 MCP server | 1 skill
|
||||
" 1 open file (ctrl+g to view) · 1 GEMINI.md file · 1 MCP server · 1 skill
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<ContextSummaryDisplay /> > should render on multiple lines on a narrow screen 1`] = `
|
||||
" - 1 open file (ctrl+g to view)
|
||||
- 1 GEMINI.md file
|
||||
- 1 MCP server
|
||||
- 1 skill
|
||||
" 1 open file (ctrl+g to view) · 1 GEMINI.md file · 1 MCP server · 1 skill
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="37" viewBox="0 0 920 37">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="37" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#afafaf" textLength="225" lengthAdjust="spacingAndGlyphs" font-style="italic">Executing Hook: test-hook</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 479 B |
@@ -1,5 +1,7 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<HookStatusDisplay /> > matches SVG snapshot for single hook 1`] = `"Executing Hook: test-hook"`;
|
||||
|
||||
exports[`<HookStatusDisplay /> > should render a single executing hook 1`] = `
|
||||
"Executing Hook: test-hook
|
||||
"
|
||||
|
||||
@@ -4,7 +4,7 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'ASB mode - Focuse
|
||||
"ScrollableList
|
||||
AppHeader(full)
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊶ Shell Command Running a long command... │
|
||||
│ ⊷ Shell Command Running a long command... │
|
||||
│ │
|
||||
│ Line 10 │
|
||||
│ Line 11 │
|
||||
@@ -25,7 +25,7 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'ASB mode - Unfocu
|
||||
"ScrollableList
|
||||
AppHeader(full)
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊶ Shell Command Running a long command... │
|
||||
│ ⊷ Shell Command Running a long command... │
|
||||
│ │
|
||||
│ Line 10 │
|
||||
│ Line 11 │
|
||||
@@ -45,7 +45,7 @@ AppHeader(full)
|
||||
exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Constrained height' 1`] = `
|
||||
"AppHeader(full)
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊶ Shell Command Running a long command... │
|
||||
│ ⊷ Shell Command Running a long command... │
|
||||
│ │
|
||||
│ ... first 11 lines hidden (Ctrl+O to show) ... │
|
||||
│ Line 12 │
|
||||
@@ -64,7 +64,7 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Con
|
||||
exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Unconstrained height' 1`] = `
|
||||
"AppHeader(full)
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊶ Shell Command Running a long command... │
|
||||
│ ⊷ Shell Command Running a long command... │
|
||||
│ │
|
||||
│ Line 1 │
|
||||
│ Line 2 │
|
||||
|
||||
@@ -11,7 +11,7 @@ exports[`StatusDisplay > renders ContextSummaryDisplay by default 1`] = `
|
||||
`;
|
||||
|
||||
exports[`StatusDisplay > renders HookStatusDisplay when hooks are active 1`] = `
|
||||
"Mock Hook Status Display
|
||||
"Mock Context Summary Display (Skills: 2, Shells: 0)
|
||||
"
|
||||
`;
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ exports[`ToolConfirmationQueue > calculates availableContentHeight based on avai
|
||||
│ 3. Modify with external editor │
|
||||
│ 4. No, suggest changes (esc) │
|
||||
│ │
|
||||
│ Enter to select · ↑/↓ to navigate · Esc to cancel │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
Press Ctrl+O to show more lines
|
||||
"
|
||||
@@ -38,6 +40,8 @@ exports[`ToolConfirmationQueue > does not render expansion hint when constrainHe
|
||||
│ 3. Modify with external editor │
|
||||
│ 4. No, suggest changes (esc) │
|
||||
│ │
|
||||
│ Enter to select · ↑/↓ to navigate · Esc to cancel │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
@@ -106,6 +110,8 @@ exports[`ToolConfirmationQueue > renders expansion hint when content is long and
|
||||
│ 3. Modify with external editor │
|
||||
│ 4. No, suggest changes (esc) │
|
||||
│ │
|
||||
│ Enter to select · ↑/↓ to navigate · Esc to cancel │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
Press Ctrl+O to show more lines
|
||||
"
|
||||
@@ -124,6 +130,8 @@ exports[`ToolConfirmationQueue > renders the confirming tool with progress indic
|
||||
│ 2. Allow for this session │
|
||||
│ 3. No, suggest changes (esc) │
|
||||
│ │
|
||||
│ Enter to select · ↑/↓ to navigate · Esc to cancel │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -10,10 +10,12 @@ import { theme } from '../../semantic-colors.js';
|
||||
|
||||
interface HorizontalLineProps {
|
||||
color?: string;
|
||||
dim?: boolean;
|
||||
}
|
||||
|
||||
export const HorizontalLine: React.FC<HorizontalLineProps> = ({
|
||||
color = theme.border.default,
|
||||
dim = false,
|
||||
}) => (
|
||||
<Box
|
||||
width="100%"
|
||||
@@ -23,5 +25,6 @@ export const HorizontalLine: React.FC<HorizontalLineProps> = ({
|
||||
borderLeft={false}
|
||||
borderRight={false}
|
||||
borderColor={color}
|
||||
borderDimColor={dim}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -6,160 +6,160 @@
|
||||
|
||||
export const INFORMATIVE_TIPS = [
|
||||
//Settings tips start here
|
||||
'Set your preferred editor for opening files (/settings)…',
|
||||
'Toggle Vim mode for a modal editing experience (/settings)…',
|
||||
'Disable automatic updates if you prefer manual control (/settings)…',
|
||||
'Turn off nagging update notifications (settings.json)…',
|
||||
'Enable checkpointing to recover your session after a crash (settings.json)…',
|
||||
'Change CLI output format to JSON for scripting (/settings)…',
|
||||
'Personalize your CLI with a new color theme (/settings)…',
|
||||
'Create and use your own custom themes (settings.json)…',
|
||||
'Hide window title for a more minimal UI (/settings)…',
|
||||
"Don't like these tips? You can hide them (/settings)…",
|
||||
'Hide the startup banner for a cleaner launch (/settings)…',
|
||||
'Hide the context summary above the input (/settings)…',
|
||||
'Reclaim vertical space by hiding the footer (/settings)…',
|
||||
'Hide individual footer elements like CWD or sandbox status (/settings)…',
|
||||
'Hide the context window percentage in the footer (/settings)…',
|
||||
'Show memory usage for performance monitoring (/settings)…',
|
||||
'Show line numbers in the chat for easier reference (/settings)…',
|
||||
'Show citations to see where the model gets information (/settings)…',
|
||||
'Customize loading phrases: tips, witty, all, or off (/settings)…',
|
||||
'Add custom witty phrases to the loading screen (settings.json)…',
|
||||
'Use alternate screen buffer to preserve shell history (/settings)…',
|
||||
'Choose a specific Gemini model for conversations (/settings)…',
|
||||
'Limit the number of turns in your session history (/settings)…',
|
||||
'Automatically summarize large tool outputs to save tokens (settings.json)…',
|
||||
'Control when chat history gets compressed based on context compression threshold (settings.json)…',
|
||||
'Define custom context file names, like CONTEXT.md (settings.json)…',
|
||||
'Set max directories to scan for context files (/settings)…',
|
||||
'Expand your workspace with additional directories (/directory)…',
|
||||
'Control how /memory reload loads context files (/settings)…',
|
||||
'Toggle respect for .gitignore files in context (/settings)…',
|
||||
'Toggle respect for .geminiignore files in context (/settings)…',
|
||||
'Enable recursive file search for @-file completions (/settings)…',
|
||||
'Disable fuzzy search when searching for files (/settings)…',
|
||||
'Run tools in a secure sandbox environment (settings.json)…',
|
||||
'Use an interactive terminal for shell commands (/settings)…',
|
||||
'Show color in shell command output (/settings)…',
|
||||
'Automatically accept safe read-only tool calls (/settings)…',
|
||||
'Restrict available built-in tools (settings.json)…',
|
||||
'Exclude specific tools from being used (settings.json)…',
|
||||
'Bypass confirmation for trusted tools (settings.json)…',
|
||||
'Use a custom command for tool discovery (settings.json)…',
|
||||
'Define a custom command for calling discovered tools (settings.json)…',
|
||||
'Define and manage connections to MCP servers (settings.json)…',
|
||||
'Enable folder trust to enhance security (/settings)…',
|
||||
'Disable YOLO mode to enforce confirmations (settings.json)…',
|
||||
'Block Git extensions for enhanced security (settings.json)…',
|
||||
'Change your authentication method (/settings)…',
|
||||
'Enforce auth type for enterprise use (settings.json)…',
|
||||
'Let Node.js auto-configure memory (settings.json)…',
|
||||
'Retry on fetch failed errors automatically (settings.json)…',
|
||||
'Customize the DNS resolution order (settings.json)…',
|
||||
'Exclude env vars from the context (settings.json)…',
|
||||
'Configure a custom command for filing bug reports (settings.json)…',
|
||||
'Enable or disable telemetry collection (/settings)…',
|
||||
'Send telemetry data to a local file or GCP (settings.json)…',
|
||||
'Configure the OTLP endpoint for telemetry (settings.json)…',
|
||||
'Choose whether to log prompt content (settings.json)…',
|
||||
'Enable AI-powered prompt completion while typing (/settings)…',
|
||||
'Enable debug logging of keystrokes to the console (/settings)…',
|
||||
'Enable automatic session cleanup of old conversations (/settings)…',
|
||||
'Show Gemini CLI status in the terminal window title (/settings)…',
|
||||
'Use the entire width of the terminal for output (/settings)…',
|
||||
'Enable screen reader mode for better accessibility (/settings)…',
|
||||
'Skip the next speaker check for faster responses (/settings)…',
|
||||
'Use ripgrep for faster file content search (/settings)…',
|
||||
'Enable truncation of large tool outputs to save tokens (/settings)…',
|
||||
'Set the character threshold for truncating tool outputs (/settings)…',
|
||||
'Set the number of lines to keep when truncating outputs (/settings)…',
|
||||
'Enable policy-based tool confirmation via message bus (/settings)…',
|
||||
'Enable write_todos_list tool to generate task lists (/settings)…',
|
||||
'Enable experimental subagents for task delegation (/settings)…',
|
||||
'Enable extension management features (settings.json)…',
|
||||
'Enable extension reloading within the CLI session (settings.json)…',
|
||||
'Set your preferred editor for opening files (/settings)',
|
||||
'Toggle Vim mode for a modal editing experience (/settings)',
|
||||
'Disable automatic updates if you prefer manual control (/settings)',
|
||||
'Turn off nagging update notifications (settings.json)',
|
||||
'Enable checkpointing to recover your session after a crash (settings.json)',
|
||||
'Change CLI output format to JSON for scripting (/settings)',
|
||||
'Personalize your CLI with a new color theme (/settings)',
|
||||
'Create and use your own custom themes (settings.json)',
|
||||
'Hide window title for a more minimal UI (/settings)',
|
||||
"Don't like these tips? You can hide them (/settings)",
|
||||
'Hide the startup banner for a cleaner launch (/settings)',
|
||||
'Hide the context summary above the input (/settings)',
|
||||
'Reclaim vertical space by hiding the footer (/settings)',
|
||||
'Hide individual footer elements like CWD or sandbox status (/settings)',
|
||||
'Hide the context window percentage in the footer (/settings)',
|
||||
'Show memory usage for performance monitoring (/settings)',
|
||||
'Show line numbers in the chat for easier reference (/settings)',
|
||||
'Show citations to see where the model gets information (/settings)',
|
||||
'Customize loading phrases: tips, witty, all, or off (/settings)',
|
||||
'Add custom witty phrases to the loading screen (settings.json)',
|
||||
'Use alternate screen buffer to preserve shell history (/settings)',
|
||||
'Choose a specific Gemini model for conversations (/settings)',
|
||||
'Limit the number of turns in your session history (/settings)',
|
||||
'Automatically summarize large tool outputs to save tokens (settings.json)',
|
||||
'Control when chat history gets compressed based on token usage (settings.json)',
|
||||
'Define custom context file names, like CONTEXT.md (settings.json)',
|
||||
'Set max directories to scan for context files (/settings)',
|
||||
'Expand your workspace with additional directories (/directory)',
|
||||
'Control how /memory reload loads context files (/settings)',
|
||||
'Toggle respect for .gitignore files in context (/settings)',
|
||||
'Toggle respect for .geminiignore files in context (/settings)',
|
||||
'Enable recursive file search for @-file completions (/settings)',
|
||||
'Disable fuzzy search when searching for files (/settings)',
|
||||
'Run tools in a secure sandbox environment (settings.json)',
|
||||
'Use an interactive terminal for shell commands (/settings)',
|
||||
'Show color in shell command output (/settings)',
|
||||
'Automatically accept safe read-only tool calls (/settings)',
|
||||
'Restrict available built-in tools (settings.json)',
|
||||
'Exclude specific tools from being used (settings.json)',
|
||||
'Bypass confirmation for trusted tools (settings.json)',
|
||||
'Use a custom command for tool discovery (settings.json)',
|
||||
'Define a custom command for calling discovered tools (settings.json)',
|
||||
'Define and manage connections to MCP servers (settings.json)',
|
||||
'Enable folder trust to enhance security (/settings)',
|
||||
'Disable YOLO mode to enforce confirmations (settings.json)',
|
||||
'Block Git extensions for enhanced security (settings.json)',
|
||||
'Change your authentication method (/settings)',
|
||||
'Enforce auth type for enterprise use (settings.json)',
|
||||
'Let Node.js auto-configure memory (settings.json)',
|
||||
'Retry on fetch failed errors automatically (settings.json)',
|
||||
'Customize the DNS resolution order (settings.json)',
|
||||
'Exclude env vars from the context (settings.json)',
|
||||
'Configure a custom command for filing bug reports (settings.json)',
|
||||
'Enable or disable telemetry collection (/settings)',
|
||||
'Send telemetry data to a local file or GCP (settings.json)',
|
||||
'Configure the OTLP endpoint for telemetry (settings.json)',
|
||||
'Choose whether to log prompt content (settings.json)',
|
||||
'Enable AI-powered prompt completion while typing (/settings)',
|
||||
'Enable debug logging of keystrokes to the console (/settings)',
|
||||
'Enable automatic session cleanup of old conversations (/settings)',
|
||||
'Show Gemini CLI status in the terminal window title (/settings)',
|
||||
'Use the entire width of the terminal for output (/settings)',
|
||||
'Enable screen reader mode for better accessibility (/settings)',
|
||||
'Skip the next speaker check for faster responses (/settings)',
|
||||
'Use ripgrep for faster file content search (/settings)',
|
||||
'Enable truncation of large tool outputs to save tokens (/settings)',
|
||||
'Set the character threshold for truncating tool outputs (/settings)',
|
||||
'Set the number of lines to keep when truncating outputs (/settings)',
|
||||
'Enable policy-based tool confirmation via message bus (/settings)',
|
||||
'Enable write_todos_list tool to generate task lists (/settings)',
|
||||
'Enable experimental subagents for task delegation (/settings)',
|
||||
'Enable extension management features (settings.json)',
|
||||
'Enable extension reloading within the CLI session (settings.json)',
|
||||
//Settings tips end here
|
||||
// Keyboard shortcut tips start here
|
||||
'Close dialogs and suggestions with Esc…',
|
||||
'Cancel a request with Ctrl+C, or press twice to exit…',
|
||||
'Exit the app with Ctrl+D on an empty line…',
|
||||
'Clear your screen at any time with Ctrl+L…',
|
||||
'Toggle the debug console display with F12…',
|
||||
'Toggle the todo list display with Ctrl+T…',
|
||||
'See full, untruncated responses with Ctrl+O…',
|
||||
'Toggle auto-approval (YOLO mode) for all tools with Ctrl+Y…',
|
||||
'Cycle through approval modes (Default, Auto-Edit, Plan) with Shift+Tab…',
|
||||
'Toggle Markdown rendering (raw markdown mode) with Alt+M…',
|
||||
'Toggle shell mode by typing ! in an empty prompt…',
|
||||
'Insert a newline with a backslash (\\) followed by Enter…',
|
||||
'Navigate your prompt history with the Up and Down arrows…',
|
||||
'You can also use Ctrl+P (up) and Ctrl+N (down) for history…',
|
||||
'Search through command history with Ctrl+R…',
|
||||
'Accept an autocomplete suggestion with Tab or Enter…',
|
||||
'Move to the start of the line with Ctrl+A or Home…',
|
||||
'Move to the end of the line with Ctrl+E or End…',
|
||||
'Move one character left or right with Ctrl+B/F or the arrow keys…',
|
||||
'Move one word left or right with Ctrl+Left/Right Arrow…',
|
||||
'Delete the character to the left with Ctrl+H or Backspace…',
|
||||
'Delete the character to the right with Ctrl+D or Delete…',
|
||||
'Delete the word to the left of the cursor with Ctrl+W…',
|
||||
'Delete the word to the right of the cursor with Ctrl+Delete…',
|
||||
'Delete from the cursor to the start of the line with Ctrl+U…',
|
||||
'Delete from the cursor to the end of the line with Ctrl+K…',
|
||||
'Clear the entire input prompt with a double-press of Esc…',
|
||||
'Paste from your clipboard with Ctrl+V…',
|
||||
'Undo text edits in the input with Alt+Z or Cmd+Z…',
|
||||
'Redo undone text edits with Shift+Alt+Z or Shift+Cmd+Z…',
|
||||
'Open the current prompt in an external editor with Ctrl+X…',
|
||||
'In menus, move up/down with k/j or the arrow keys…',
|
||||
'In menus, select an item by typing its number…',
|
||||
"If you're using an IDE, see the context with Ctrl+G…",
|
||||
'Toggle background shells with Ctrl+B or /shells...',
|
||||
'Toggle the background shell process list with Ctrl+L...',
|
||||
'Close dialogs and suggestions with Esc',
|
||||
'Cancel a request with Ctrl+C, or press twice to exit',
|
||||
'Exit the app with Ctrl+D on an empty line',
|
||||
'Clear your screen at any time with Ctrl+L',
|
||||
'Toggle the debug console display with F12',
|
||||
'Toggle the todo list display with Ctrl+T',
|
||||
'See full, untruncated responses with Ctrl+O',
|
||||
'Toggle auto-approval (YOLO mode) for all tools with Ctrl+Y',
|
||||
'Cycle through approval modes (Default, Auto-Edit, Plan) with Shift+Tab',
|
||||
'Toggle Markdown rendering (raw markdown mode) with Alt+M',
|
||||
'Toggle shell mode by typing ! in an empty prompt',
|
||||
'Insert a newline with a backslash (\\) followed by Enter',
|
||||
'Navigate your prompt history with the Up and Down arrows',
|
||||
'You can also use Ctrl+P (up) and Ctrl+N (down) for history',
|
||||
'Search through command history with Ctrl+R',
|
||||
'Accept an autocomplete suggestion with Tab or Enter',
|
||||
'Move to the start of the line with Ctrl+A or Home',
|
||||
'Move to the end of the line with Ctrl+E or End',
|
||||
'Move one character left or right with Ctrl+B/F or the arrow keys',
|
||||
'Move one word left or right with Ctrl+Left/Right Arrow',
|
||||
'Delete the character to the left with Ctrl+H or Backspace',
|
||||
'Delete the character to the right with Ctrl+D or Delete',
|
||||
'Delete the word to the left of the cursor with Ctrl+W',
|
||||
'Delete the word to the right of the cursor with Ctrl+Delete',
|
||||
'Delete from the cursor to the start of the line with Ctrl+U',
|
||||
'Delete from the cursor to the end of the line with Ctrl+K',
|
||||
'Clear the entire input prompt with a double-press of Esc',
|
||||
'Paste from your clipboard with Ctrl+V',
|
||||
'Undo text edits in the input with Alt+Z or Cmd+Z',
|
||||
'Redo undone text edits with Shift+Alt+Z or Shift+Cmd+Z',
|
||||
'Open the current prompt in an external editor with Ctrl+X',
|
||||
'In menus, move up/down with k/j or the arrow keys',
|
||||
'In menus, select an item by typing its number',
|
||||
"If you're using an IDE, see the context with Ctrl+G",
|
||||
'Toggle background shells with Ctrl+B or /shells',
|
||||
'Toggle the background shell process list with Ctrl+L',
|
||||
// Keyboard shortcut tips end here
|
||||
// Command tips start here
|
||||
'Show version info with /about…',
|
||||
'Change your authentication method with /auth…',
|
||||
'File a bug report directly with /bug…',
|
||||
'List your saved chat checkpoints with /resume list…',
|
||||
'Save your current conversation with /resume save <tag>…',
|
||||
'Resume a saved conversation with /resume resume <tag>…',
|
||||
'Delete a conversation checkpoint with /resume delete <tag>…',
|
||||
'Share your conversation to a file with /resume share <file>…',
|
||||
'Clear the screen and history with /clear…',
|
||||
'Save tokens by summarizing the context with /compress…',
|
||||
'Copy the last response to your clipboard with /copy…',
|
||||
'Open the full documentation in your browser with /docs…',
|
||||
'Add directories to your workspace with /directory add <path>…',
|
||||
'Show all directories in your workspace with /directory show…',
|
||||
'Use /dir as a shortcut for /directory…',
|
||||
'Set your preferred external editor with /editor…',
|
||||
'List all active extensions with /extensions list…',
|
||||
'Update all or specific extensions with /extensions update…',
|
||||
'Get help on commands with /help…',
|
||||
'Manage IDE integration with /ide…',
|
||||
'Create a project-specific GEMINI.md file with /init…',
|
||||
'List configured MCP servers and tools with /mcp list…',
|
||||
'Authenticate with an OAuth-enabled MCP server with /mcp auth…',
|
||||
'Reload MCP servers with /mcp reload…',
|
||||
'See the current instructional context with /memory show…',
|
||||
'Add content to the instructional memory with /memory add…',
|
||||
'Reload instructional context from GEMINI.md files with /memory reload…',
|
||||
'List the paths of the GEMINI.md files in use with /memory list…',
|
||||
'Choose your Gemini model with /model…',
|
||||
'Display the privacy notice with /privacy…',
|
||||
'Restore project files to a previous state with /restore…',
|
||||
'Exit the CLI with /quit or /exit…',
|
||||
'Check model-specific usage stats with /stats model…',
|
||||
'Check tool-specific usage stats with /stats tools…',
|
||||
"Change the CLI's color theme with /theme…",
|
||||
'List all available tools with /tools…',
|
||||
'View and edit settings with the /settings editor…',
|
||||
'Toggle Vim keybindings on and off with /vim…',
|
||||
'Set up GitHub Actions with /setup-github…',
|
||||
'Configure terminal keybindings for multiline input with /terminal-setup…',
|
||||
'Find relevant documentation with /find-docs…',
|
||||
'Execute any shell command with !<command>…',
|
||||
'Show version info with /about',
|
||||
'Change your authentication method with /auth',
|
||||
'File a bug report directly with /bug',
|
||||
'List your saved chat checkpoints with /resume list',
|
||||
'Save your current conversation with /resume save <tag>',
|
||||
'Resume a saved conversation with /resume resume <tag>',
|
||||
'Delete a conversation checkpoint with /resume delete <tag>',
|
||||
'Share your conversation to a file with /resume share <file>',
|
||||
'Clear the screen and history with /clear',
|
||||
'Save tokens by summarizing the context with /compress',
|
||||
'Copy the last response to your clipboard with /copy',
|
||||
'Open the full documentation in your browser with /docs',
|
||||
'Add directories to your workspace with /directory add <path>',
|
||||
'Show all directories in your workspace with /directory show',
|
||||
'Use /dir as a shortcut for /directory',
|
||||
'Set your preferred external editor with /editor',
|
||||
'List all active extensions with /extensions list',
|
||||
'Update all or specific extensions with /extensions update',
|
||||
'Get help on commands with /help',
|
||||
'Manage IDE integration with /ide',
|
||||
'Create a project-specific GEMINI.md file with /init',
|
||||
'List configured MCP servers and tools with /mcp list',
|
||||
'Authenticate with an OAuth-enabled MCP server with /mcp auth',
|
||||
'Reload MCP servers with /mcp reload',
|
||||
'See the current instructional context with /memory show',
|
||||
'Add content to the instructional memory with /memory add',
|
||||
'Reload instructional context from GEMINI.md files with /memory reload',
|
||||
'List the paths of the GEMINI.md files in use with /memory list',
|
||||
'Choose your Gemini model with /model',
|
||||
'Display the privacy notice with /privacy',
|
||||
'Restore project files to a previous state with /restore',
|
||||
'Exit the CLI with /quit or /exit',
|
||||
'Check model-specific usage stats with /stats model',
|
||||
'Check tool-specific usage stats with /stats tools',
|
||||
"Change the CLI's color theme with /theme",
|
||||
'List all available tools with /tools',
|
||||
'View and edit settings with the /settings editor',
|
||||
'Toggle Vim keybindings on and off with /vim',
|
||||
'Set up GitHub Actions with /setup-github',
|
||||
'Configure terminal keybindings for multiline input with /terminal-setup',
|
||||
'Find relevant documentation with /find-docs',
|
||||
'Execute any shell command with !<command>',
|
||||
// Command tips end here
|
||||
];
|
||||
|
||||
@@ -6,113 +6,113 @@
|
||||
|
||||
export const WITTY_LOADING_PHRASES = [
|
||||
"I'm Feeling Lucky",
|
||||
'Shipping awesomeness… ',
|
||||
'Painting the serifs back on…',
|
||||
'Navigating the slime mold…',
|
||||
'Consulting the digital spirits…',
|
||||
'Reticulating splines…',
|
||||
'Warming up the AI hamsters…',
|
||||
'Asking the magic conch shell…',
|
||||
'Generating witty retort…',
|
||||
'Polishing the algorithms…',
|
||||
"Don't rush perfection (or my code)…",
|
||||
'Brewing fresh bytes…',
|
||||
'Counting electrons…',
|
||||
'Engaging cognitive processors…',
|
||||
'Checking for syntax errors in the universe…',
|
||||
'One moment, optimizing humor…',
|
||||
'Shuffling punchlines…',
|
||||
'Untangling neural nets…',
|
||||
'Compiling brilliance…',
|
||||
'Loading wit.exe…',
|
||||
'Summoning the cloud of wisdom…',
|
||||
'Preparing a witty response…',
|
||||
"Just a sec, I'm debugging reality…",
|
||||
'Confuzzling the options…',
|
||||
'Tuning the cosmic frequencies…',
|
||||
'Crafting a response worthy of your patience…',
|
||||
'Compiling the 1s and 0s…',
|
||||
'Resolving dependencies… and existential crises…',
|
||||
'Defragmenting memories… both RAM and personal…',
|
||||
'Rebooting the humor module…',
|
||||
'Caching the essentials (mostly cat memes)…',
|
||||
'Shipping awesomeness',
|
||||
'Painting the serifs back on',
|
||||
'Navigating the slime mold',
|
||||
'Consulting the digital spirits',
|
||||
'Reticulating splines',
|
||||
'Warming up the AI hamsters',
|
||||
'Asking the magic conch shell',
|
||||
'Generating witty retort',
|
||||
'Polishing the algorithms',
|
||||
"Don't rush perfection (or my code)",
|
||||
'Brewing fresh bytes',
|
||||
'Counting electrons',
|
||||
'Engaging cognitive processors',
|
||||
'Checking for syntax errors in the universe',
|
||||
'One moment, optimizing humor',
|
||||
'Shuffling punchlines',
|
||||
'Untangling neural nets',
|
||||
'Compiling brilliance',
|
||||
'Loading wit.exe',
|
||||
'Summoning the cloud of wisdom',
|
||||
'Preparing a witty response',
|
||||
"Just a sec, I'm debugging reality",
|
||||
'Confuzzling the options',
|
||||
'Tuning the cosmic frequencies',
|
||||
'Crafting a response worthy of your patience',
|
||||
'Compiling the 1s and 0s',
|
||||
'Resolving dependencies… and existential crises',
|
||||
'Defragmenting memories… both RAM and personal',
|
||||
'Rebooting the humor module',
|
||||
'Caching the essentials (mostly cat memes)',
|
||||
'Optimizing for ludicrous speed',
|
||||
"Swapping bits… don't tell the bytes…",
|
||||
'Garbage collecting… be right back…',
|
||||
'Assembling the interwebs…',
|
||||
'Converting coffee into code…',
|
||||
'Updating the syntax for reality…',
|
||||
'Rewiring the synapses…',
|
||||
'Looking for a misplaced semicolon…',
|
||||
"Greasin' the cogs of the machine…",
|
||||
'Pre-heating the servers…',
|
||||
'Calibrating the flux capacitor…',
|
||||
'Engaging the improbability drive…',
|
||||
'Channeling the Force…',
|
||||
'Aligning the stars for optimal response…',
|
||||
'So say we all…',
|
||||
'Loading the next great idea…',
|
||||
"Just a moment, I'm in the zone…",
|
||||
'Preparing to dazzle you with brilliance…',
|
||||
"Just a tick, I'm polishing my wit…",
|
||||
"Hold tight, I'm crafting a masterpiece…",
|
||||
"Just a jiffy, I'm debugging the universe…",
|
||||
"Just a moment, I'm aligning the pixels…",
|
||||
"Just a sec, I'm optimizing the humor…",
|
||||
"Just a moment, I'm tuning the algorithms…",
|
||||
'Warp speed engaged…',
|
||||
'Mining for more Dilithium crystals…',
|
||||
"Don't panic…",
|
||||
'Following the white rabbit…',
|
||||
'The truth is in here… somewhere…',
|
||||
'Blowing on the cartridge…',
|
||||
"Swapping bits… don't tell the bytes",
|
||||
'Garbage collecting… be right back',
|
||||
'Assembling the interwebs',
|
||||
'Converting coffee into code',
|
||||
'Updating the syntax for reality',
|
||||
'Rewiring the synapses',
|
||||
'Looking for a misplaced semicolon',
|
||||
"Greasin' the cogs of the machine",
|
||||
'Pre-heating the servers',
|
||||
'Calibrating the flux capacitor',
|
||||
'Engaging the improbability drive',
|
||||
'Channeling the Force',
|
||||
'Aligning the stars for optimal response',
|
||||
'So say we all',
|
||||
'Loading the next great idea',
|
||||
"Just a moment, I'm in the zone",
|
||||
'Preparing to dazzle you with brilliance',
|
||||
"Just a tick, I'm polishing my wit",
|
||||
"Hold tight, I'm crafting a masterpiece",
|
||||
"Just a jiffy, I'm debugging the universe",
|
||||
"Just a moment, I'm aligning the pixels",
|
||||
"Just a sec, I'm optimizing the humor",
|
||||
"Just a moment, I'm tuning the algorithms",
|
||||
'Warp speed engaged',
|
||||
'Mining for more Dilithium crystals',
|
||||
"Don't panic",
|
||||
'Following the white rabbit',
|
||||
'The truth is in here… somewhere',
|
||||
'Blowing on the cartridge',
|
||||
'Loading… Do a barrel roll!',
|
||||
'Waiting for the respawn…',
|
||||
'Finishing the Kessel Run in less than 12 parsecs…',
|
||||
"The cake is not a lie, it's just still loading…",
|
||||
'Fiddling with the character creation screen…',
|
||||
"Just a moment, I'm finding the right meme…",
|
||||
"Pressing 'A' to continue…",
|
||||
'Herding digital cats…',
|
||||
'Polishing the pixels…',
|
||||
'Finding a suitable loading screen pun…',
|
||||
'Distracting you with this witty phrase…',
|
||||
'Almost there… probably…',
|
||||
'Our hamsters are working as fast as they can…',
|
||||
'Giving Cloudy a pat on the head…',
|
||||
'Petting the cat…',
|
||||
'Rickrolling my boss…',
|
||||
'Slapping the bass…',
|
||||
'Tasting the snozberries…',
|
||||
"I'm going the distance, I'm going for speed…",
|
||||
'Is this the real life? Is this just fantasy?…',
|
||||
"I've got a good feeling about this…",
|
||||
'Poking the bear…',
|
||||
'Doing research on the latest memes…',
|
||||
'Figuring out how to make this more witty…',
|
||||
'Hmmm… let me think…',
|
||||
'What do you call a fish with no eyes? A fsh…',
|
||||
'Why did the computer go to therapy? It had too many bytes…',
|
||||
"Why don't programmers like nature? It has too many bugs…",
|
||||
'Why do programmers prefer dark mode? Because light attracts bugs…',
|
||||
'Why did the developer go broke? Because they used up all their cache…',
|
||||
"What can you do with a broken pencil? Nothing, it's pointless…",
|
||||
'Applying percussive maintenance…',
|
||||
'Searching for the correct USB orientation…',
|
||||
'Ensuring the magic smoke stays inside the wires…',
|
||||
'Rewriting in Rust for no particular reason…',
|
||||
'Trying to exit Vim…',
|
||||
'Spinning up the hamster wheel…',
|
||||
"That's not a bug, it's an undocumented feature…",
|
||||
'Waiting for the respawn',
|
||||
'Finishing the Kessel Run in less than 12 parsecs',
|
||||
"The cake is not a lie, it's just still loading",
|
||||
'Fiddling with the character creation screen',
|
||||
"Just a moment, I'm finding the right meme",
|
||||
"Pressing 'A' to continue",
|
||||
'Herding digital cats',
|
||||
'Polishing the pixels',
|
||||
'Finding a suitable loading screen pun',
|
||||
'Distracting you with this witty phrase',
|
||||
'Almost there… probably',
|
||||
'Our hamsters are working as fast as they can',
|
||||
'Giving Cloudy a pat on the head',
|
||||
'Petting the cat',
|
||||
'Rickrolling my boss',
|
||||
'Slapping the bass',
|
||||
'Tasting the snozberries',
|
||||
"I'm going the distance, I'm going for speed",
|
||||
'Is this the real life? Is this just fantasy?',
|
||||
"I've got a good feeling about this",
|
||||
'Poking the bear',
|
||||
'Doing research on the latest memes',
|
||||
'Figuring out how to make this more witty',
|
||||
'Hmmm… let me think',
|
||||
'What do you call a fish with no eyes? A fsh',
|
||||
'Why did the computer go to therapy? It had too many bytes',
|
||||
"Why don't programmers like nature? It has too many bugs",
|
||||
'Why do programmers prefer dark mode? Because light attracts bugs',
|
||||
'Why did the developer go broke? Because they used up all their cache',
|
||||
"What can you do with a broken pencil? Nothing, it's pointless",
|
||||
'Applying percussive maintenance',
|
||||
'Searching for the correct USB orientation',
|
||||
'Ensuring the magic smoke stays inside the wires',
|
||||
'Rewriting in Rust for no particular reason',
|
||||
'Trying to exit Vim',
|
||||
'Spinning up the hamster wheel',
|
||||
"That's not a bug, it's an undocumented feature",
|
||||
'Engage.',
|
||||
"I'll be back… with an answer.",
|
||||
'My other process is a TARDIS…',
|
||||
'Communing with the machine spirit…',
|
||||
'Letting the thoughts marinate…',
|
||||
'Just remembered where I put my keys…',
|
||||
'Pondering the orb…',
|
||||
'My other process is a TARDIS',
|
||||
'Communing with the machine spirit',
|
||||
'Letting the thoughts marinate',
|
||||
'Just remembered where I put my keys',
|
||||
'Pondering the orb',
|
||||
"I've seen things you people wouldn't believe… like a user who reads loading messages.",
|
||||
'Initiating thoughtful gaze…',
|
||||
'Initiating thoughtful gaze',
|
||||
"What's a computer's favorite snack? Microchips.",
|
||||
"Why do Java developers wear glasses? Because they don't C#.",
|
||||
'Charging the laser… pew pew!',
|
||||
@@ -120,18 +120,18 @@ export const WITTY_LOADING_PHRASES = [
|
||||
'Looking for an adult superviso… I mean, processing.',
|
||||
'Making it go beep boop.',
|
||||
'Buffering… because even AIs need a moment.',
|
||||
'Entangling quantum particles for a faster response…',
|
||||
'Entangling quantum particles for a faster response',
|
||||
'Polishing the chrome… on the algorithms.',
|
||||
'Are you not entertained? (Working on it!)',
|
||||
'Summoning the code gremlins… to help, of course.',
|
||||
'Just waiting for the dial-up tone to finish…',
|
||||
'Just waiting for the dial-up tone to finish',
|
||||
'Recalibrating the humor-o-meter.',
|
||||
'My other loading screen is even funnier.',
|
||||
"Pretty sure there's a cat walking on the keyboard somewhere…",
|
||||
"Pretty sure there's a cat walking on the keyboard somewhere",
|
||||
'Enhancing… Enhancing… Still loading.',
|
||||
"It's not a bug, it's a feature… of this loading screen.",
|
||||
'Have you tried turning it off and on again? (The loading screen, not me.)',
|
||||
'Constructing additional pylons…',
|
||||
'Constructing additional pylons',
|
||||
'New line? That’s Ctrl+J.',
|
||||
'Releasing the HypnoDrones…',
|
||||
'Releasing the HypnoDrones',
|
||||
];
|
||||
|
||||
@@ -168,6 +168,8 @@ export interface UIState {
|
||||
cleanUiDetailsVisible: boolean;
|
||||
elapsedTime: number;
|
||||
currentLoadingPhrase: string | undefined;
|
||||
currentTip: string | undefined;
|
||||
currentWittyPhrase: string | undefined;
|
||||
historyRemountKey: number;
|
||||
activeHooks: ActiveHook[];
|
||||
messageQueue: string[];
|
||||
|
||||
@@ -2,10 +2,8 @@
|
||||
|
||||
exports[`usePhraseCycler > should prioritize interactive shell waiting over normal waiting immediately 1`] = `"Waiting for user confirmation..."`;
|
||||
|
||||
exports[`usePhraseCycler > should prioritize interactive shell waiting over normal waiting immediately 2`] = `"Interactive shell awaiting input... press tab to focus shell"`;
|
||||
|
||||
exports[`usePhraseCycler > should reset phrase when transitioning from waiting to active 1`] = `"Waiting for user confirmation..."`;
|
||||
exports[`usePhraseCycler > should prioritize interactive shell waiting over normal waiting immediately 2`] = `"! Shell awaiting input (Tab to focus)"`;
|
||||
|
||||
exports[`usePhraseCycler > should show "Waiting for user confirmation..." when isWaiting is true 1`] = `"Waiting for user confirmation..."`;
|
||||
|
||||
exports[`usePhraseCycler > should show interactive shell waiting message immediately when isInteractiveShellWaiting is true 1`] = `"Interactive shell awaiting input... press tab to focus shell"`;
|
||||
exports[`usePhraseCycler > should show interactive shell waiting message immediately when shouldShowFocusHint is true 1`] = `"! Shell awaiting input (Tab to focus)"`;
|
||||
|
||||
@@ -43,6 +43,7 @@ export const useHookDisplayState = () => {
|
||||
{
|
||||
name: payload.hookName,
|
||||
eventName: payload.eventName,
|
||||
source: payload.source,
|
||||
index: payload.hookIndex,
|
||||
total: payload.totalHooks,
|
||||
},
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
import { WITTY_LOADING_PHRASES } from '../constants/wittyPhrases.js';
|
||||
import { INFORMATIVE_TIPS } from '../constants/tips.js';
|
||||
import type { RetryAttemptPayload } from '@google/gemini-cli-core';
|
||||
import type { LoadingPhrasesMode } from '../../config/settings.js';
|
||||
|
||||
describe('useLoadingIndicator', () => {
|
||||
beforeEach(() => {
|
||||
@@ -34,7 +33,8 @@ describe('useLoadingIndicator', () => {
|
||||
initialStreamingState: StreamingState,
|
||||
initialShouldShowFocusHint: boolean = false,
|
||||
initialRetryStatus: RetryAttemptPayload | null = null,
|
||||
loadingPhrasesMode: LoadingPhrasesMode = 'all',
|
||||
initialShowTips: boolean = true,
|
||||
initialShowWit: boolean = true,
|
||||
initialErrorVerbosity: 'low' | 'full' = 'full',
|
||||
) => {
|
||||
let hookResult: ReturnType<typeof useLoadingIndicator>;
|
||||
@@ -42,30 +42,35 @@ describe('useLoadingIndicator', () => {
|
||||
streamingState,
|
||||
shouldShowFocusHint,
|
||||
retryStatus,
|
||||
mode,
|
||||
showTips,
|
||||
showWit,
|
||||
errorVerbosity,
|
||||
}: {
|
||||
streamingState: StreamingState;
|
||||
shouldShowFocusHint?: boolean;
|
||||
retryStatus?: RetryAttemptPayload | null;
|
||||
mode?: LoadingPhrasesMode;
|
||||
errorVerbosity: 'low' | 'full';
|
||||
showTips?: boolean;
|
||||
showWit?: boolean;
|
||||
errorVerbosity?: 'low' | 'full';
|
||||
}) {
|
||||
hookResult = useLoadingIndicator({
|
||||
streamingState,
|
||||
shouldShowFocusHint: !!shouldShowFocusHint,
|
||||
retryStatus: retryStatus || null,
|
||||
loadingPhrasesMode: mode,
|
||||
showTips,
|
||||
showWit,
|
||||
errorVerbosity,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const { rerender } = render(
|
||||
<TestComponent
|
||||
streamingState={initialStreamingState}
|
||||
shouldShowFocusHint={initialShouldShowFocusHint}
|
||||
retryStatus={initialRetryStatus}
|
||||
mode={loadingPhrasesMode}
|
||||
showTips={initialShowTips}
|
||||
showWit={initialShowWit}
|
||||
errorVerbosity={initialErrorVerbosity}
|
||||
/>,
|
||||
);
|
||||
@@ -79,12 +84,14 @@ describe('useLoadingIndicator', () => {
|
||||
streamingState: StreamingState;
|
||||
shouldShowFocusHint?: boolean;
|
||||
retryStatus?: RetryAttemptPayload | null;
|
||||
mode?: LoadingPhrasesMode;
|
||||
showTips?: boolean;
|
||||
showWit?: boolean;
|
||||
errorVerbosity?: 'low' | 'full';
|
||||
}) =>
|
||||
rerender(
|
||||
<TestComponent
|
||||
mode={loadingPhrasesMode}
|
||||
showTips={initialShowTips}
|
||||
showWit={initialShowWit}
|
||||
errorVerbosity={initialErrorVerbosity}
|
||||
{...newProps}
|
||||
/>,
|
||||
@@ -93,24 +100,19 @@ describe('useLoadingIndicator', () => {
|
||||
};
|
||||
|
||||
it('should initialize with default values when Idle', () => {
|
||||
vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty
|
||||
vi.spyOn(Math, 'random').mockImplementation(() => 0.5);
|
||||
const { result } = renderLoadingIndicatorHook(StreamingState.Idle);
|
||||
expect(result.current.elapsedTime).toBe(0);
|
||||
expect(result.current.currentLoadingPhrase).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should show interactive shell waiting phrase when shouldShowFocusHint is true', async () => {
|
||||
vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty
|
||||
vi.spyOn(Math, 'random').mockImplementation(() => 0.5);
|
||||
const { result, rerender } = renderLoadingIndicatorHook(
|
||||
StreamingState.Responding,
|
||||
false,
|
||||
);
|
||||
|
||||
// Initially should be witty phrase or tip
|
||||
expect([...WITTY_LOADING_PHRASES, ...INFORMATIVE_TIPS]).toContain(
|
||||
result.current.currentLoadingPhrase,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
rerender({
|
||||
streamingState: StreamingState.Responding,
|
||||
@@ -124,19 +126,17 @@ describe('useLoadingIndicator', () => {
|
||||
});
|
||||
|
||||
it('should reflect values when Responding', async () => {
|
||||
vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty for subsequent phrases
|
||||
vi.spyOn(Math, 'random').mockImplementation(() => 0.5);
|
||||
const { result } = renderLoadingIndicatorHook(StreamingState.Responding);
|
||||
|
||||
// Initial phrase on first activation will be a tip, not necessarily from witty phrases
|
||||
expect(result.current.elapsedTime).toBe(0);
|
||||
// On first activation, it may show a tip, so we can't guarantee it's in WITTY_LOADING_PHRASES
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS + 1);
|
||||
});
|
||||
|
||||
// Phrase should cycle if PHRASE_CHANGE_INTERVAL_MS has passed, now it should be witty since first activation already happened
|
||||
expect(WITTY_LOADING_PHRASES).toContain(
|
||||
// Both tip and witty phrase are available in the currentLoadingPhrase because it defaults to tip if present
|
||||
expect([...WITTY_LOADING_PHRASES, ...INFORMATIVE_TIPS]).toContain(
|
||||
result.current.currentLoadingPhrase,
|
||||
);
|
||||
});
|
||||
@@ -167,8 +167,8 @@ describe('useLoadingIndicator', () => {
|
||||
expect(result.current.elapsedTime).toBe(60);
|
||||
});
|
||||
|
||||
it('should reset elapsedTime and use a witty phrase when transitioning from WaitingForConfirmation to Responding', async () => {
|
||||
vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty
|
||||
it('should reset elapsedTime and cycle phrases when transitioning from WaitingForConfirmation to Responding', async () => {
|
||||
vi.spyOn(Math, 'random').mockImplementation(() => 0.5);
|
||||
const { result, rerender } = renderLoadingIndicatorHook(
|
||||
StreamingState.Responding,
|
||||
);
|
||||
@@ -190,7 +190,7 @@ describe('useLoadingIndicator', () => {
|
||||
rerender({ streamingState: StreamingState.Responding });
|
||||
});
|
||||
expect(result.current.elapsedTime).toBe(0); // Should reset
|
||||
expect(WITTY_LOADING_PHRASES).toContain(
|
||||
expect([...WITTY_LOADING_PHRASES, ...INFORMATIVE_TIPS]).toContain(
|
||||
result.current.currentLoadingPhrase,
|
||||
);
|
||||
|
||||
@@ -201,7 +201,7 @@ describe('useLoadingIndicator', () => {
|
||||
});
|
||||
|
||||
it('should reset timer and phrase when streamingState changes from Responding to Idle', async () => {
|
||||
vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty
|
||||
vi.spyOn(Math, 'random').mockImplementation(() => 0.5);
|
||||
const { result, rerender } = renderLoadingIndicatorHook(
|
||||
StreamingState.Responding,
|
||||
);
|
||||
@@ -217,12 +217,6 @@ describe('useLoadingIndicator', () => {
|
||||
|
||||
expect(result.current.elapsedTime).toBe(0);
|
||||
expect(result.current.currentLoadingPhrase).toBeUndefined();
|
||||
|
||||
// Timer should not advance
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
});
|
||||
expect(result.current.elapsedTime).toBe(0);
|
||||
});
|
||||
|
||||
it('should reflect retry status in currentLoadingPhrase when provided', () => {
|
||||
@@ -253,7 +247,8 @@ describe('useLoadingIndicator', () => {
|
||||
StreamingState.Responding,
|
||||
false,
|
||||
retryStatus,
|
||||
'all',
|
||||
true,
|
||||
true,
|
||||
'low',
|
||||
);
|
||||
|
||||
@@ -273,7 +268,8 @@ describe('useLoadingIndicator', () => {
|
||||
StreamingState.Responding,
|
||||
false,
|
||||
retryStatus,
|
||||
'all',
|
||||
true,
|
||||
true,
|
||||
'low',
|
||||
);
|
||||
|
||||
@@ -282,12 +278,13 @@ describe('useLoadingIndicator', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should show no phrases when loadingPhrasesMode is "off"', () => {
|
||||
it('should show no phrases when showTips and showWit are false', () => {
|
||||
const { result } = renderLoadingIndicatorHook(
|
||||
StreamingState.Responding,
|
||||
false,
|
||||
null,
|
||||
'off',
|
||||
false,
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.current.currentLoadingPhrase).toBeUndefined();
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
getDisplayString,
|
||||
type RetryAttemptPayload,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { LoadingPhrasesMode } from '../../config/settings.js';
|
||||
|
||||
const LOW_VERBOSITY_RETRY_HINT_ATTEMPT_THRESHOLD = 2;
|
||||
|
||||
@@ -20,18 +19,22 @@ export interface UseLoadingIndicatorProps {
|
||||
streamingState: StreamingState;
|
||||
shouldShowFocusHint: boolean;
|
||||
retryStatus: RetryAttemptPayload | null;
|
||||
loadingPhrasesMode?: LoadingPhrasesMode;
|
||||
showTips?: boolean;
|
||||
showWit?: boolean;
|
||||
customWittyPhrases?: string[];
|
||||
errorVerbosity: 'low' | 'full';
|
||||
errorVerbosity?: 'low' | 'full';
|
||||
maxLength?: number;
|
||||
}
|
||||
|
||||
export const useLoadingIndicator = ({
|
||||
streamingState,
|
||||
shouldShowFocusHint,
|
||||
retryStatus,
|
||||
loadingPhrasesMode,
|
||||
showTips = true,
|
||||
showWit = false,
|
||||
customWittyPhrases,
|
||||
errorVerbosity,
|
||||
errorVerbosity = 'full',
|
||||
maxLength,
|
||||
}: UseLoadingIndicatorProps) => {
|
||||
const [timerResetKey, setTimerResetKey] = useState(0);
|
||||
const isTimerActive = streamingState === StreamingState.Responding;
|
||||
@@ -40,12 +43,15 @@ export const useLoadingIndicator = ({
|
||||
|
||||
const isPhraseCyclingActive = streamingState === StreamingState.Responding;
|
||||
const isWaiting = streamingState === StreamingState.WaitingForConfirmation;
|
||||
const currentLoadingPhrase = usePhraseCycler(
|
||||
|
||||
const { currentTip, currentWittyPhrase } = usePhraseCycler(
|
||||
isPhraseCyclingActive,
|
||||
isWaiting,
|
||||
shouldShowFocusHint,
|
||||
loadingPhrasesMode,
|
||||
showTips,
|
||||
showWit,
|
||||
customWittyPhrases,
|
||||
maxLength,
|
||||
);
|
||||
|
||||
const [retainedElapsedTime, setRetainedElapsedTime] = useState(0);
|
||||
@@ -86,6 +92,8 @@ export const useLoadingIndicator = ({
|
||||
streamingState === StreamingState.WaitingForConfirmation
|
||||
? retainedElapsedTime
|
||||
: elapsedTimeFromTimer,
|
||||
currentLoadingPhrase: retryPhrase || currentLoadingPhrase,
|
||||
currentLoadingPhrase: retryPhrase || currentTip || currentWittyPhrase,
|
||||
currentTip,
|
||||
currentWittyPhrase,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -14,30 +14,35 @@ import {
|
||||
} from './usePhraseCycler.js';
|
||||
import { INFORMATIVE_TIPS } from '../constants/tips.js';
|
||||
import { WITTY_LOADING_PHRASES } from '../constants/wittyPhrases.js';
|
||||
import type { LoadingPhrasesMode } from '../../config/settings.js';
|
||||
|
||||
// Test component to consume the hook
|
||||
const TestComponent = ({
|
||||
isActive,
|
||||
isWaiting,
|
||||
isInteractiveShellWaiting = false,
|
||||
loadingPhrasesMode = 'all',
|
||||
shouldShowFocusHint = false,
|
||||
showTips = true,
|
||||
showWit = true,
|
||||
customPhrases,
|
||||
}: {
|
||||
isActive: boolean;
|
||||
isWaiting: boolean;
|
||||
isInteractiveShellWaiting?: boolean;
|
||||
loadingPhrasesMode?: LoadingPhrasesMode;
|
||||
shouldShowFocusHint?: boolean;
|
||||
showTips?: boolean;
|
||||
showWit?: boolean;
|
||||
customPhrases?: string[];
|
||||
}) => {
|
||||
const phrase = usePhraseCycler(
|
||||
const { currentTip, currentWittyPhrase } = usePhraseCycler(
|
||||
isActive,
|
||||
isWaiting,
|
||||
isInteractiveShellWaiting,
|
||||
loadingPhrasesMode,
|
||||
shouldShowFocusHint,
|
||||
showTips,
|
||||
showWit,
|
||||
customPhrases,
|
||||
);
|
||||
return <Text>{phrase}</Text>;
|
||||
// For tests, we'll combine them to verify existence
|
||||
return (
|
||||
<Text>{[currentTip, currentWittyPhrase].filter(Boolean).join(' | ')}</Text>
|
||||
);
|
||||
};
|
||||
|
||||
describe('usePhraseCycler', () => {
|
||||
@@ -75,7 +80,7 @@ describe('usePhraseCycler', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should show interactive shell waiting message immediately when isInteractiveShellWaiting is true', async () => {
|
||||
it('should show interactive shell waiting message immediately when shouldShowFocusHint is true', async () => {
|
||||
const { lastFrame, rerender, waitUntilReady, unmount } = render(
|
||||
<TestComponent isActive={true} isWaiting={false} />,
|
||||
);
|
||||
@@ -86,7 +91,7 @@ describe('usePhraseCycler', () => {
|
||||
<TestComponent
|
||||
isActive={true}
|
||||
isWaiting={false}
|
||||
isInteractiveShellWaiting={true}
|
||||
shouldShowFocusHint={true}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
@@ -108,7 +113,7 @@ describe('usePhraseCycler', () => {
|
||||
<TestComponent
|
||||
isActive={true}
|
||||
isWaiting={true}
|
||||
isInteractiveShellWaiting={true}
|
||||
shouldShowFocusHint={true}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
@@ -133,55 +138,56 @@ describe('usePhraseCycler', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should show a tip on first activation, then a witty phrase', async () => {
|
||||
vi.spyOn(Math, 'random').mockImplementation(() => 0.99); // Subsequent phrases are witty
|
||||
it('should show both a tip and a witty phrase when both are enabled', async () => {
|
||||
vi.spyOn(Math, 'random').mockImplementation(() => 0.5);
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<TestComponent isActive={true} isWaiting={false} />,
|
||||
<TestComponent
|
||||
isActive={true}
|
||||
isWaiting={false}
|
||||
showTips={true}
|
||||
showWit={true}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
// Initial phrase on first activation should be a tip
|
||||
expect(INFORMATIVE_TIPS).toContain(lastFrame().trim());
|
||||
|
||||
// After the first interval, it should be a witty phrase
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS + 100);
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(WITTY_LOADING_PHRASES).toContain(lastFrame().trim());
|
||||
// In the new logic, both are selected independently if enabled.
|
||||
const frame = lastFrame().trim();
|
||||
const parts = frame.split(' | ');
|
||||
expect(parts).toHaveLength(2);
|
||||
expect(INFORMATIVE_TIPS).toContain(parts[0]);
|
||||
expect(WITTY_LOADING_PHRASES).toContain(parts[1]);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should cycle through phrases when isActive is true and not waiting', async () => {
|
||||
vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty for subsequent phrases
|
||||
vi.spyOn(Math, 'random').mockImplementation(() => 0.5);
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<TestComponent isActive={true} isWaiting={false} />,
|
||||
<TestComponent
|
||||
isActive={true}
|
||||
isWaiting={false}
|
||||
showTips={true}
|
||||
showWit={true}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
// Initial phrase on first activation will be a tip
|
||||
|
||||
// After the first interval, it should follow the random pattern (witty phrases due to mock)
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS + 100);
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(WITTY_LOADING_PHRASES).toContain(lastFrame().trim());
|
||||
const frame = lastFrame().trim();
|
||||
const parts = frame.split(' | ');
|
||||
expect(parts).toHaveLength(2);
|
||||
expect(INFORMATIVE_TIPS).toContain(parts[0]);
|
||||
expect(WITTY_LOADING_PHRASES).toContain(parts[1]);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS);
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(WITTY_LOADING_PHRASES).toContain(lastFrame().trim());
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should reset to a phrase when isActive becomes true after being false', async () => {
|
||||
it('should reset to phrases when isActive becomes true after being false', async () => {
|
||||
const customPhrases = ['Phrase A', 'Phrase B'];
|
||||
let callCount = 0;
|
||||
vi.spyOn(Math, 'random').mockImplementation(() => {
|
||||
// For custom phrases, only 1 Math.random call is made per update.
|
||||
// 0 -> index 0 ('Phrase A')
|
||||
// 0.99 -> index 1 ('Phrase B')
|
||||
const val = callCount % 2 === 0 ? 0 : 0.99;
|
||||
callCount++;
|
||||
return val;
|
||||
@@ -192,34 +198,31 @@ describe('usePhraseCycler', () => {
|
||||
isActive={false}
|
||||
isWaiting={false}
|
||||
customPhrases={customPhrases}
|
||||
showWit={true}
|
||||
showTips={false}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
// Activate -> On first activation will show tip on initial call, then first interval will use first mock value for 'Phrase A'
|
||||
// Activate
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<TestComponent
|
||||
isActive={true}
|
||||
isWaiting={false}
|
||||
customPhrases={customPhrases}
|
||||
showWit={true}
|
||||
showTips={false}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS); // First interval after initial state -> callCount 0 -> 'Phrase A'
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(customPhrases).toContain(lastFrame().trim()); // Should be one of the custom phrases
|
||||
|
||||
// Second interval -> callCount 1 -> returns 0.99 -> 'Phrase B'
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS);
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(customPhrases).toContain(lastFrame().trim()); // Should be one of the custom phrases
|
||||
expect(customPhrases).toContain(lastFrame().trim());
|
||||
|
||||
// Deactivate -> resets to undefined (empty string in output)
|
||||
await act(async () => {
|
||||
@@ -228,6 +231,8 @@ describe('usePhraseCycler', () => {
|
||||
isActive={false}
|
||||
isWaiting={false}
|
||||
customPhrases={customPhrases}
|
||||
showWit={true}
|
||||
showTips={false}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
@@ -235,24 +240,6 @@ describe('usePhraseCycler', () => {
|
||||
|
||||
// The phrase should be empty after reset
|
||||
expect(lastFrame({ allowEmpty: true }).trim()).toBe('');
|
||||
|
||||
// Activate again -> this will show a tip on first activation, then cycle from where mock is
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<TestComponent
|
||||
isActive={true}
|
||||
isWaiting={false}
|
||||
customPhrases={customPhrases}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS); // First interval after re-activation -> should contain phrase
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(customPhrases).toContain(lastFrame().trim()); // Should be one of the custom phrases
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -264,7 +251,7 @@ describe('usePhraseCycler', () => {
|
||||
|
||||
const clearIntervalSpy = vi.spyOn(global, 'clearInterval');
|
||||
unmount();
|
||||
expect(clearIntervalSpy).toHaveBeenCalledOnce();
|
||||
expect(clearIntervalSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use custom phrases when provided', async () => {
|
||||
@@ -293,7 +280,8 @@ describe('usePhraseCycler', () => {
|
||||
<TestComponent
|
||||
isActive={config.isActive}
|
||||
isWaiting={false}
|
||||
loadingPhrasesMode="witty"
|
||||
showTips={false}
|
||||
showWit={true}
|
||||
customPhrases={config.customPhrases}
|
||||
/>
|
||||
);
|
||||
@@ -304,7 +292,7 @@ describe('usePhraseCycler', () => {
|
||||
|
||||
// After first interval, it should use custom phrases
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS + 100);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
@@ -323,78 +311,24 @@ describe('usePhraseCycler', () => {
|
||||
await waitUntilReady();
|
||||
expect(customPhrases).toContain(lastFrame({ allowEmpty: true }).trim());
|
||||
|
||||
randomMock.mockReturnValue(0.99);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS);
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(customPhrases).toContain(lastFrame({ allowEmpty: true }).trim());
|
||||
|
||||
// Test fallback to default phrases.
|
||||
randomMock.mockRestore();
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.5); // Always witty
|
||||
|
||||
await act(async () => {
|
||||
setStateExternally?.({
|
||||
isActive: true,
|
||||
customPhrases: [] as string[],
|
||||
});
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS); // Wait for first cycle
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
expect(WITTY_LOADING_PHRASES).toContain(lastFrame().trim());
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should fall back to witty phrases if custom phrases are an empty array', async () => {
|
||||
vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty for subsequent phrases
|
||||
vi.spyOn(Math, 'random').mockImplementation(() => 0.5);
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<TestComponent isActive={true} isWaiting={false} customPhrases={[]} />,
|
||||
<TestComponent
|
||||
isActive={true}
|
||||
isWaiting={false}
|
||||
showTips={false}
|
||||
showWit={true}
|
||||
customPhrases={[]}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS); // Next phrase after tip
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(WITTY_LOADING_PHRASES).toContain(lastFrame().trim());
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should reset phrase when transitioning from waiting to active', async () => {
|
||||
vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty for subsequent phrases
|
||||
const { lastFrame, rerender, waitUntilReady, unmount } = render(
|
||||
<TestComponent isActive={true} isWaiting={false} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
// Cycle to a different phrase (should be witty due to mock)
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS);
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(WITTY_LOADING_PHRASES).toContain(lastFrame().trim());
|
||||
|
||||
// Go to waiting state
|
||||
await act(async () => {
|
||||
rerender(<TestComponent isActive={false} isWaiting={true} />);
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(lastFrame().trim()).toMatchSnapshot();
|
||||
|
||||
// Go back to active cycling - should pick a phrase based on the logic (witty due to mock)
|
||||
await act(async () => {
|
||||
rerender(<TestComponent isActive={true} isWaiting={false} />);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS); // Skip the tip and get next phrase
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(WITTY_LOADING_PHRASES).toContain(lastFrame().trim());
|
||||
|
||||
@@ -7,112 +7,177 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { INFORMATIVE_TIPS } from '../constants/tips.js';
|
||||
import { WITTY_LOADING_PHRASES } from '../constants/wittyPhrases.js';
|
||||
import type { LoadingPhrasesMode } from '../../config/settings.js';
|
||||
|
||||
export const PHRASE_CHANGE_INTERVAL_MS = 15000;
|
||||
export const PHRASE_CHANGE_INTERVAL_MS = 10000;
|
||||
export const WITTY_PHRASE_CHANGE_INTERVAL_MS = 5000;
|
||||
export const INTERACTIVE_SHELL_WAITING_PHRASE =
|
||||
'Interactive shell awaiting input... press tab to focus shell';
|
||||
'! Shell awaiting input (Tab to focus)';
|
||||
|
||||
/**
|
||||
* Custom hook to manage cycling through loading phrases.
|
||||
* @param isActive Whether the phrase cycling should be active.
|
||||
* @param isWaiting Whether to show a specific waiting phrase.
|
||||
* @param shouldShowFocusHint Whether to show the shell focus hint.
|
||||
* @param loadingPhrasesMode Which phrases to show: tips, witty, all, or off.
|
||||
* @param showTips Whether to show informative tips.
|
||||
* @param showWit Whether to show witty phrases.
|
||||
* @param customPhrases Optional list of custom phrases to use instead of built-in witty phrases.
|
||||
* @param maxLength Optional maximum length for the selected phrase.
|
||||
* @returns The current loading phrase.
|
||||
*/
|
||||
export const usePhraseCycler = (
|
||||
isActive: boolean,
|
||||
isWaiting: boolean,
|
||||
shouldShowFocusHint: boolean,
|
||||
loadingPhrasesMode: LoadingPhrasesMode = 'tips',
|
||||
showTips: boolean = true,
|
||||
showWit: boolean = true,
|
||||
customPhrases?: string[],
|
||||
maxLength?: number,
|
||||
) => {
|
||||
const [currentLoadingPhrase, setCurrentLoadingPhrase] = useState<
|
||||
const [currentTipState, setCurrentTipState] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [currentWittyPhraseState, setCurrentWittyPhraseState] = useState<
|
||||
string | undefined
|
||||
>(undefined);
|
||||
|
||||
const phraseIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const hasShownFirstRequestTipRef = useRef(false);
|
||||
const tipIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const wittyIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastTipChangeTimeRef = useRef<number>(0);
|
||||
const lastWittyChangeTimeRef = useRef<number>(0);
|
||||
const lastSelectedTipRef = useRef<string | undefined>(undefined);
|
||||
const lastSelectedWittyPhraseRef = useRef<string | undefined>(undefined);
|
||||
const MIN_TIP_DISPLAY_TIME_MS = 10000;
|
||||
const MIN_WIT_DISPLAY_TIME_MS = 5000;
|
||||
|
||||
useEffect(() => {
|
||||
// Always clear on re-run
|
||||
if (phraseIntervalRef.current) {
|
||||
clearInterval(phraseIntervalRef.current);
|
||||
phraseIntervalRef.current = null;
|
||||
}
|
||||
const clearTimers = () => {
|
||||
if (tipIntervalRef.current) {
|
||||
clearInterval(tipIntervalRef.current);
|
||||
tipIntervalRef.current = null;
|
||||
}
|
||||
if (wittyIntervalRef.current) {
|
||||
clearInterval(wittyIntervalRef.current);
|
||||
wittyIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
if (shouldShowFocusHint) {
|
||||
setCurrentLoadingPhrase(INTERACTIVE_SHELL_WAITING_PHRASE);
|
||||
clearTimers();
|
||||
|
||||
if (shouldShowFocusHint || isWaiting) {
|
||||
// These are handled by the return value directly for immediate feedback
|
||||
return;
|
||||
}
|
||||
|
||||
if (isWaiting) {
|
||||
setCurrentLoadingPhrase('Waiting for user confirmation...');
|
||||
if (!isActive || (!showTips && !showWit)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isActive || loadingPhrasesMode === 'off') {
|
||||
setCurrentLoadingPhrase(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const wittyPhrases =
|
||||
const wittyPhrasesList =
|
||||
customPhrases && customPhrases.length > 0
|
||||
? customPhrases
|
||||
: WITTY_LOADING_PHRASES;
|
||||
|
||||
const setRandomPhrase = () => {
|
||||
let phraseList: readonly string[];
|
||||
|
||||
switch (loadingPhrasesMode) {
|
||||
case 'tips':
|
||||
phraseList = INFORMATIVE_TIPS;
|
||||
break;
|
||||
case 'witty':
|
||||
phraseList = wittyPhrases;
|
||||
break;
|
||||
case 'all':
|
||||
// Show a tip on the first request after startup, then continue with 1/6 chance
|
||||
if (!hasShownFirstRequestTipRef.current) {
|
||||
phraseList = INFORMATIVE_TIPS;
|
||||
hasShownFirstRequestTipRef.current = true;
|
||||
} else {
|
||||
const showTip = Math.random() < 1 / 6;
|
||||
phraseList = showTip ? INFORMATIVE_TIPS : wittyPhrases;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
phraseList = INFORMATIVE_TIPS;
|
||||
break;
|
||||
const setRandomTip = (force: boolean = false) => {
|
||||
if (!showTips) {
|
||||
setCurrentTipState(undefined);
|
||||
lastSelectedTipRef.current = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const randomIndex = Math.floor(Math.random() * phraseList.length);
|
||||
setCurrentLoadingPhrase(phraseList[randomIndex]);
|
||||
};
|
||||
const now = Date.now();
|
||||
if (
|
||||
!force &&
|
||||
now - lastTipChangeTimeRef.current < MIN_TIP_DISPLAY_TIME_MS &&
|
||||
lastSelectedTipRef.current
|
||||
) {
|
||||
setCurrentTipState(lastSelectedTipRef.current);
|
||||
return;
|
||||
}
|
||||
|
||||
// Select an initial random phrase
|
||||
setRandomPhrase();
|
||||
const filteredTips =
|
||||
maxLength !== undefined
|
||||
? INFORMATIVE_TIPS.filter((p) => p.length <= maxLength)
|
||||
: INFORMATIVE_TIPS;
|
||||
|
||||
phraseIntervalRef.current = setInterval(() => {
|
||||
// Select a new random phrase
|
||||
setRandomPhrase();
|
||||
}, PHRASE_CHANGE_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
if (phraseIntervalRef.current) {
|
||||
clearInterval(phraseIntervalRef.current);
|
||||
phraseIntervalRef.current = null;
|
||||
if (filteredTips.length > 0) {
|
||||
const selected =
|
||||
filteredTips[Math.floor(Math.random() * filteredTips.length)];
|
||||
setCurrentTipState(selected);
|
||||
lastSelectedTipRef.current = selected;
|
||||
lastTipChangeTimeRef.current = now;
|
||||
}
|
||||
};
|
||||
|
||||
const setRandomWitty = (force: boolean = false) => {
|
||||
if (!showWit) {
|
||||
setCurrentWittyPhraseState(undefined);
|
||||
lastSelectedWittyPhraseRef.current = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (
|
||||
!force &&
|
||||
now - lastWittyChangeTimeRef.current < MIN_WIT_DISPLAY_TIME_MS &&
|
||||
lastSelectedWittyPhraseRef.current
|
||||
) {
|
||||
setCurrentWittyPhraseState(lastSelectedWittyPhraseRef.current);
|
||||
return;
|
||||
}
|
||||
|
||||
const filteredWitty =
|
||||
maxLength !== undefined
|
||||
? wittyPhrasesList.filter((p) => p.length <= maxLength)
|
||||
: wittyPhrasesList;
|
||||
|
||||
if (filteredWitty.length > 0) {
|
||||
const selected =
|
||||
filteredWitty[Math.floor(Math.random() * filteredWitty.length)];
|
||||
setCurrentWittyPhraseState(selected);
|
||||
lastSelectedWittyPhraseRef.current = selected;
|
||||
lastWittyChangeTimeRef.current = now;
|
||||
}
|
||||
};
|
||||
|
||||
// Select initial random phrases or resume previous ones
|
||||
setRandomTip(false);
|
||||
setRandomWitty(false);
|
||||
|
||||
if (showTips) {
|
||||
tipIntervalRef.current = setInterval(() => {
|
||||
setRandomTip(true);
|
||||
}, PHRASE_CHANGE_INTERVAL_MS);
|
||||
}
|
||||
|
||||
if (showWit) {
|
||||
wittyIntervalRef.current = setInterval(() => {
|
||||
setRandomWitty(true);
|
||||
}, WITTY_PHRASE_CHANGE_INTERVAL_MS);
|
||||
}
|
||||
|
||||
return clearTimers;
|
||||
}, [
|
||||
isActive,
|
||||
isWaiting,
|
||||
shouldShowFocusHint,
|
||||
loadingPhrasesMode,
|
||||
showTips,
|
||||
showWit,
|
||||
customPhrases,
|
||||
maxLength,
|
||||
]);
|
||||
|
||||
return currentLoadingPhrase;
|
||||
let currentTip = undefined;
|
||||
let currentWittyPhrase = undefined;
|
||||
|
||||
if (shouldShowFocusHint) {
|
||||
currentTip = INTERACTIVE_SHELL_WAITING_PHRASE;
|
||||
} else if (isWaiting) {
|
||||
currentTip = 'Waiting for user confirmation...';
|
||||
} else if (isActive) {
|
||||
currentTip = currentTipState;
|
||||
currentWittyPhrase = currentWittyPhraseState;
|
||||
}
|
||||
|
||||
return { currentTip, currentWittyPhrase };
|
||||
};
|
||||
|
||||
@@ -31,9 +31,6 @@ export const DefaultAppLayout: React.FC = () => {
|
||||
flexDirection="column"
|
||||
width={uiState.terminalWidth}
|
||||
height={isAlternateBuffer ? terminalHeight : undefined}
|
||||
paddingBottom={
|
||||
isAlternateBuffer && !uiState.copyModeEnabled ? 1 : undefined
|
||||
}
|
||||
flexShrink={0}
|
||||
flexGrow={0}
|
||||
overflow="hidden"
|
||||
|
||||
@@ -18,3 +18,5 @@ export const REDIRECTION_WARNING_NOTE_TEXT =
|
||||
export const REDIRECTION_WARNING_TIP_LABEL = 'Tip: '; // Padded to align with "Note: "
|
||||
export const getRedirectionWarningTipText = (shiftTabHint: string) =>
|
||||
`Toggle auto-edit (${shiftTabHint}) to allow redirection in the future.`;
|
||||
|
||||
export const GENERIC_WORKING_LABEL = 'Working...';
|
||||
|
||||
@@ -507,6 +507,7 @@ export interface PermissionConfirmationRequest {
|
||||
export interface ActiveHook {
|
||||
name: string;
|
||||
eventName: string;
|
||||
source?: string;
|
||||
index?: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ import { ConsecaSafetyChecker } from '../safety/conseca/conseca.js';
|
||||
import type { AgentLoopContext } from './agent-loop-context.js';
|
||||
|
||||
export interface AccessibilitySettings {
|
||||
/** @deprecated Use ui.loadingPhrases instead. */
|
||||
/** @deprecated Use ui.statusHints instead. */
|
||||
enableLoadingPhrases?: boolean;
|
||||
screenReader?: boolean;
|
||||
}
|
||||
|
||||
@@ -303,6 +303,7 @@ export class HookEventHandler {
|
||||
coreEvents.emitHookStart({
|
||||
hookName: this.getHookName(config),
|
||||
eventName,
|
||||
source: config.source,
|
||||
hookIndex: index + 1,
|
||||
totalHooks: plan.hookConfigs.length,
|
||||
});
|
||||
|
||||
@@ -88,9 +88,12 @@ export interface HookPayload {
|
||||
* Payload for the 'hook-start' event.
|
||||
*/
|
||||
export interface HookStartPayload extends HookPayload {
|
||||
/**
|
||||
* The source of the hook configuration.
|
||||
*/
|
||||
source?: string;
|
||||
/**
|
||||
* The 1-based index of the current hook in the execution sequence.
|
||||
* Used for progress indication (e.g. "Hook 1/3").
|
||||
*/
|
||||
hookIndex?: number;
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user