From d726c194aba90fa3ff24106f21dff29fd0f45fac Mon Sep 17 00:00:00 2001 From: Keith Guerin Date: Tue, 17 Mar 2026 23:03:55 -0700 Subject: [PATCH] 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. --- docs/cli/settings.md | 65 +- docs/reference/configuration.md | 31 +- packages/cli/src/config/settings.test.ts | 115 ++- packages/cli/src/config/settings.ts | 133 +++- .../cli/src/config/settingsSchema.test.ts | 15 +- packages/cli/src/config/settingsSchema.ts | 58 +- packages/cli/src/test-utils/AppRig.tsx | 12 +- packages/cli/src/ui/AppContainer.tsx | 57 +- .../cli/src/ui/components/AppHeader.test.tsx | 20 + .../cli/src/ui/components/Composer.test.tsx | 199 ++++-- packages/cli/src/ui/components/Composer.tsx | 668 +++++++++++------- .../src/ui/components/ConfigInitDisplay.tsx | 6 +- .../cli/src/ui/components/ConsentPrompt.tsx | 7 +- .../components/ContextSummaryDisplay.test.tsx | 26 - .../ui/components/ContextSummaryDisplay.tsx | 27 +- .../ui/components/GeminiRespondingSpinner.tsx | 20 +- .../ui/components/HookStatusDisplay.test.tsx | 26 + .../src/ui/components/HookStatusDisplay.tsx | 40 +- .../ui/components/LoadingIndicator.test.tsx | 99 ++- .../src/ui/components/LoadingIndicator.tsx | 54 +- .../cli/src/ui/components/ShortcutsHint.tsx | 24 - .../cli/src/ui/components/StatusDisplay.tsx | 10 +- .../cli/src/ui/components/ToastDisplay.tsx | 2 +- .../__snapshots__/Composer.test.tsx.snap | 21 +- .../ConfigInitDisplay.test.tsx.snap | 16 +- .../ContextSummaryDisplay.test.tsx.snap | 9 +- ...ches-SVG-snapshot-for-single-hook.snap.svg | 9 + .../HookStatusDisplay.test.tsx.snap | 2 + .../__snapshots__/MainContent.test.tsx.snap | 8 +- .../__snapshots__/StatusDisplay.test.tsx.snap | 2 +- .../ToolConfirmationQueue.test.tsx.snap | 8 + .../ui/components/shared/HorizontalLine.tsx | 3 + packages/cli/src/ui/constants/tips.ts | 302 ++++---- packages/cli/src/ui/constants/wittyPhrases.ts | 214 +++--- .../cli/src/ui/contexts/UIStateContext.tsx | 2 + .../usePhraseCycler.test.tsx.snap | 6 +- .../cli/src/ui/hooks/useHookDisplayState.ts | 1 + .../src/ui/hooks/useLoadingIndicator.test.tsx | 67 +- .../cli/src/ui/hooks/useLoadingIndicator.ts | 24 +- .../cli/src/ui/hooks/usePhraseCycler.test.tsx | 198 ++---- packages/cli/src/ui/hooks/usePhraseCycler.ts | 187 +++-- .../cli/src/ui/layouts/DefaultAppLayout.tsx | 3 - packages/cli/src/ui/textConstants.ts | 2 + packages/cli/src/ui/types.ts | 1 + packages/core/src/config/config.ts | 2 +- packages/core/src/hooks/hookEventHandler.ts | 1 + packages/core/src/utils/events.ts | 5 +- schemas/settings.schema.json | 43 +- 48 files changed, 1716 insertions(+), 1134 deletions(-) delete mode 100644 packages/cli/src/ui/components/ShortcutsHint.tsx create mode 100644 packages/cli/src/ui/components/__snapshots__/HookStatusDisplay--HookStatusDisplay-matches-SVG-snapshot-for-single-hook.snap.svg diff --git a/docs/cli/settings.md b/docs/cli/settings.md index eb9ba4158e..d150480170 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -44,38 +44,39 @@ they appear in the UI. ### UI -| UI Label | Setting | Description | Default | -| ------------------------------------ | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | -| Auto Theme Switching | `ui.autoThemeSwitching` | Automatically switch between default light and dark themes based on terminal background color. | `true` | -| Terminal Background Polling Interval | `ui.terminalBackgroundPollingInterval` | Interval in seconds to poll the terminal background color. | `60` | -| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` | -| Inline Thinking | `ui.inlineThinkingMode` | Display model thinking inline: off or full. | `"off"` | -| Show Thoughts in Title | `ui.showStatusInTitle` | Show Gemini CLI model thoughts in the terminal window title during the working phase | `false` | -| Dynamic Window Title | `ui.dynamicWindowTitle` | Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦) | `true` | -| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` | -| Show Compatibility Warnings | `ui.showCompatibilityWarnings` | Show warnings about terminal or OS compatibility issues. | `true` | -| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` | -| Escape Pasted @ Symbols | `ui.escapePastedAtSymbols` | When enabled, @ symbols in pasted text are escaped to prevent unintended @path expansion. | `false` | -| Show Shortcuts Hint | `ui.showShortcutsHint` | Show the "? for shortcuts" hint above the input. | `true` | -| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` | -| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` | -| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory in the footer. | `false` | -| Hide Sandbox Status | `ui.footer.hideSandboxStatus` | Hide the sandbox status indicator in the footer. | `false` | -| Hide Model Info | `ui.footer.hideModelInfo` | Hide the model name and context usage in the footer. | `false` | -| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window usage percentage. | `true` | -| Hide Footer | `ui.hideFooter` | Hide the footer from the UI | `false` | -| Show Memory Usage | `ui.showMemoryUsage` | Display memory usage information in the UI | `false` | -| Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `true` | -| Show Citations | `ui.showCitations` | Show citations for generated text in the chat. | `false` | -| Show Model Info In Chat | `ui.showModelInfoInChat` | Show the model name in the chat for each model turn. | `false` | -| Show User Identity | `ui.showUserIdentity` | Show the signed-in user's identity (e.g. email) in the UI. | `true` | -| Use Alternate Screen Buffer | `ui.useAlternateBuffer` | Use an alternate screen buffer for the UI, preserving shell history. | `false` | -| Use Background Color | `ui.useBackgroundColor` | Whether to use background colors in the UI. | `true` | -| Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` | -| Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` | -| Loading Phrases | `ui.loadingPhrases` | What to show while the model is working: tips, witty comments, both, or nothing. | `"tips"` | -| Error Verbosity | `ui.errorVerbosity` | Controls whether recoverable errors are hidden (low) or fully shown (full). | `"low"` | -| Screen Reader Mode | `ui.accessibility.screenReader` | Render output in plain-text to be more screen reader accessible | `false` | +| UI Label | Setting | Description | Default | +| ------------------------------------ | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| Auto Theme Switching | `ui.autoThemeSwitching` | Automatically switch between default light and dark themes based on terminal background color. | `true` | +| Terminal Background Polling Interval | `ui.terminalBackgroundPollingInterval` | Interval in seconds to poll the terminal background color. | `60` | +| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` | +| Inline Thinking | `ui.inlineThinkingMode` | Display model thinking inline: off or full. | `"off"` | +| Show Thoughts in Title | `ui.showStatusInTitle` | Show Gemini CLI model thoughts in the terminal window title during the working phase | `false` | +| Dynamic Window Title | `ui.dynamicWindowTitle` | Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦) | `true` | +| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` | +| Show Compatibility Warnings | `ui.showCompatibilityWarnings` | Show warnings about terminal or OS compatibility issues. | `true` | +| Hide Startup Tips | `ui.hideTips` | Hide the introductory tips shown at the top of the screen. | `false` | +| Escape Pasted @ Symbols | `ui.escapePastedAtSymbols` | When enabled, @ symbols in pasted text are escaped to prevent unintended @path expansion. | `false` | +| Show Shortcuts Hint | `ui.showShortcutsHint` | Show basic shortcut help ('?') when the status line is idle. | `true` | +| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` | +| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` | +| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory in the footer. | `false` | +| Hide Sandbox Status | `ui.footer.hideSandboxStatus` | Hide the sandbox status indicator in the footer. | `false` | +| Hide Model Info | `ui.footer.hideModelInfo` | Hide the model name and context usage in the footer. | `false` | +| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window usage percentage. | `true` | +| Hide Footer | `ui.hideFooter` | Hide the footer from the UI | `false` | +| Show Memory Usage | `ui.showMemoryUsage` | Display memory usage information in the UI | `false` | +| Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `true` | +| Show Citations | `ui.showCitations` | Show citations for generated text in the chat. | `false` | +| Show Model Info In Chat | `ui.showModelInfoInChat` | Show the model name in the chat for each model turn. | `false` | +| Show User Identity | `ui.showUserIdentity` | Show the signed-in user's identity (e.g. email) in the UI. | `true` | +| Use Alternate Screen Buffer | `ui.useAlternateBuffer` | Use an alternate screen buffer for the UI, preserving shell history. | `false` | +| Use Background Color | `ui.useBackgroundColor` | Whether to use background colors in the UI. | `true` | +| Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` | +| Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` | +| Hide Footer Tips | `ui.hideStatusTips` | Hide helpful tips in the footer while the model is working. | `false` | +| Hide Footer Wit | `ui.hideStatusWit` | Hide witty loading phrases in the footer while the model is working. | `true` | +| Error Verbosity | `ui.errorVerbosity` | Controls whether recoverable errors are hidden (low) or fully shown (full). | `"low"` | +| Screen Reader Mode | `ui.accessibility.screenReader` | Render output in plain-text to be more screen reader accessible | `false` | ### IDE diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 7df1de61f1..9da9b7a2b5 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -242,7 +242,12 @@ their corresponding top-level category object in your `settings.json` file. - **Requires restart:** Yes - **`ui.hideTips`** (boolean): - - **Description:** Hide helpful tips in the UI + - **Description:** Hide the introductory tips shown at the top of the screen. + - **Default:** `false` + +- **`ui.hideIntroTips`** (boolean): + - **Description:** @deprecated Use ui.hideTips instead. Hide the intro tips in + the header. - **Default:** `false` - **`ui.escapePastedAtSymbols`** (boolean): @@ -251,7 +256,8 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `false` - **`ui.showShortcutsHint`** (boolean): - - **Description:** Show the "? for shortcuts" hint above the input. + - **Description:** Show basic shortcut help ('?') when the status line is + idle. - **Default:** `true` - **`ui.hideBanner`** (boolean): @@ -334,9 +340,26 @@ their corresponding top-level category object in your `settings.json` file. - **Description:** Show the spinner during operations. - **Default:** `true` +- **`ui.hideStatusTips`** (boolean): + - **Description:** Hide helpful tips in the footer while the model is working. + - **Default:** `false` + +- **`ui.hideStatusWit`** (boolean): + - **Description:** Hide witty loading phrases in the footer while the model is + working. + - **Default:** `true` + +- **`ui.statusHints`** (enum): + - **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). + - **Default:** `"tips"` + - **Values:** `"tips"`, `"witty"`, `"all"`, `"off"` + - **`ui.loadingPhrases`** (enum): - - **Description:** What to show while the model is working: tips, witty - comments, both, or nothing. + - **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). - **Default:** `"tips"` - **Values:** `"tips"`, `"witty"`, `"all"`, `"off"` diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index 06129a4760..1d91cf8fc8 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -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; - 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; - 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); } } }); diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 711ff93271..900c2860c3 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -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 | 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 @@ -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; } } } diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index 37ddf87642..f683289829 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -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', () => { diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 8a107c4d47..45494a3262 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -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' }, diff --git a/packages/cli/src/test-utils/AppRig.tsx b/packages/cli/src/test-utils/AppRig.tsx index 6043c7f8cc..c1de9b9d45 100644 --- a/packages/cli/src/test-utils/AppRig.tsx +++ b/packages/cli/src/test-utils/AppRig.tsx @@ -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); } diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index b0a936a81b..7af7b027e4 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -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, diff --git a/packages/cli/src/ui/components/AppHeader.test.tsx b/packages/cli/src/ui/components/AppHeader.test.tsx index ebcd4de973..a5b7187d69 100644 --- a/packages/cli/src/ui/components/AppHeader.test.tsx +++ b/packages/cli/src/ui/components/AppHeader.test.tsx @@ -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('', () => { 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( + , + { + config: mockConfig, + settings: { + merged: { + ui: { hideTips: true }, + }, + } as unknown as LoadedSettings, + }, + ); + await waitUntilReady(); + + expect(lastFrame()).not.toContain('Tips'); + unmount(); + }); }); diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 84f8d15a06..fa6367770f 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -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: () => StatusDisplay, -})); - -vi.mock('./ToastDisplay.js', () => ({ - ToastDisplay: () => ToastDisplay, - 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 }) => ( + StatusDisplay{hideContextSummary ? ' (hidden summary)' : ''} + ), })); vi.mock('./ContextSummaryDisplay.js', () => ({ @@ -81,17 +80,15 @@ vi.mock('./HookStatusDisplay.js', () => ({ })); vi.mock('./ApprovalModeIndicator.js', () => ({ - ApprovalModeIndicator: () => ApprovalModeIndicator, + ApprovalModeIndicator: ({ approvalMode }: { approvalMode: ApprovalMode }) => ( + ApprovalModeIndicator: {approvalMode} + ), })); vi.mock('./ShellModeIndicator.js', () => ({ ShellModeIndicator: () => ShellModeIndicator, })); -vi.mock('./ShortcutsHint.js', () => ({ - ShortcutsHint: () => ShortcutsHint, -})); - vi.mock('./ShortcutsHelp.js', () => ({ ShortcutsHelp: () => ShortcutsHelp, })); @@ -174,6 +171,8 @@ const createMockUIState = (overrides: Partial = {}): UIState => isFocused: true, thought: '', currentLoadingPhrase: '', + currentTip: '', + currentWittyPhrase: '', elapsedTime: 0, ctrlCPressedOnce: false, ctrlDPressedOnce: false, @@ -202,6 +201,7 @@ const createMockUIState = (overrides: Partial = {}): 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 ( - + @@ -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: ( - Dialog content + Test Dialog ), }); 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(); diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 0864b8f02b..54c5f8ce1a 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -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)[ + '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 ( + + + {ambientContentStr === uiState.currentTip + ? `Tip: ${ambientContentStr}` + : ambientContentStr} + + + ); + }; + + 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 ( + + ); + } + + return ( + + ); + }; + + const statusNode = renderStatusNode(); + + /** + * Renders the minimal metadata row content shown when UI details are hidden. + */ + const renderMinimalMetaRowContent = () => ( + + {renderStatusNode()} + {showMinimalBleedThroughRow && ( + + {miniMode_ShowApprovalMode && modeContentObj && ( + ● {modeContentObj.text} + )} + + )} + + ); + + const renderStatusRow = () => { + // Mini Mode Height Reservation (The "Anti-Jitter" line) + if (!showUiDetails && !showRow1_MiniMode && !showRow2_MiniMode) { + return ; + } + + return ( + + {/* Row 1: multipurpose status (thinking, hooks, wit, tips) */} + {showRow1 && ( + + + {!showUiDetails && showRow1_MiniMode ? ( + renderMinimalMetaRowContent() + ) : isInteractiveShellWaiting ? ( + + + ! Shell awaiting input (Tab to focus) + + + ) : ( + + {statusNode} + + )} + + + + {!isNarrow && showAmbientLine && renderAmbientNode()} + + + )} + + {/* Internal Separator Line */} + {showRow1 && + showRow2 && + (showUiDetails || (showRow1_MiniMode && showRow2_MiniMode)) && ( + + + + )} + + {/* Row 2: Mode and Context Summary */} + {showRow2 && ( + + + {showUiDetails ? ( + <> + {showApprovalIndicator && ( + + )} + {uiState.shellModeActive && ( + + + + )} + {showRawMarkdownIndicator && ( + + + + )} + + ) : ( + miniMode_ShowApprovalMode && + modeContentObj && ( + + ● {modeContentObj.text} + + ) + )} + + + {showUiDetails && ( + + )} + + + )} + + ); + }; return ( { {showUiDetails && } - - - - {showUiDetails && showLoadingIndicator && ( - - )} - - - {showUiDetails && showShortcutsHint && } - - - {showMinimalMetaRow && ( - - - {showMinimalInlineLoading && ( - - )} - {showMinimalModeBleedThrough && minimalModeBleedThrough && ( - - ● {minimalModeBleedThrough.text} - - )} - {hasMinimalStatusBleedThrough && ( - - - - )} - - {(showMinimalContextBleedThrough || - shouldReserveSpaceForShortcutsHint) && ( - - {showMinimalContextBleedThrough && ( - - )} - - {showShortcutsHint && } - - - )} - - )} - {showShortcutsHelp && } - {showUiDetails && } - {showUiDetails && ( - - - {hasToast ? ( - - ) : ( - - {showApprovalIndicator && ( - - )} - {!showLoadingIndicator && ( - <> - {uiState.shellModeActive && ( - - - - )} - {showRawMarkdownIndicator && ( - - - - )} - - )} - - )} - + {showShortcutsHelp && } - - {!showLoadingIndicator && ( - - )} - - - )} + {(showUiDetails || miniMode_ShowToast) && ( + + + + )} + + + {renderStatusRow()} {showUiDetails && uiState.showErrorDetails && ( diff --git a/packages/cli/src/ui/components/ConfigInitDisplay.tsx b/packages/cli/src/ui/components/ConfigInitDisplay.tsx index d421da211e..4997260621 100644 --- a/packages/cli/src/ui/components/ConfigInitDisplay.tsx +++ b/packages/cli/src/ui/components/ConfigInitDisplay.tsx @@ -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, ); diff --git a/packages/cli/src/ui/components/ConsentPrompt.tsx b/packages/cli/src/ui/components/ConsentPrompt.tsx index 3f255d2606..859d29281d 100644 --- a/packages/cli/src/ui/components/ConsentPrompt.tsx +++ b/packages/cli/src/ui/components/ConsentPrompt.tsx @@ -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 )} - + { ]} onSelect={onConfirm} /> + ); diff --git a/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx b/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx index f48cfb2a31..43b733da3d 100644 --- a/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx +++ b/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx @@ -78,32 +78,6 @@ describe('', () => { 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, diff --git a/packages/cli/src/ui/components/ContextSummaryDisplay.tsx b/packages/cli/src/ui/components/ContextSummaryDisplay.tsx index c9f67e34b3..696793bc06 100644 --- a/packages/cli/src/ui/components/ContextSummaryDisplay.tsx +++ b/packages/cli/src/ui/components/ContextSummaryDisplay.tsx @@ -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 = ({ 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 = ({ skillCount === 0 && backgroundProcessCount === 0 ) { - return ; // Render an empty space to reserve height + return null; } const openFilesText = (() => { @@ -113,21 +109,14 @@ export const ContextSummaryDisplay: React.FC = ({ backgroundText, ].filter(Boolean); - if (isNarrow) { - return ( - - {summaryParts.map((part, index) => ( - - - {part} - - ))} - - ); - } - return ( - - {summaryParts.join(' | ')} + + {summaryParts.map((part, index) => ( + + {index > 0 && {' · '}} + {part} + + ))} ); }; diff --git a/packages/cli/src/ui/components/GeminiRespondingSpinner.tsx b/packages/cli/src/ui/components/GeminiRespondingSpinner.tsx index 2e6821355f..316438d737 100644 --- a/packages/cli/src/ui/components/GeminiRespondingSpinner.tsx +++ b/packages/cli/src/ui/components/GeminiRespondingSpinner.tsx @@ -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 ( {SCREEN_READER_LOADING} ) : ( - {nonRespondingDisplay} + {nonRespondingDisplay} ); } diff --git a/packages/cli/src/ui/components/HookStatusDisplay.test.tsx b/packages/cli/src/ui/components/HookStatusDisplay.test.tsx index fbf9ccb555..4cb964b750 100644 --- a/packages/cli/src/ui/components/HookStatusDisplay.test.tsx +++ b/packages/cli/src/ui/components/HookStatusDisplay.test.tsx @@ -64,4 +64,30 @@ describe('', () => { 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( + , + ); + 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(); + await renderResult.waitUntilReady(); + await expect(renderResult).toMatchSvgSnapshot(); + renderResult.unmount(); + }); }); diff --git a/packages/cli/src/ui/components/HookStatusDisplay.tsx b/packages/cli/src/ui/components/HookStatusDisplay.tsx index 07b2ee3d4a..38422ba402 100644 --- a/packages/cli/src/ui/components/HookStatusDisplay.tsx +++ b/packages/cli/src/ui/components/HookStatusDisplay.tsx @@ -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 = ({ 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} + + ); + } + + // If only system/extension hooks are running, show a generic message. return ( - - {text} + + {GENERIC_WORKING_LABEL} ); }; diff --git a/packages/cli/src/ui/components/LoadingIndicator.test.tsx b/packages/cli/src/ui/components/LoadingIndicator.test.tsx index 4c4e3053ef..e5e0f6bb91 100644 --- a/packages/cli/src/ui/components/LoadingIndicator.test.tsx +++ b/packages/cli/src/ui/components/LoadingIndicator.test.tsx @@ -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('', () => { const defaultProps = { - currentLoadingPhrase: 'Loading...', + currentLoadingPhrase: 'Thinking...', elapsedTime: 5, }; @@ -71,8 +71,8 @@ describe('', () => { 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('', () => { 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('', () => { 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('', () => { 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('', () => { 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('', () => { 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('', () => { ); await waitUntilReady(); const output = lastFrame(); - expect(output).toContain('Loading...'); + expect(output).toContain('Thinking...'); unmount(); }); @@ -266,7 +266,7 @@ describe('', () => { 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('', () => { ); 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('', () => { ); 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('', () => { 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('', () => { // 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('', () => { 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( + , + 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( + , + 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( + , + StreamingState.WaitingForConfirmation, + ); + await waitUntilReady(); + const output = lastFrame(); + expect(output).toContain('?'); + expect(output).not.toContain('⠏'); }); }); diff --git a/packages/cli/src/ui/components/LoadingIndicator.tsx b/packages/cli/src/ui/components/LoadingIndicator.tsx index eba0a7d8a3..a48451b26c 100644 --- a/packages/cli/src/ui/components/LoadingIndicator.tsx +++ b/packages/cli/src/ui/components/LoadingIndicator.tsx @@ -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 = ({ 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 = ({ ? 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 = ({ ? `(esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)})` : null; + const wittyPhraseNode = + !forceRealStatusOnly && + showWit && + wittyPhrase && + primaryText === 'Thinking...' ? ( + + + {wittyPhrase} + + + ) : null; + if (inline) { return ( {primaryText && ( - {thinkingIndicator} {primaryText} {primaryText === INTERACTIVE_SHELL_WAITING_PHRASE && ( @@ -102,6 +122,7 @@ export const LoadingIndicator: React.FC = ({ {cancelAndTimerContent} )} + {wittyPhraseNode} ); } @@ -118,16 +139,17 @@ export const LoadingIndicator: React.FC = ({ {primaryText && ( - {thinkingIndicator} {primaryText} {primaryText === INTERACTIVE_SHELL_WAITING_PHRASE && ( @@ -144,6 +166,7 @@ export const LoadingIndicator: React.FC = ({ {cancelAndTimerContent} )} + {!isNarrow && wittyPhraseNode} {!isNarrow && {/* Spacer */}} {!isNarrow && rightContent && {rightContent}} @@ -153,6 +176,7 @@ export const LoadingIndicator: React.FC = ({ {cancelAndTimerContent} )} + {isNarrow && wittyPhraseNode} {isNarrow && rightContent && {rightContent}} ); diff --git a/packages/cli/src/ui/components/ShortcutsHint.tsx b/packages/cli/src/ui/components/ShortcutsHint.tsx deleted file mode 100644 index 4ecb01e9d8..0000000000 --- a/packages/cli/src/ui/components/ShortcutsHint.tsx +++ /dev/null @@ -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 press tab twice for more ; - } - - const highlightColor = shortcutsHelpVisible - ? theme.text.accent - : theme.text.secondary; - - return ? for shortcuts ; -}; diff --git a/packages/cli/src/ui/components/StatusDisplay.tsx b/packages/cli/src/ui/components/StatusDisplay.tsx index 223340c039..472e900b3b 100644 --- a/packages/cli/src/ui/components/StatusDisplay.tsx +++ b/packages/cli/src/ui/components/StatusDisplay.tsx @@ -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 = ({ return |⌐■_■|; } - if ( - uiState.activeHooks.length > 0 && - settings.merged.hooksConfig.notifications - ) { - return ; - } - if (!settings.merged.ui.hideContextSummary && !hideContextSummary) { return ( { if (uiState.showIsExpandableHint) { const action = uiState.constrainHeight ? 'show more' : 'collapse'; return ( - + Press Ctrl+O to {action} lines of the last response ); diff --git a/packages/cli/src/ui/components/__snapshots__/Composer.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/Composer.test.tsx.snap index 452663d719..745347bc95 100644 --- a/packages/cli/src/ui/components/__snapshots__/Composer.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/Composer.test.tsx.snap @@ -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 " diff --git a/packages/cli/src/ui/components/__snapshots__/ConfigInitDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ConfigInitDisplay.test.tsx.snap index 28929deee5..20ee186d27 100644 --- a/packages/cli/src/ui/components/__snapshots__/ConfigInitDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/ConfigInitDisplay.test.tsx.snap @@ -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 -" -`; diff --git a/packages/cli/src/ui/components/__snapshots__/ContextSummaryDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ContextSummaryDisplay.test.tsx.snap index e28d884acf..876524bdb8 100644 --- a/packages/cli/src/ui/components/__snapshots__/ContextSummaryDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/ContextSummaryDisplay.test.tsx.snap @@ -1,19 +1,16 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[` > should not render empty parts 1`] = ` -" - 1 open file (ctrl+g to view) +" 1 open file (ctrl+g to view) " `; exports[` > 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[` > 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 " `; diff --git a/packages/cli/src/ui/components/__snapshots__/HookStatusDisplay--HookStatusDisplay-matches-SVG-snapshot-for-single-hook.snap.svg b/packages/cli/src/ui/components/__snapshots__/HookStatusDisplay--HookStatusDisplay-matches-SVG-snapshot-for-single-hook.snap.svg new file mode 100644 index 0000000000..7c9cc6473c --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/HookStatusDisplay--HookStatusDisplay-matches-SVG-snapshot-for-single-hook.snap.svg @@ -0,0 +1,9 @@ + + + + + Executing Hook: test-hook + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/__snapshots__/HookStatusDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/HookStatusDisplay.test.tsx.snap index 458728736e..5e04b96cb8 100644 --- a/packages/cli/src/ui/components/__snapshots__/HookStatusDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/HookStatusDisplay.test.tsx.snap @@ -1,5 +1,7 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[` > matches SVG snapshot for single hook 1`] = `"Executing Hook: test-hook"`; + exports[` > should render a single executing hook 1`] = ` "Executing Hook: test-hook " diff --git a/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap index c0043bf6f9..ccbca75f8d 100644 --- a/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap @@ -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 │ diff --git a/packages/cli/src/ui/components/__snapshots__/StatusDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/StatusDisplay.test.tsx.snap index 2620531cc3..2e6b4b75ad 100644 --- a/packages/cli/src/ui/components/__snapshots__/StatusDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/StatusDisplay.test.tsx.snap @@ -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) " `; diff --git a/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap index 6d9baba94f..f752c1da65 100644 --- a/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap @@ -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 │ +│ │ ╰──────────────────────────────────────────────────────────────────────────────╯ " `; diff --git a/packages/cli/src/ui/components/shared/HorizontalLine.tsx b/packages/cli/src/ui/components/shared/HorizontalLine.tsx index 92935617a7..cdce88a4e5 100644 --- a/packages/cli/src/ui/components/shared/HorizontalLine.tsx +++ b/packages/cli/src/ui/components/shared/HorizontalLine.tsx @@ -10,10 +10,12 @@ import { theme } from '../../semantic-colors.js'; interface HorizontalLineProps { color?: string; + dim?: boolean; } export const HorizontalLine: React.FC = ({ color = theme.border.default, + dim = false, }) => ( = ({ borderLeft={false} borderRight={false} borderColor={color} + borderDimColor={dim} /> ); diff --git a/packages/cli/src/ui/constants/tips.ts b/packages/cli/src/ui/constants/tips.ts index a1ed09de3e..2b023b05eb 100644 --- a/packages/cli/src/ui/constants/tips.ts +++ b/packages/cli/src/ui/constants/tips.ts @@ -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 …', - 'Resume a saved conversation with /resume resume …', - 'Delete a conversation checkpoint with /resume delete …', - 'Share your conversation to a file with /resume share …', - '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 …', - '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 !…', + '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 ', + 'Resume a saved conversation with /resume resume ', + 'Delete a conversation checkpoint with /resume delete ', + 'Share your conversation to a file with /resume share ', + '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 ', + '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 tips end here ]; diff --git a/packages/cli/src/ui/constants/wittyPhrases.ts b/packages/cli/src/ui/constants/wittyPhrases.ts index a8facd9e5a..e37a74593f 100644 --- a/packages/cli/src/ui/constants/wittyPhrases.ts +++ b/packages/cli/src/ui/constants/wittyPhrases.ts @@ -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', ]; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index ea9025aa6b..f3bf108090 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -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[]; diff --git a/packages/cli/src/ui/hooks/__snapshots__/usePhraseCycler.test.tsx.snap b/packages/cli/src/ui/hooks/__snapshots__/usePhraseCycler.test.tsx.snap index 77d028caa7..3250d20060 100644 --- a/packages/cli/src/ui/hooks/__snapshots__/usePhraseCycler.test.tsx.snap +++ b/packages/cli/src/ui/hooks/__snapshots__/usePhraseCycler.test.tsx.snap @@ -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)"`; diff --git a/packages/cli/src/ui/hooks/useHookDisplayState.ts b/packages/cli/src/ui/hooks/useHookDisplayState.ts index 6c9e1811ad..c98bc7ba29 100644 --- a/packages/cli/src/ui/hooks/useHookDisplayState.ts +++ b/packages/cli/src/ui/hooks/useHookDisplayState.ts @@ -43,6 +43,7 @@ export const useHookDisplayState = () => { { name: payload.hookName, eventName: payload.eventName, + source: payload.source, index: payload.hookIndex, total: payload.totalHooks, }, diff --git a/packages/cli/src/ui/hooks/useLoadingIndicator.test.tsx b/packages/cli/src/ui/hooks/useLoadingIndicator.test.tsx index ae5e20e0e8..41e4ea255f 100644 --- a/packages/cli/src/ui/hooks/useLoadingIndicator.test.tsx +++ b/packages/cli/src/ui/hooks/useLoadingIndicator.test.tsx @@ -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; @@ -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( , ); @@ -79,12 +84,14 @@ describe('useLoadingIndicator', () => { streamingState: StreamingState; shouldShowFocusHint?: boolean; retryStatus?: RetryAttemptPayload | null; - mode?: LoadingPhrasesMode; + showTips?: boolean; + showWit?: boolean; errorVerbosity?: 'low' | 'full'; }) => rerender( , @@ -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(); diff --git a/packages/cli/src/ui/hooks/useLoadingIndicator.ts b/packages/cli/src/ui/hooks/useLoadingIndicator.ts index 4f7b631844..6d13615761 100644 --- a/packages/cli/src/ui/hooks/useLoadingIndicator.ts +++ b/packages/cli/src/ui/hooks/useLoadingIndicator.ts @@ -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, }; }; diff --git a/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx b/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx index ca89c623ac..ab7431da7a 100644 --- a/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx +++ b/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx @@ -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 {phrase}; + // For tests, we'll combine them to verify existence + return ( + {[currentTip, currentWittyPhrase].filter(Boolean).join(' | ')} + ); }; 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( , ); @@ -86,7 +91,7 @@ describe('usePhraseCycler', () => { , ); }); @@ -108,7 +113,7 @@ describe('usePhraseCycler', () => { , ); }); @@ -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( - , + , ); 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( - , + , ); 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( , ); }); 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( - , - ); - }); - 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', () => { ); @@ -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( - , + , ); 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( - , - ); - 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(); - }); - 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(); - }); - 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()); diff --git a/packages/cli/src/ui/hooks/usePhraseCycler.ts b/packages/cli/src/ui/hooks/usePhraseCycler.ts index 8ddab6eef9..1b82336afe 100644 --- a/packages/cli/src/ui/hooks/usePhraseCycler.ts +++ b/packages/cli/src/ui/hooks/usePhraseCycler.ts @@ -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( + undefined, + ); + const [currentWittyPhraseState, setCurrentWittyPhraseState] = useState< string | undefined >(undefined); - const phraseIntervalRef = useRef(null); - const hasShownFirstRequestTipRef = useRef(false); + const tipIntervalRef = useRef(null); + const wittyIntervalRef = useRef(null); + const lastTipChangeTimeRef = useRef(0); + const lastWittyChangeTimeRef = useRef(0); + const lastSelectedTipRef = useRef(undefined); + const lastSelectedWittyPhraseRef = useRef(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 }; }; diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx index c703f5102f..74c02c1d9a 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx @@ -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" diff --git a/packages/cli/src/ui/textConstants.ts b/packages/cli/src/ui/textConstants.ts index 00be0623d2..eaef8bf0ff 100644 --- a/packages/cli/src/ui/textConstants.ts +++ b/packages/cli/src/ui/textConstants.ts @@ -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...'; diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 2f8e414a83..ff3a839eb8 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -507,6 +507,7 @@ export interface PermissionConfirmationRequest { export interface ActiveHook { name: string; eventName: string; + source?: string; index?: number; total?: number; } diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index aa3e9aa5b6..4a75c5125f 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -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; } diff --git a/packages/core/src/hooks/hookEventHandler.ts b/packages/core/src/hooks/hookEventHandler.ts index a092bed334..e7b970875c 100644 --- a/packages/core/src/hooks/hookEventHandler.ts +++ b/packages/core/src/hooks/hookEventHandler.ts @@ -303,6 +303,7 @@ export class HookEventHandler { coreEvents.emitHookStart({ hookName: this.getHookName(config), eventName, + source: config.source, hookIndex: index + 1, totalHooks: plan.hookConfigs.length, }); diff --git a/packages/core/src/utils/events.ts b/packages/core/src/utils/events.ts index 47c42c93ba..bf3d997da1 100644 --- a/packages/core/src/utils/events.ts +++ b/packages/core/src/utils/events.ts @@ -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; /** diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index f85a39bb35..13cb707357 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -297,9 +297,16 @@ "type": "boolean" }, "hideTips": { - "title": "Hide Tips", - "description": "Hide helpful tips in the UI", - "markdownDescription": "Hide helpful tips in the UI\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `false`", + "title": "Hide Startup Tips", + "description": "Hide the introductory tips shown at the top of the screen.", + "markdownDescription": "Hide the introductory tips shown at the top of the screen.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `false`", + "default": false, + "type": "boolean" + }, + "hideIntroTips": { + "title": "Hide Intro Tips", + "description": "@deprecated Use ui.hideTips instead. Hide the intro tips in the header.", + "markdownDescription": "@deprecated Use ui.hideTips instead. Hide the intro tips in the header.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `false`", "default": false, "type": "boolean" }, @@ -312,8 +319,8 @@ }, "showShortcutsHint": { "title": "Show Shortcuts Hint", - "description": "Show the \"? for shortcuts\" hint above the input.", - "markdownDescription": "Show the \"? for shortcuts\" hint above the input.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `true`", + "description": "Show basic shortcut help ('?') when the status line is idle.", + "markdownDescription": "Show basic shortcut help ('?') when the status line is idle.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `true`", "default": true, "type": "boolean" }, @@ -455,10 +462,32 @@ "default": true, "type": "boolean" }, + "hideStatusTips": { + "title": "Hide Footer Tips", + "description": "Hide helpful tips in the footer while the model is working.", + "markdownDescription": "Hide helpful tips in the footer while the model is working.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `false`", + "default": false, + "type": "boolean" + }, + "hideStatusWit": { + "title": "Hide Footer Wit", + "description": "Hide witty loading phrases in the footer while the model is working.", + "markdownDescription": "Hide witty loading phrases in the footer while the model is working.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `true`", + "default": true, + "type": "boolean" + }, + "statusHints": { + "title": "Status Line Hints", + "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).", + "markdownDescription": "@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).\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `tips`", + "default": "tips", + "type": "string", + "enum": ["tips", "witty", "all", "off"] + }, "loadingPhrases": { "title": "Loading Phrases", - "description": "What to show while the model is working: tips, witty comments, both, or nothing.", - "markdownDescription": "What to show while the model is working: tips, witty comments, both, or nothing.\n\n- Category: `UI`\n- Requires restart: `no`\n- 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).", + "markdownDescription": "@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).\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `tips`", "default": "tips", "type": "string", "enum": ["tips", "witty", "all", "off"]