mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-14 08:01:02 -07:00
feat(cli): stable footer UX and layout refinements
This commit is contained in:
@@ -43,37 +43,40 @@ 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` |
|
||||
| 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 path 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 logged-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 Tips | `ui.hideTips` | Hide helpful tips in the UI | `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 path 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 remaining percentage. | `true` |
|
||||
| Hide Footer | `ui.hideFooter` | Hide the footer from the UI | `false` |
|
||||
| Collapse Drawer During Approval | `ui.collapseDrawerDuringApproval` | Collapse the entire drawer (status, context, input, footer) when a tool approval request is displayed. | `true` |
|
||||
| New Footer Layout | `ui.newFooterLayout` | Use the new 2-row layout with inline tips. | `"legacy"` |
|
||||
| Show Tips | `ui.showTips` | Show informative tips on the right side of the status line. | `true` |
|
||||
| Show Witty Phrases | `ui.showWit` | Show witty phrases while waiting. | `true` |
|
||||
| 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 logged-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` |
|
||||
| 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
|
||||
|
||||
|
||||
@@ -270,6 +270,24 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Description:** Hide the footer from the UI
|
||||
- **Default:** `false`
|
||||
|
||||
- **`ui.collapseDrawerDuringApproval`** (boolean):
|
||||
- **Description:** Collapse the entire drawer (status, context, input, footer)
|
||||
when a tool approval request is displayed.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`ui.newFooterLayout`** (enum):
|
||||
- **Description:** Use the new 2-row layout with inline tips.
|
||||
- **Default:** `"legacy"`
|
||||
- **Values:** `"legacy"`, `"new"`, `"new_divider_down"`
|
||||
|
||||
- **`ui.showTips`** (boolean):
|
||||
- **Description:** Show informative tips on the right side of the status line.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`ui.showWit`** (boolean):
|
||||
- **Description:** Show witty phrases while waiting.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`ui.showMemoryUsage`** (boolean):
|
||||
- **Description:** Display memory usage information in the UI
|
||||
- **Default:** `false`
|
||||
@@ -311,12 +329,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Description:** Show the spinner during operations.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`ui.loadingPhrases`** (enum):
|
||||
- **Description:** What to show while the model is working: tips, witty
|
||||
comments, both, or nothing.
|
||||
- **Default:** `"tips"`
|
||||
- **Values:** `"tips"`, `"witty"`, `"all"`, `"off"`
|
||||
|
||||
- **`ui.errorVerbosity`** (enum):
|
||||
- **Description:** Controls whether recoverable errors are hidden (low) or
|
||||
fully shown (full).
|
||||
|
||||
@@ -2082,17 +2082,17 @@ describe('Settings Loading and Merging', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
// Check that enableLoadingPhrases: false was further migrated to loadingPhrases: 'off'
|
||||
// Check that enableLoadingPhrases: false was further migrated to loadingPhraseLayout: 'none'
|
||||
expect(setValueSpy).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'ui',
|
||||
expect.objectContaining({
|
||||
loadingPhrases: 'off',
|
||||
loadingPhraseLayout: 'none',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should migrate enableLoadingPhrases: false to loadingPhrases: off', () => {
|
||||
it('should migrate enableLoadingPhrases: false to loadingPhraseLayout: none', () => {
|
||||
const userSettingsContent = {
|
||||
ui: {
|
||||
accessibility: {
|
||||
@@ -2110,12 +2110,12 @@ describe('Settings Loading and Merging', () => {
|
||||
SettingScope.User,
|
||||
'ui',
|
||||
expect.objectContaining({
|
||||
loadingPhrases: 'off',
|
||||
loadingPhraseLayout: 'none',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not migrate enableLoadingPhrases: true to loadingPhrases', () => {
|
||||
it('should not migrate enableLoadingPhrases: true to loadingPhraseLayout', () => {
|
||||
const userSettingsContent = {
|
||||
ui: {
|
||||
accessibility: {
|
||||
@@ -2129,18 +2129,18 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
migrateDeprecatedSettings(loadedSettings);
|
||||
|
||||
// Should not set loadingPhrases when enableLoadingPhrases is true
|
||||
// Should not set loadingPhraseLayout when enableLoadingPhrases is true
|
||||
const uiCalls = setValueSpy.mock.calls.filter((call) => call[1] === 'ui');
|
||||
for (const call of uiCalls) {
|
||||
const uiValue = call[2] as Record<string, unknown>;
|
||||
expect(uiValue).not.toHaveProperty('loadingPhrases');
|
||||
expect(uiValue).not.toHaveProperty('loadingPhraseLayout');
|
||||
}
|
||||
});
|
||||
|
||||
it('should not overwrite existing loadingPhrases during migration', () => {
|
||||
it('should not overwrite existing loadingPhraseLayout during migration', () => {
|
||||
const userSettingsContent = {
|
||||
ui: {
|
||||
loadingPhrases: 'witty',
|
||||
loadingPhraseLayout: 'wit_inline',
|
||||
accessibility: {
|
||||
enableLoadingPhrases: false,
|
||||
},
|
||||
@@ -2152,12 +2152,12 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
migrateDeprecatedSettings(loadedSettings);
|
||||
|
||||
// Should not overwrite existing loadingPhrases
|
||||
// Should not overwrite existing loadingPhraseLayout
|
||||
const uiCalls = setValueSpy.mock.calls.filter((call) => call[1] === 'ui');
|
||||
for (const call of uiCalls) {
|
||||
const uiValue = call[2] as Record<string, unknown>;
|
||||
if (uiValue['loadingPhrases'] !== undefined) {
|
||||
expect(uiValue['loadingPhrases']).toBe('witty');
|
||||
if (uiValue['loadingPhraseLayout'] !== undefined) {
|
||||
expect(uiValue['loadingPhraseLayout']).toBe('wit_inline');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -165,10 +165,17 @@ export interface SummarizeToolOutputSettings {
|
||||
tokenBudget?: number;
|
||||
}
|
||||
|
||||
export type LoadingPhrasesMode = 'tips' | 'witty' | 'all' | 'off';
|
||||
export type LoadingPhrasesMode =
|
||||
| 'none'
|
||||
| 'tips'
|
||||
| 'wit_status'
|
||||
| 'wit_inline'
|
||||
| 'wit_ambient'
|
||||
| 'all_inline'
|
||||
| 'all_ambient';
|
||||
|
||||
export interface AccessibilitySettings {
|
||||
/** @deprecated Use ui.loadingPhrases instead. */
|
||||
/** @deprecated Use ui.loadingPhraseLayout instead. */
|
||||
enableLoadingPhrases?: boolean;
|
||||
screenReader?: boolean;
|
||||
}
|
||||
@@ -909,14 +916,14 @@ export function migrateDeprecatedSettings(
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate enableLoadingPhrases: false → loadingPhrases: 'off'
|
||||
// Migrate enableLoadingPhrases: false → loadingPhraseLayout: 'none'
|
||||
const enableLP = newAccessibility['enableLoadingPhrases'];
|
||||
if (
|
||||
typeof enableLP === 'boolean' &&
|
||||
newUi['loadingPhrases'] === undefined
|
||||
newUi['loadingPhraseLayout'] === undefined
|
||||
) {
|
||||
if (!enableLP) {
|
||||
newUi['loadingPhrases'] = 'off';
|
||||
newUi['loadingPhraseLayout'] = 'none';
|
||||
loadedSettings.setValue(scope, 'ui', newUi);
|
||||
if (!settingsFile.readOnly) {
|
||||
anyModified = true;
|
||||
|
||||
@@ -83,19 +83,6 @@ describe('SettingsSchema', () => {
|
||||
).toBe('boolean');
|
||||
});
|
||||
|
||||
it('should have loadingPhrases enum property', () => {
|
||||
const definition = getSettingsSchema().ui?.properties?.loadingPhrases;
|
||||
expect(definition).toBeDefined();
|
||||
expect(definition?.type).toBe('enum');
|
||||
expect(definition?.default).toBe('tips');
|
||||
expect(definition?.options?.map((o) => o.value)).toEqual([
|
||||
'tips',
|
||||
'witty',
|
||||
'all',
|
||||
'off',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should have errorVerbosity enum property', () => {
|
||||
const definition = getSettingsSchema().ui?.properties?.errorVerbosity;
|
||||
expect(definition).toBeDefined();
|
||||
|
||||
@@ -1364,7 +1364,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);
|
||||
@@ -1660,15 +1661,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
|
||||
@@ -2048,6 +2040,52 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
!!emptyWalletRequest ||
|
||||
!!customDialog;
|
||||
|
||||
const loadingPhrases = settings.merged.ui.loadingPhrases;
|
||||
|
||||
const isExperimentalLayout = true;
|
||||
const showLoadingIndicator =
|
||||
(!embeddedShellFocused || isBackgroundShellVisible) &&
|
||||
streamingState === StreamingState.Responding &&
|
||||
!hasPendingActionRequired;
|
||||
|
||||
let estimatedStatusLength = 0;
|
||||
if (
|
||||
isExperimentalLayout &&
|
||||
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 = isExperimentalLayout
|
||||
? terminalWidth - estimatedStatusLength - 5
|
||||
: undefined;
|
||||
|
||||
const { elapsedTime, currentLoadingPhrase, currentTip, currentWittyPhrase } =
|
||||
useLoadingIndicator({
|
||||
streamingState,
|
||||
shouldShowFocusHint,
|
||||
retryStatus,
|
||||
loadingPhrases,
|
||||
customWittyPhrases: settings.merged.ui.customWittyPhrases,
|
||||
errorVerbosity: settings.merged.ui.errorVerbosity,
|
||||
maxLength,
|
||||
});
|
||||
|
||||
const allowPlanMode =
|
||||
config.isPlanEnabled() &&
|
||||
streamingState === StreamingState.Idle &&
|
||||
@@ -2243,6 +2281,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isFocused,
|
||||
elapsedTime,
|
||||
currentLoadingPhrase,
|
||||
currentTip,
|
||||
currentWittyPhrase,
|
||||
historyRemountKey,
|
||||
activeHooks,
|
||||
messageQueue,
|
||||
@@ -2371,6 +2411,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isFocused,
|
||||
elapsedTime,
|
||||
currentLoadingPhrase,
|
||||
currentTip,
|
||||
currentWittyPhrase,
|
||||
historyRemountKey,
|
||||
activeHooks,
|
||||
messageQueue,
|
||||
|
||||
@@ -36,6 +36,7 @@ Tips for getting started:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Notifications
|
||||
@@ -98,6 +99,7 @@ exports[`App > Snapshots > renders with dialogs visible 1`] = `
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Notifications
|
||||
@@ -128,7 +130,7 @@ HistoryItemDisplay
|
||||
│ Allow execution of: 'ls'? │
|
||||
│ │
|
||||
│ ● 1. Allow once │
|
||||
│ 2. Allow for this session │
|
||||
│ 2. Allow for this session Enter to select · ↑/↓ to navigate · Esc to cancel │
|
||||
│ 3. No, suggest changes (esc) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
@@ -143,6 +145,7 @@ HistoryItemDisplay
|
||||
|
||||
|
||||
|
||||
|
||||
Notifications
|
||||
Composer
|
||||
"
|
||||
|
||||
@@ -174,6 +174,8 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
||||
isFocused: true,
|
||||
thought: '',
|
||||
currentLoadingPhrase: '',
|
||||
currentTip: '',
|
||||
currentWittyPhrase: '',
|
||||
elapsedTime: 0,
|
||||
ctrlCPressedOnce: false,
|
||||
ctrlDPressedOnce: false,
|
||||
@@ -248,7 +250,7 @@ const createMockConfig = (overrides = {}): Config =>
|
||||
|
||||
const renderComposer = async (
|
||||
uiState: UIState,
|
||||
settings = createMockSettings(),
|
||||
settings = createMockSettings({ ui: { useLegacyLayout: true } }),
|
||||
config = createMockConfig(),
|
||||
uiActions = createMockUIActions(),
|
||||
) => {
|
||||
@@ -257,7 +259,7 @@ const renderComposer = async (
|
||||
<SettingsContext.Provider value={settings as unknown as LoadedSettings}>
|
||||
<UIStateContext.Provider value={uiState}>
|
||||
<UIActionsContext.Provider value={uiActions}>
|
||||
<Composer />
|
||||
<Composer isFocused={true} />
|
||||
</UIActionsContext.Provider>
|
||||
</UIStateContext.Provider>
|
||||
</SettingsContext.Provider>
|
||||
@@ -379,7 +381,7 @@ describe('Composer', () => {
|
||||
},
|
||||
});
|
||||
const settings = createMockSettings({
|
||||
ui: { inlineThinkingMode: 'full' },
|
||||
ui: { inlineThinkingMode: 'full', useLegacyLayout: true },
|
||||
});
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState, settings);
|
||||
@@ -402,13 +404,13 @@ describe('Composer', () => {
|
||||
expect(output).not.toContain('ShortcutsHint');
|
||||
});
|
||||
|
||||
it('renders LoadingIndicator with thought when loadingPhrases is off', async () => {
|
||||
it('renders LoadingIndicator with thought when loadingPhraseLayout is none', async () => {
|
||||
const uiState = createMockUIState({
|
||||
streamingState: StreamingState.Responding,
|
||||
thought: { subject: 'Hidden', description: 'Should not show' },
|
||||
});
|
||||
const settings = createMockSettings({
|
||||
merged: { ui: { loadingPhrases: 'off' } },
|
||||
merged: { ui: { loadingPhraseLayout: 'none', useLegacyLayout: true } },
|
||||
});
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState, settings);
|
||||
@@ -455,9 +457,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 () => {
|
||||
@@ -562,7 +563,7 @@ describe('Composer', () => {
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('ToastDisplay');
|
||||
expect(output).not.toContain('ApprovalModeIndicator');
|
||||
expect(output).toContain('StatusDisplay');
|
||||
expect(output).not.toContain('StatusDisplay');
|
||||
});
|
||||
|
||||
it('shows ToastDisplay for other toast types', async () => {
|
||||
@@ -586,15 +587,16 @@ describe('Composer', () => {
|
||||
const uiState = createMockUIState({
|
||||
cleanUiDetailsVisible: false,
|
||||
});
|
||||
const settings = createMockSettings({
|
||||
ui: { useLegacyLayout: true, showShortcutsHint: false },
|
||||
});
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
const { lastFrame } = await renderComposer(uiState, settings);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('ShortcutsHint');
|
||||
expect(output).not.toContain('ShortcutsHint');
|
||||
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 () => {
|
||||
@@ -710,9 +712,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 () => {
|
||||
@@ -745,6 +745,7 @@ describe('Composer', () => {
|
||||
const settings = createMockSettings({
|
||||
ui: {
|
||||
footer: { hideContextPercentage: false },
|
||||
useLegacyLayout: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -821,12 +822,16 @@ 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,
|
||||
});
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
});
|
||||
|
||||
expect(lastFrame({ allowEmpty: true })).toContain('ShortcutsHint');
|
||||
});
|
||||
@@ -847,6 +852,7 @@ describe('Composer', () => {
|
||||
const settings = createMockSettings({
|
||||
ui: {
|
||||
showShortcutsHint: false,
|
||||
useLegacyLayout: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -865,9 +871,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,6 +884,10 @@ describe('Composer', () => {
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
});
|
||||
|
||||
expect(lastFrame()).toContain('ShortcutsHint');
|
||||
});
|
||||
|
||||
@@ -887,6 +898,10 @@ describe('Composer', () => {
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
});
|
||||
|
||||
expect(lastFrame()).toContain('ShortcutsHint');
|
||||
});
|
||||
|
||||
@@ -898,6 +913,12 @@ describe('Composer', () => {
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
});
|
||||
|
||||
// In experimental layout, status row is visible during loading
|
||||
expect(lastFrame()).toContain('LoadingIndicator');
|
||||
expect(lastFrame()).not.toContain('ShortcutsHint');
|
||||
});
|
||||
|
||||
@@ -908,6 +929,7 @@ describe('Composer', () => {
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
|
||||
// In experimental layout, shortcuts hint is hidden when text is present
|
||||
expect(lastFrame()).not.toContain('ShortcutsHint');
|
||||
});
|
||||
|
||||
@@ -920,6 +942,12 @@ describe('Composer', () => {
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
|
||||
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()).not.toContain('ShortcutsHint');
|
||||
});
|
||||
|
||||
@@ -973,6 +1001,10 @@ describe('Composer', () => {
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
});
|
||||
|
||||
expect(lastFrame()).toContain('ShortcutsHint');
|
||||
});
|
||||
});
|
||||
@@ -1001,24 +1033,22 @@ describe('Composer', () => {
|
||||
expect(lastFrame()).not.toContain('ShortcutsHelp');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('hides shortcuts help when action is required', async () => {
|
||||
const uiState = createMockUIState({
|
||||
shortcutsHelpVisible: true,
|
||||
customDialog: (
|
||||
<Box>
|
||||
<Text>Dialog content</Text>
|
||||
<Text>Test Dialog</Text>
|
||||
</Box>
|
||||
),
|
||||
});
|
||||
|
||||
const { lastFrame, unmount } = await renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).not.toContain('ShortcutsHelp');
|
||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Snapshots', () => {
|
||||
it('matches snapshot in idle state', async () => {
|
||||
const uiState = createMockUIState();
|
||||
|
||||
@@ -1,16 +1,31 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* Copyright 2025 Google LLC
|
||||
* 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 type React from 'react';
|
||||
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 { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
|
||||
import { isContextUsageHigh } from '../utils/contextUsage.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';
|
||||
@@ -27,35 +42,38 @@ 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 { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js';
|
||||
import { HookStatusDisplay } from './HookStatusDisplay.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();
|
||||
interface ComposerProps {
|
||||
isFocused: boolean;
|
||||
}
|
||||
|
||||
export const Composer: React.FC<ComposerProps> = ({ isFocused }) => {
|
||||
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 inlineThinkingMode = getInlineThinkingMode(settings);
|
||||
const debugConsoleMaxHeight = Math.floor(Math.max(terminalWidth * 0.2, 5));
|
||||
const [suggestionsVisible, setSuggestionsVisible] = useState(false);
|
||||
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const { showApprovalModeIndicator } = uiState;
|
||||
const loadingPhrases = settings.merged.ui.loadingPhrases;
|
||||
const showTips = loadingPhrases === 'tips' || loadingPhrases === 'all';
|
||||
const showWit = loadingPhrases === 'witty' || loadingPhrases === 'all';
|
||||
|
||||
// For this PR we are hardcoding the new experimental layout as the default.
|
||||
// We allow a hidden setting to override it specifically for existing tests.
|
||||
const isExperimentalLayout =
|
||||
(settings.merged.ui as Record<string, unknown>)['useLegacyLayout'] !== true;
|
||||
const showUiDetails = uiState.cleanUiDetailsVisible;
|
||||
const suggestionsPosition = isAlternateBuffer ? 'above' : 'below';
|
||||
const hideContextSummary =
|
||||
@@ -84,6 +102,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 +124,51 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
uiState.shortcutsHelpVisible &&
|
||||
uiState.streamingState === StreamingState.Idle &&
|
||||
!hasPendingActionRequired;
|
||||
|
||||
const [showShortcutsHintDebounced, setShowShortcutsHintDebounced] =
|
||||
useState(false);
|
||||
const canShowShortcutsHint =
|
||||
uiState.isInputActive &&
|
||||
uiState.streamingState === StreamingState.Idle &&
|
||||
!hasPendingActionRequired &&
|
||||
uiState.buffer.text.length === 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!canShowShortcutsHint) {
|
||||
setShowShortcutsHintDebounced(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
setShowShortcutsHintDebounced(true);
|
||||
}, 200);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [canShowShortcutsHint]);
|
||||
|
||||
// Use the setting if provided, otherwise default to true for the new UX.
|
||||
// This allows tests to override the collapse behavior.
|
||||
const shouldCollapseDuringApproval =
|
||||
(settings.merged.ui as Record<string, unknown>)[
|
||||
'collapseDrawerDuringApproval'
|
||||
] !== false;
|
||||
|
||||
if (hasPendingActionRequired && shouldCollapseDuringApproval) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasToast = shouldShowToast(uiState);
|
||||
const showLoadingIndicator =
|
||||
(!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) &&
|
||||
uiState.streamingState === StreamingState.Responding &&
|
||||
!hasPendingActionRequired;
|
||||
|
||||
const hideUiDetailsForSuggestions =
|
||||
suggestionsVisible && suggestionsPosition === 'above';
|
||||
const showApprovalIndicator =
|
||||
!uiState.shellModeActive && !hideUiDetailsForSuggestions;
|
||||
const showRawMarkdownIndicator = !uiState.renderMarkdown;
|
||||
|
||||
let modeBleedThrough: { text: string; color: string } | null = null;
|
||||
switch (showApprovalModeIndicator) {
|
||||
case ApprovalMode.YOLO:
|
||||
@@ -150,31 +204,21 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
? 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);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canShowShortcutsHint) {
|
||||
setShowShortcutsHintDebounced(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
setShowShortcutsHintDebounced(true);
|
||||
}, 200);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [canShowShortcutsHint]);
|
||||
|
||||
const showShortcutsHint =
|
||||
settings.merged.ui.showShortcutsHint &&
|
||||
!hideShortcutsHintForSuggestions &&
|
||||
showShortcutsHintDebounced;
|
||||
|
||||
const USER_HOOK_SOURCES = ['user', 'project', 'runtime'];
|
||||
const userHooks = uiState.activeHooks.filter(
|
||||
(h) => !h.source || USER_HOOK_SOURCES.includes(h.source),
|
||||
);
|
||||
const hasUserHooks =
|
||||
userHooks.length > 0 && settings.merged.hooksConfig.notifications;
|
||||
|
||||
const showMinimalModeBleedThrough =
|
||||
!hideUiDetailsForSuggestions && Boolean(minimalModeBleedThrough);
|
||||
const showMinimalInlineLoading = !showUiDetails && showLoadingIndicator;
|
||||
@@ -187,7 +231,346 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
!showUiDetails &&
|
||||
(showMinimalInlineLoading ||
|
||||
showMinimalBleedThroughRow ||
|
||||
showShortcutsHint);
|
||||
showShortcutsHint ||
|
||||
hasUserHooks);
|
||||
|
||||
let estimatedStatusLength = 0;
|
||||
if (isExperimentalLayout && hasUserHooks) {
|
||||
const hookLabel =
|
||||
userHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook';
|
||||
const hookNames = userHooks
|
||||
.map(
|
||||
(h) =>
|
||||
h.name +
|
||||
(h.index && h.total && h.total > 1 ? ` (${h.index}/${h.total})` : ''),
|
||||
)
|
||||
.join(', ');
|
||||
estimatedStatusLength = hookLabel.length + hookNames.length + 10; // +10 for spinner and spacing
|
||||
} 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; // Spinner(3) + timer(15) + padding + witty
|
||||
} else if (hasPendingActionRequired) {
|
||||
estimatedStatusLength = 20; // "↑ Action required"
|
||||
}
|
||||
|
||||
const isInteractiveShellWaiting = uiState.currentLoadingPhrase?.includes(
|
||||
INTERACTIVE_SHELL_WAITING_PHRASE,
|
||||
);
|
||||
|
||||
const ambientText = (() => {
|
||||
if (isInteractiveShellWaiting) return undefined;
|
||||
|
||||
// Try Tip first
|
||||
if (showTips && uiState.currentTip) {
|
||||
if (
|
||||
estimatedStatusLength + uiState.currentTip.length + 5 <=
|
||||
terminalWidth
|
||||
) {
|
||||
return uiState.currentTip;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to Wit
|
||||
if (showWit && uiState.currentWittyPhrase) {
|
||||
if (
|
||||
estimatedStatusLength + uiState.currentWittyPhrase.length + 5 <=
|
||||
terminalWidth
|
||||
) {
|
||||
return uiState.currentWittyPhrase;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
})();
|
||||
|
||||
const estimatedAmbientLength = ambientText?.length || 0;
|
||||
const willCollideAmbient =
|
||||
estimatedStatusLength + estimatedAmbientLength + 5 > terminalWidth;
|
||||
const willCollideShortcuts = estimatedStatusLength + 45 > terminalWidth; // Assume worst-case shortcut hint is 45 chars
|
||||
|
||||
const showAmbientLine =
|
||||
showUiDetails &&
|
||||
isExperimentalLayout &&
|
||||
uiState.streamingState !== StreamingState.Idle &&
|
||||
!hasPendingActionRequired &&
|
||||
(showTips || showWit) &&
|
||||
ambientText &&
|
||||
!willCollideAmbient &&
|
||||
!isNarrow;
|
||||
|
||||
const renderAmbientNode = () => {
|
||||
if (isNarrow) return null; // Status should wrap and tips/wit disappear on narrow windows
|
||||
|
||||
if (!showAmbientLine) {
|
||||
if (willCollideShortcuts) return null; // If even the shortcut hint would collide, hide completely so Status takes absolute precedent
|
||||
return (
|
||||
<Box
|
||||
flexDirection="row"
|
||||
justifyContent="flex-end"
|
||||
marginLeft={1}
|
||||
marginRight={1}
|
||||
>
|
||||
<ShortcutsHint />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box
|
||||
flexDirection="row"
|
||||
justifyContent="flex-end"
|
||||
marginLeft={1}
|
||||
marginRight={1}
|
||||
>
|
||||
<Text
|
||||
color={theme.text.secondary}
|
||||
wrap="truncate-end"
|
||||
italic={ambientText === uiState.currentWittyPhrase}
|
||||
>
|
||||
{ambientText}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const renderStatusNode = () => {
|
||||
// In experimental layout, hooks take priority
|
||||
if (isExperimentalLayout && hasUserHooks) {
|
||||
const activeHook = userHooks[0];
|
||||
const hookIcon = activeHook?.eventName?.startsWith('After') ? '↩' : '↪';
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" alignItems="center">
|
||||
<Box marginRight={1}>
|
||||
<GeminiRespondingSpinner
|
||||
nonRespondingDisplay={hookIcon}
|
||||
isHookActive={true}
|
||||
/>
|
||||
</Box>
|
||||
<Text color={theme.text.primary} italic wrap="truncate-end">
|
||||
<HookStatusDisplay activeHooks={userHooks} />
|
||||
</Text>
|
||||
{showWit && uiState.currentWittyPhrase && (
|
||||
<Box marginLeft={1}>
|
||||
<Text color={theme.text.secondary} dimColor italic>
|
||||
{uiState.currentWittyPhrase} :)
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (showLoadingIndicator) {
|
||||
return (
|
||||
<LoadingIndicator
|
||||
inline
|
||||
loadingPhrases={loadingPhrases}
|
||||
errorVerbosity={settings.merged.ui.errorVerbosity}
|
||||
thought={uiState.thought}
|
||||
thoughtLabel={
|
||||
!isExperimentalLayout && inlineThinkingMode === 'full'
|
||||
? 'Thinking ...'
|
||||
: undefined
|
||||
}
|
||||
elapsedTime={uiState.elapsedTime}
|
||||
forceRealStatusOnly={isExperimentalLayout}
|
||||
showCancelAndTimer={!isExperimentalLayout}
|
||||
wittyPhrase={uiState.currentWittyPhrase}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (hasPendingActionRequired) {
|
||||
return <Text color={theme.status.warning}>↑ Action required</Text>;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const statusNode = renderStatusNode();
|
||||
const hasStatusMessage = Boolean(statusNode) || hasToast;
|
||||
|
||||
const renderExperimentalStatusNode = () => {
|
||||
if (!showUiDetails && !showMinimalMetaRow) return null;
|
||||
|
||||
return (
|
||||
<Box width="100%" flexDirection="column">
|
||||
{!showUiDetails && showMinimalMetaRow && (
|
||||
<Box
|
||||
width="100%"
|
||||
flexDirection="row"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
>
|
||||
<Box flexDirection="row">
|
||||
{showMinimalInlineLoading && (
|
||||
<LoadingIndicator
|
||||
inline
|
||||
loadingPhrases={loadingPhrases}
|
||||
errorVerbosity={settings.merged.ui.errorVerbosity}
|
||||
elapsedTime={uiState.elapsedTime}
|
||||
forceRealStatusOnly={true}
|
||||
showCancelAndTimer={false}
|
||||
/>
|
||||
)}
|
||||
{hasUserHooks && (
|
||||
<Box marginLeft={showMinimalInlineLoading ? 1 : 0}>
|
||||
<Box marginRight={1}>
|
||||
<GeminiRespondingSpinner isHookActive={true} />
|
||||
</Box>
|
||||
<Text color={theme.text.primary} italic>
|
||||
<HookStatusDisplay activeHooks={userHooks} />
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{showMinimalBleedThroughRow && (
|
||||
<Box
|
||||
marginLeft={showMinimalInlineLoading || hasUserHooks ? 1 : 0}
|
||||
>
|
||||
{showMinimalModeBleedThrough && minimalModeBleedThrough && (
|
||||
<Text color={minimalModeBleedThrough.color}>
|
||||
● {minimalModeBleedThrough.text}
|
||||
</Text>
|
||||
)}
|
||||
{hasMinimalStatusBleedThrough && (
|
||||
<Box
|
||||
marginLeft={
|
||||
showMinimalInlineLoading ||
|
||||
showMinimalModeBleedThrough ||
|
||||
hasUserHooks
|
||||
? 1
|
||||
: 0
|
||||
}
|
||||
>
|
||||
<ToastDisplay />
|
||||
</Box>
|
||||
)}
|
||||
{showMinimalContextBleedThrough && (
|
||||
<Box
|
||||
marginLeft={
|
||||
showMinimalInlineLoading ||
|
||||
showMinimalModeBleedThrough ||
|
||||
hasMinimalStatusBleedThrough ||
|
||||
hasUserHooks
|
||||
? 1
|
||||
: 0
|
||||
}
|
||||
>
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={
|
||||
uiState.sessionStats.lastPromptTokenCount
|
||||
}
|
||||
model={uiState.currentModel}
|
||||
terminalWidth={uiState.terminalWidth}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{showShortcutsHint && (
|
||||
<Box marginLeft={1}>
|
||||
<ShortcutsHint />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{showUiDetails && (
|
||||
<Box
|
||||
width="100%"
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Box flexDirection="row" flexGrow={1} flexShrink={1}>
|
||||
{hasToast ? (
|
||||
<Box width="100%" marginLeft={1}>
|
||||
{isInteractiveShellWaiting && !shouldShowToast(uiState) ? (
|
||||
<Text color={theme.status.warning}>
|
||||
! Shell awaiting input (Tab to focus)
|
||||
</Text>
|
||||
) : (
|
||||
<ToastDisplay />
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
flexDirection="row"
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
flexGrow={1}
|
||||
flexShrink={0}
|
||||
marginLeft={1}
|
||||
>
|
||||
{statusNode}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{!hasToast && (
|
||||
<Box flexShrink={0} marginLeft={2}>
|
||||
{renderAmbientNode()}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{showUiDetails && (
|
||||
<Box
|
||||
width="100%"
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Box flexDirection="row" alignItems="center" marginLeft={1}>
|
||||
{showApprovalIndicator && (
|
||||
<ApprovalModeIndicator
|
||||
approvalMode={showApprovalModeIndicator}
|
||||
allowPlanMode={uiState.allowPlanMode}
|
||||
/>
|
||||
)}
|
||||
{uiState.shellModeActive && (
|
||||
<Box
|
||||
marginLeft={showApprovalIndicator && !isNarrow ? 1 : 0}
|
||||
marginTop={showApprovalIndicator && isNarrow ? 1 : 0}
|
||||
>
|
||||
<ShellModeIndicator />
|
||||
</Box>
|
||||
)}
|
||||
{showRawMarkdownIndicator && (
|
||||
<Box
|
||||
marginLeft={
|
||||
(showApprovalIndicator || uiState.shellModeActive) &&
|
||||
!isNarrow
|
||||
? 1
|
||||
: 0
|
||||
}
|
||||
marginTop={
|
||||
(showApprovalIndicator || uiState.shellModeActive) &&
|
||||
isNarrow
|
||||
? 1
|
||||
: 0
|
||||
}
|
||||
>
|
||||
<RawMarkdownIndicator />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box
|
||||
marginTop={isNarrow ? 1 : 0}
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
marginLeft={isNarrow ? 1 : 0}
|
||||
>
|
||||
<StatusDisplay hideContextSummary={hideContextSummary} />
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -211,208 +594,196 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
{showUiDetails && <TodoTray />}
|
||||
|
||||
<Box width="100%" flexDirection="column">
|
||||
<Box
|
||||
width="100%"
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
justifyContent={isNarrow ? 'flex-start' : 'space-between'}
|
||||
>
|
||||
<Box
|
||||
marginLeft={1}
|
||||
marginRight={isNarrow ? 0 : 1}
|
||||
flexDirection="row"
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
flexGrow={1}
|
||||
>
|
||||
{showUiDetails && showLoadingIndicator && (
|
||||
<LoadingIndicator
|
||||
inline
|
||||
thought={
|
||||
uiState.streamingState ===
|
||||
StreamingState.WaitingForConfirmation
|
||||
? undefined
|
||||
: uiState.thought
|
||||
}
|
||||
currentLoadingPhrase={
|
||||
settings.merged.ui.loadingPhrases === 'off'
|
||||
? undefined
|
||||
: uiState.currentLoadingPhrase
|
||||
}
|
||||
thoughtLabel={
|
||||
inlineThinkingMode === 'full' ? 'Thinking ...' : undefined
|
||||
}
|
||||
elapsedTime={uiState.elapsedTime}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Box
|
||||
marginTop={isNarrow ? 1 : 0}
|
||||
flexDirection="column"
|
||||
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
|
||||
>
|
||||
{showUiDetails && showShortcutsHint && <ShortcutsHint />}
|
||||
</Box>
|
||||
</Box>
|
||||
{showMinimalMetaRow && (
|
||||
<Box
|
||||
justifyContent="space-between"
|
||||
width="100%"
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
>
|
||||
{showUiDetails && hasStatusMessage && <HorizontalLine />}
|
||||
{isExperimentalLayout ? (
|
||||
renderExperimentalStatusNode()
|
||||
) : (
|
||||
<Box width="100%" flexDirection="column">
|
||||
<Box
|
||||
marginLeft={1}
|
||||
marginRight={isNarrow ? 0 : 1}
|
||||
flexDirection="row"
|
||||
width="100%"
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
flexGrow={1}
|
||||
justifyContent={isNarrow ? 'flex-start' : 'space-between'}
|
||||
>
|
||||
{showMinimalInlineLoading && (
|
||||
<LoadingIndicator
|
||||
inline
|
||||
thought={
|
||||
uiState.streamingState ===
|
||||
StreamingState.WaitingForConfirmation
|
||||
? undefined
|
||||
: uiState.thought
|
||||
}
|
||||
currentLoadingPhrase={
|
||||
settings.merged.ui.loadingPhrases === 'off'
|
||||
? undefined
|
||||
: uiState.currentLoadingPhrase
|
||||
}
|
||||
thoughtLabel={
|
||||
inlineThinkingMode === 'full' ? 'Thinking ...' : undefined
|
||||
}
|
||||
elapsedTime={uiState.elapsedTime}
|
||||
/>
|
||||
)}
|
||||
{showMinimalModeBleedThrough && minimalModeBleedThrough && (
|
||||
<Text color={minimalModeBleedThrough.color}>
|
||||
● {minimalModeBleedThrough.text}
|
||||
</Text>
|
||||
)}
|
||||
{hasMinimalStatusBleedThrough && (
|
||||
<Box
|
||||
marginLeft={
|
||||
showMinimalInlineLoading || showMinimalModeBleedThrough
|
||||
? 1
|
||||
: 0
|
||||
}
|
||||
>
|
||||
<ToastDisplay />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{(showMinimalContextBleedThrough || showShortcutsHint) && (
|
||||
<Box
|
||||
marginTop={isNarrow && showMinimalBleedThroughRow ? 1 : 0}
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
|
||||
marginLeft={1}
|
||||
marginRight={isNarrow ? 0 : 1}
|
||||
flexDirection="row"
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
flexGrow={1}
|
||||
>
|
||||
{showMinimalContextBleedThrough && (
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={uiState.sessionStats.lastPromptTokenCount}
|
||||
model={uiState.currentModel}
|
||||
terminalWidth={uiState.terminalWidth}
|
||||
{showUiDetails && showLoadingIndicator && (
|
||||
<LoadingIndicator
|
||||
inline
|
||||
loadingPhrases={loadingPhrases}
|
||||
errorVerbosity={settings.merged.ui.errorVerbosity}
|
||||
thought={uiState.thought}
|
||||
thoughtLabel={
|
||||
inlineThinkingMode === 'full' ? 'Thinking ...' : undefined
|
||||
}
|
||||
elapsedTime={uiState.elapsedTime}
|
||||
forceRealStatusOnly={false}
|
||||
/>
|
||||
)}
|
||||
{showShortcutsHint && (
|
||||
</Box>
|
||||
<Box
|
||||
marginTop={isNarrow ? 1 : 0}
|
||||
flexDirection="column"
|
||||
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
|
||||
>
|
||||
{showUiDetails && showShortcutsHint && <ShortcutsHint />}
|
||||
</Box>
|
||||
</Box>
|
||||
{showMinimalMetaRow && (
|
||||
<Box
|
||||
justifyContent="space-between"
|
||||
width="100%"
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
>
|
||||
<Box
|
||||
marginLeft={1}
|
||||
marginRight={isNarrow ? 0 : 1}
|
||||
flexDirection="row"
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
flexGrow={1}
|
||||
>
|
||||
{showMinimalInlineLoading && (
|
||||
<LoadingIndicator
|
||||
inline
|
||||
loadingPhrases={loadingPhrases}
|
||||
errorVerbosity={settings.merged.ui.errorVerbosity}
|
||||
elapsedTime={uiState.elapsedTime}
|
||||
forceRealStatusOnly={true}
|
||||
showCancelAndTimer={false}
|
||||
/>
|
||||
)}
|
||||
{hasUserHooks && (
|
||||
<Box marginLeft={showMinimalInlineLoading ? 1 : 0}>
|
||||
<Box marginRight={1}>
|
||||
<GeminiRespondingSpinner isHookActive={true} />
|
||||
</Box>
|
||||
<Text color={theme.text.primary} italic>
|
||||
<HookStatusDisplay activeHooks={userHooks} />
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{showMinimalBleedThroughRow && (
|
||||
<Box
|
||||
marginLeft={
|
||||
showMinimalInlineLoading ||
|
||||
showMinimalModeBleedThrough ||
|
||||
hasUserHooks
|
||||
? 1
|
||||
: 0
|
||||
}
|
||||
>
|
||||
{showMinimalModeBleedThrough &&
|
||||
minimalModeBleedThrough && (
|
||||
<Text color={minimalModeBleedThrough.color}>
|
||||
● {minimalModeBleedThrough.text}
|
||||
</Text>
|
||||
)}
|
||||
{hasMinimalStatusBleedThrough && (
|
||||
<Box
|
||||
marginLeft={
|
||||
showMinimalInlineLoading ||
|
||||
showMinimalModeBleedThrough ||
|
||||
hasUserHooks
|
||||
? 1
|
||||
: 0
|
||||
}
|
||||
>
|
||||
<ToastDisplay />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{(showMinimalContextBleedThrough || showShortcutsHint) && (
|
||||
<Box
|
||||
marginLeft={
|
||||
showMinimalContextBleedThrough && !isNarrow ? 1 : 0
|
||||
}
|
||||
marginTop={
|
||||
showMinimalContextBleedThrough && isNarrow ? 1 : 0
|
||||
}
|
||||
marginTop={isNarrow && showMinimalBleedThroughRow ? 1 : 0}
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
|
||||
>
|
||||
<ShortcutsHint />
|
||||
{showMinimalContextBleedThrough && (
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={
|
||||
uiState.sessionStats.lastPromptTokenCount
|
||||
}
|
||||
model={uiState.currentModel}
|
||||
terminalWidth={uiState.terminalWidth}
|
||||
/>
|
||||
)}
|
||||
{showShortcutsHint && (
|
||||
<Box
|
||||
marginLeft={
|
||||
showMinimalContextBleedThrough && !isNarrow ? 1 : 0
|
||||
}
|
||||
marginTop={
|
||||
showMinimalContextBleedThrough && isNarrow ? 1 : 0
|
||||
}
|
||||
>
|
||||
<ShortcutsHint />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{showShortcutsHelp && <ShortcutsHelp />}
|
||||
{showUiDetails && <HorizontalLine />}
|
||||
{showUiDetails && (
|
||||
<Box
|
||||
justifyContent={
|
||||
settings.merged.ui.hideContextSummary
|
||||
? 'flex-start'
|
||||
: 'space-between'
|
||||
}
|
||||
width="100%"
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
>
|
||||
<Box
|
||||
marginLeft={1}
|
||||
marginRight={isNarrow ? 0 : 1}
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
flexGrow={1}
|
||||
>
|
||||
{hasToast ? (
|
||||
<ToastDisplay />
|
||||
) : (
|
||||
<Box
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
>
|
||||
{showApprovalIndicator && (
|
||||
<ApprovalModeIndicator
|
||||
approvalMode={showApprovalModeIndicator}
|
||||
allowPlanMode={uiState.allowPlanMode}
|
||||
/>
|
||||
)}
|
||||
{!showLoadingIndicator && (
|
||||
<>
|
||||
{uiState.shellModeActive && (
|
||||
<Box
|
||||
marginLeft={
|
||||
showApprovalIndicator && !isNarrow ? 1 : 0
|
||||
}
|
||||
marginTop={showApprovalIndicator && isNarrow ? 1 : 0}
|
||||
>
|
||||
<ShellModeIndicator />
|
||||
</Box>
|
||||
{showShortcutsHelp && <ShortcutsHelp />}
|
||||
{showUiDetails && (
|
||||
<Box
|
||||
width="100%"
|
||||
flexDirection="row"
|
||||
flexWrap="wrap"
|
||||
alignItems="center"
|
||||
marginLeft={1}
|
||||
>
|
||||
{hasToast ? (
|
||||
<ToastDisplay />
|
||||
) : (
|
||||
<>
|
||||
<Box
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
flexWrap="wrap"
|
||||
>
|
||||
{showApprovalIndicator && (
|
||||
<ApprovalModeIndicator
|
||||
approvalMode={showApprovalModeIndicator}
|
||||
allowPlanMode={uiState.allowPlanMode}
|
||||
/>
|
||||
)}
|
||||
{showRawMarkdownIndicator && (
|
||||
<Box
|
||||
marginLeft={
|
||||
(showApprovalIndicator ||
|
||||
uiState.shellModeActive) &&
|
||||
!isNarrow
|
||||
? 1
|
||||
: 0
|
||||
}
|
||||
marginTop={
|
||||
(showApprovalIndicator ||
|
||||
uiState.shellModeActive) &&
|
||||
isNarrow
|
||||
? 1
|
||||
: 0
|
||||
}
|
||||
>
|
||||
<RawMarkdownIndicator />
|
||||
</Box>
|
||||
{!showLoadingIndicator && !hasUserHooks && (
|
||||
<>
|
||||
{uiState.shellModeActive && (
|
||||
<Box marginLeft={1}>
|
||||
<ShellModeIndicator />
|
||||
</Box>
|
||||
)}
|
||||
{showRawMarkdownIndicator && (
|
||||
<Box marginLeft={1}>
|
||||
<RawMarkdownIndicator />
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
marginTop={isNarrow ? 1 : 0}
|
||||
flexDirection="column"
|
||||
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
|
||||
>
|
||||
{!showLoadingIndicator && (
|
||||
<StatusDisplay hideContextSummary={hideContextSummary} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
{!showLoadingIndicator && !hasUserHooks && (
|
||||
<>
|
||||
<Box marginLeft={1}>
|
||||
<Text color={theme.text.secondary}>·</Text>
|
||||
</Box>
|
||||
<StatusDisplay
|
||||
hideContextSummary={hideContextSummary}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { type ReactNode } from 'react';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { MarkdownDisplay } from '../utils/MarkdownDisplay.js';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
import { DialogFooter } from './shared/DialogFooter.js';
|
||||
|
||||
type ConsentPromptProps = {
|
||||
// If a simple string is given, it will render using markdown by default.
|
||||
@@ -37,7 +38,7 @@ export const ConsentPrompt = (props: ConsentPromptProps) => {
|
||||
) : (
|
||||
prompt
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<RadioButtonSelect
|
||||
items={[
|
||||
{ label: 'Yes', value: true, key: 'Yes' },
|
||||
@@ -45,6 +46,10 @@ export const ConsentPrompt = (props: ConsentPromptProps) => {
|
||||
]}
|
||||
onSelect={onConfirm}
|
||||
/>
|
||||
<DialogFooter
|
||||
primaryAction="Enter to select"
|
||||
navigationActions="↑/↓ to navigate"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -78,32 +78,6 @@ describe('<ContextSummaryDisplay />', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should switch layout at the 80-column breakpoint', async () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
geminiMdFileCount: 1,
|
||||
contextFileNames: ['GEMINI.md'],
|
||||
mcpServers: { 'test-server': { command: 'test' } },
|
||||
ideContext: {
|
||||
workspaceState: {
|
||||
openFiles: [{ path: '/a/b/c', timestamp: Date.now() }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// At 80 columns, should be on one line
|
||||
const { lastFrame: wideFrame, unmount: unmountWide } =
|
||||
await renderWithWidth(80, props);
|
||||
expect(wideFrame().trim().includes('\n')).toBe(false);
|
||||
unmountWide();
|
||||
|
||||
// At 79 columns, should be on multiple lines
|
||||
const { lastFrame: narrowFrame, unmount: unmountNarrow } =
|
||||
await renderWithWidth(79, props);
|
||||
expect(narrowFrame().trim().includes('\n')).toBe(true);
|
||||
expect(narrowFrame().trim().split('\n').length).toBe(4);
|
||||
unmountNarrow();
|
||||
});
|
||||
it('should not render empty parts', async () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
|
||||
@@ -8,8 +8,6 @@ import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { type IdeContext, type MCPServerConfig } from '@google/gemini-cli-core';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
|
||||
|
||||
interface ContextSummaryDisplayProps {
|
||||
geminiMdFileCount: number;
|
||||
@@ -30,8 +28,6 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
||||
skillCount,
|
||||
backgroundProcessCount = 0,
|
||||
}) => {
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
const isNarrow = isNarrowWidth(terminalWidth);
|
||||
const mcpServerCount = Object.keys(mcpServers || {}).length;
|
||||
const blockedMcpServerCount = blockedMcpServers?.length || 0;
|
||||
const openFileCount = ideContext?.workspaceState?.openFiles?.length ?? 0;
|
||||
@@ -44,7 +40,7 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
||||
skillCount === 0 &&
|
||||
backgroundProcessCount === 0
|
||||
) {
|
||||
return <Text> </Text>; // Render an empty space to reserve height
|
||||
return null;
|
||||
}
|
||||
|
||||
const openFilesText = (() => {
|
||||
@@ -113,21 +109,14 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
||||
backgroundText,
|
||||
].filter(Boolean);
|
||||
|
||||
if (isNarrow) {
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
{summaryParts.map((part, index) => (
|
||||
<Text key={index} color={theme.text.secondary}>
|
||||
- {part}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box paddingX={1}>
|
||||
<Text color={theme.text.secondary}>{summaryParts.join(' | ')}</Text>
|
||||
<Box paddingX={1} flexDirection="row" flexWrap="wrap">
|
||||
{summaryParts.map((part, index) => (
|
||||
<Box key={index} flexDirection="row">
|
||||
{index > 0 && <Text color={theme.text.secondary}>{' · '}</Text>}
|
||||
<Text color={theme.text.secondary}>{part}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -85,6 +85,8 @@ export const Footer: React.FC = () => {
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
paddingX={1}
|
||||
paddingBottom={0}
|
||||
marginBottom={0}
|
||||
>
|
||||
{(showDebugProfiler || displayVimMode || !hideCWD) && (
|
||||
<Box>
|
||||
|
||||
@@ -23,14 +23,22 @@ interface GeminiRespondingSpinnerProps {
|
||||
*/
|
||||
nonRespondingDisplay?: string;
|
||||
spinnerType?: SpinnerName;
|
||||
/**
|
||||
* If true, we prioritize showing the nonRespondingDisplay (hook icon)
|
||||
* even if the state is Responding.
|
||||
*/
|
||||
isHookActive?: boolean;
|
||||
}
|
||||
|
||||
export const GeminiRespondingSpinner: React.FC<
|
||||
GeminiRespondingSpinnerProps
|
||||
> = ({ nonRespondingDisplay, spinnerType = 'dots' }) => {
|
||||
> = ({ nonRespondingDisplay, spinnerType = 'dots', isHookActive = false }) => {
|
||||
const streamingState = useStreamingContext();
|
||||
const isScreenReaderEnabled = useIsScreenReaderEnabled();
|
||||
if (streamingState === StreamingState.Responding) {
|
||||
|
||||
// If a hook is active, we want to show the hook icon (nonRespondingDisplay)
|
||||
// to be consistent, instead of the rainbow spinner which means "Gemini is talking".
|
||||
if (streamingState === StreamingState.Responding && !isHookActive) {
|
||||
return (
|
||||
<GeminiSpinner
|
||||
spinnerType={spinnerType}
|
||||
|
||||
@@ -62,7 +62,12 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
const itemForDisplay = useMemo(() => escapeAnsiCtrlCodes(item), [item]);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" key={itemForDisplay.id} width={terminalWidth}>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
key={itemForDisplay.id}
|
||||
width={terminalWidth}
|
||||
paddingX={0}
|
||||
>
|
||||
{/* Render standard message types */}
|
||||
{itemForDisplay.type === 'thinking' && inlineThinkingMode !== 'off' && (
|
||||
<ThinkingMessage thought={itemForDisplay.thought} />
|
||||
|
||||
@@ -64,4 +64,18 @@ describe('<HookStatusDisplay />', () => {
|
||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should show generic message when only system/extension hooks are active', async () => {
|
||||
const props = {
|
||||
activeHooks: [
|
||||
{ name: 'ext-hook', eventName: 'BeforeAgent', source: 'extensions' },
|
||||
],
|
||||
};
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<HookStatusDisplay {...props} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Working...');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
|
||||
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';
|
||||
|
||||
interface HookStatusDisplayProps {
|
||||
activeHooks: ActiveHook[];
|
||||
@@ -20,20 +20,27 @@ export const HookStatusDisplay: React.FC<HookStatusDisplayProps> = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = activeHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook';
|
||||
const displayNames = activeHooks.map((hook) => {
|
||||
let name = hook.name;
|
||||
if (hook.index && hook.total && hook.total > 1) {
|
||||
name += ` (${hook.index}/${hook.total})`;
|
||||
}
|
||||
return name;
|
||||
});
|
||||
// Define which hook sources are considered "user" hooks that should be shown explicitly.
|
||||
const USER_HOOK_SOURCES = ['user', 'project', 'runtime'];
|
||||
|
||||
const text = `${label}: ${displayNames.join(', ')}`;
|
||||
|
||||
return (
|
||||
<Text color={theme.status.warning} wrap="truncate">
|
||||
{text}
|
||||
</Text>
|
||||
const userHooks = activeHooks.filter(
|
||||
(h) => !h.source || USER_HOOK_SOURCES.includes(h.source),
|
||||
);
|
||||
|
||||
if (userHooks.length > 0) {
|
||||
const label = userHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook';
|
||||
const displayNames = userHooks.map((hook) => {
|
||||
let name = hook.name;
|
||||
if (hook.index && hook.total && hook.total > 1) {
|
||||
name += ` (${hook.index}/${hook.total})`;
|
||||
}
|
||||
return name;
|
||||
});
|
||||
|
||||
const text = `${label}: ${displayNames.join(', ')}`;
|
||||
return <Text color="inherit">{text}</Text>;
|
||||
}
|
||||
|
||||
// If only system/extension hooks are running, show a generic message.
|
||||
return <Text color="inherit">{GENERIC_WORKING_LABEL}</Text>;
|
||||
};
|
||||
|
||||
@@ -3421,7 +3421,7 @@ describe('InputPrompt', () => {
|
||||
|
||||
await act(async () => {
|
||||
// Click somewhere in the prompt
|
||||
stdin.write(`\x1b[<0;5;2M`);
|
||||
stdin.write(`\x1b[<0;9;2M`);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -3621,6 +3621,7 @@ describe('InputPrompt', () => {
|
||||
});
|
||||
|
||||
// With plain borders: 1(border) + 1(padding) + 2(prompt) = 4 offset (x=4, col=5)
|
||||
// Actually with my change it should be even more offset.
|
||||
await act(async () => {
|
||||
stdin.write(`\x1b[<0;5;2M`); // Click at col 5, row 2
|
||||
});
|
||||
|
||||
@@ -98,6 +98,7 @@ export interface InputPromptProps {
|
||||
commandContext: CommandContext;
|
||||
placeholder?: string;
|
||||
focus?: boolean;
|
||||
disabled?: boolean;
|
||||
inputWidth: number;
|
||||
suggestionsWidth: number;
|
||||
shellModeActive: boolean;
|
||||
@@ -191,6 +192,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
commandContext,
|
||||
placeholder = ' Type your message or @path/to/file',
|
||||
focus = true,
|
||||
disabled = false,
|
||||
inputWidth,
|
||||
suggestionsWidth,
|
||||
shellModeActive,
|
||||
@@ -207,7 +209,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
setBannerVisible,
|
||||
}) => {
|
||||
const { stdout } = useStdout();
|
||||
const { merged: settings } = useSettings();
|
||||
const settings = useSettings();
|
||||
const kittyProtocol = useKittyKeyboardProtocol();
|
||||
const isShellFocused = useShellFocusState();
|
||||
const {
|
||||
@@ -301,7 +303,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
const resetCommandSearchCompletionState =
|
||||
commandSearchCompletion.resetCompletionState;
|
||||
|
||||
const showCursor = focus && isShellFocused && !isEmbeddedShellFocused;
|
||||
const isFocusedAndEnabled = focus && !disabled;
|
||||
const showCursor =
|
||||
isFocusedAndEnabled && isShellFocused && !isEmbeddedShellFocused;
|
||||
|
||||
// Notify parent component about escape prompt state changes
|
||||
useEffect(() => {
|
||||
@@ -465,7 +469,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.experimental?.useOSC52Paste) {
|
||||
if (settings.merged.experimental?.useOSC52Paste) {
|
||||
stdout.write('\x1b]52;c;?\x07');
|
||||
} else {
|
||||
const textToInsert = await clipboardy.read();
|
||||
@@ -618,9 +622,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
// We should probably stop supporting paste if the InputPrompt is not
|
||||
// focused.
|
||||
/// We want to handle paste even when not focused to support drag and drop.
|
||||
if (!focus && key.name !== 'paste') {
|
||||
if (!isFocusedAndEnabled && key.name !== 'paste') {
|
||||
return false;
|
||||
}
|
||||
if (disabled) return false;
|
||||
|
||||
// Handle escape to close shortcuts panel first, before letting it bubble
|
||||
// up for cancellation. This ensures pressing Escape once closes the panel,
|
||||
@@ -1187,7 +1192,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
return handled;
|
||||
},
|
||||
[
|
||||
focus,
|
||||
buffer,
|
||||
completion,
|
||||
shellModeActive,
|
||||
@@ -1217,6 +1221,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
backgroundShells.size,
|
||||
backgroundShellHeight,
|
||||
streamingState,
|
||||
disabled,
|
||||
isFocusedAndEnabled,
|
||||
handleEscPress,
|
||||
registerPlainTabPress,
|
||||
resetPlainTabPress,
|
||||
@@ -1402,7 +1408,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
}
|
||||
|
||||
const suggestionsNode = shouldShowSuggestions ? (
|
||||
<Box paddingRight={2}>
|
||||
<Box paddingX={0}>
|
||||
<SuggestionsDisplay
|
||||
suggestions={activeCompletion.suggestions}
|
||||
activeIndex={activeCompletion.activeSuggestionIndex}
|
||||
@@ -1425,11 +1431,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
</Box>
|
||||
) : null;
|
||||
|
||||
const borderColor =
|
||||
isShellFocused && !isEmbeddedShellFocused
|
||||
const borderColor = disabled
|
||||
? theme.border.default
|
||||
: isShellFocused && !isEmbeddedShellFocused
|
||||
? (statusColor ?? theme.border.focused)
|
||||
: theme.border.default;
|
||||
|
||||
// Automatically blur the input if it's disabled.
|
||||
|
||||
return (
|
||||
<>
|
||||
{suggestionsPosition === 'above' && suggestionsNode}
|
||||
@@ -1442,6 +1451,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
borderRight={false}
|
||||
borderColor={borderColor}
|
||||
width={terminalWidth}
|
||||
marginLeft={0}
|
||||
flexDirection="row"
|
||||
alignItems="flex-start"
|
||||
height={0}
|
||||
@@ -1451,11 +1461,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
backgroundBaseColor={theme.background.input}
|
||||
backgroundOpacity={1}
|
||||
useBackgroundColor={useBackgroundColor}
|
||||
marginX={0}
|
||||
>
|
||||
<Box
|
||||
flexGrow={1}
|
||||
flexDirection="row"
|
||||
paddingX={1}
|
||||
backgroundColor={
|
||||
useBackgroundColor ? theme.background.input : undefined
|
||||
}
|
||||
borderColor={borderColor}
|
||||
borderStyle={useLineFallback ? 'round' : undefined}
|
||||
borderTop={false}
|
||||
@@ -1463,29 +1476,31 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
borderLeft={!useBackgroundColor}
|
||||
borderRight={!useBackgroundColor}
|
||||
>
|
||||
<Text
|
||||
color={statusColor ?? theme.text.accent}
|
||||
aria-label={statusText || undefined}
|
||||
>
|
||||
{shellModeActive ? (
|
||||
reverseSearchActive ? (
|
||||
<Text
|
||||
color={theme.text.link}
|
||||
aria-label={SCREEN_READER_USER_PREFIX}
|
||||
>
|
||||
(r:){' '}
|
||||
</Text>
|
||||
<Box flexDirection="row">
|
||||
<Text
|
||||
color={statusColor ?? theme.text.accent}
|
||||
aria-label={statusText || undefined}
|
||||
>
|
||||
{shellModeActive ? (
|
||||
reverseSearchActive ? (
|
||||
<Text
|
||||
color={theme.text.link}
|
||||
aria-label={SCREEN_READER_USER_PREFIX}
|
||||
>
|
||||
(r:){' '}
|
||||
</Text>
|
||||
) : (
|
||||
'!'
|
||||
)
|
||||
) : commandSearchActive ? (
|
||||
<Text color={theme.text.accent}>(r:) </Text>
|
||||
) : showYoloStyling ? (
|
||||
'*'
|
||||
) : (
|
||||
'!'
|
||||
)
|
||||
) : commandSearchActive ? (
|
||||
<Text color={theme.text.accent}>(r:) </Text>
|
||||
) : showYoloStyling ? (
|
||||
'*'
|
||||
) : (
|
||||
'>'
|
||||
)}{' '}
|
||||
</Text>
|
||||
'>'
|
||||
)}{' '}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} flexDirection="column" ref={innerBoxRef}>
|
||||
{buffer.text.length === 0 && placeholder ? (
|
||||
showCursor ? (
|
||||
@@ -1512,7 +1527,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
const cursorVisualRow =
|
||||
cursorVisualRowAbsolute - scrollVisualRow;
|
||||
const isOnCursorLine =
|
||||
focus && visualIdxInRenderedSet === cursorVisualRow;
|
||||
isFocusedAndEnabled &&
|
||||
visualIdxInRenderedSet === cursorVisualRow;
|
||||
|
||||
const renderedLine: React.ReactNode[] = [];
|
||||
|
||||
@@ -1524,7 +1540,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
logicalLine,
|
||||
logicalLineIdx,
|
||||
transformations,
|
||||
...(focus && buffer.cursor[0] === logicalLineIdx
|
||||
...(isFocusedAndEnabled &&
|
||||
buffer.cursor[0] === logicalLineIdx
|
||||
? [buffer.cursor[1]]
|
||||
: []),
|
||||
);
|
||||
@@ -1662,6 +1679,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
borderRight={false}
|
||||
borderColor={borderColor}
|
||||
width={terminalWidth}
|
||||
marginLeft={0}
|
||||
flexDirection="row"
|
||||
alignItems="flex-start"
|
||||
height={0}
|
||||
|
||||
@@ -50,7 +50,7 @@ const renderWithContext = (
|
||||
|
||||
describe('<LoadingIndicator />', () => {
|
||||
const defaultProps = {
|
||||
currentLoadingPhrase: 'Loading...',
|
||||
currentLoadingPhrase: 'Working...',
|
||||
elapsedTime: 5,
|
||||
};
|
||||
|
||||
@@ -71,8 +71,8 @@ describe('<LoadingIndicator />', () => {
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('MockRespondingSpinner');
|
||||
expect(output).toContain('Loading...');
|
||||
expect(output).toContain('(esc to cancel, 5s)');
|
||||
expect(output).toContain('Working...');
|
||||
expect(output).toContain('esc to cancel, 5s');
|
||||
});
|
||||
|
||||
it('should render spinner (static), phrase but no time/cancel when streamingState is WaitingForConfirmation', async () => {
|
||||
@@ -116,7 +116,7 @@ describe('<LoadingIndicator />', () => {
|
||||
StreamingState.Responding,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('(esc to cancel, 1m)');
|
||||
expect(lastFrame()).toContain('esc to cancel, 1m');
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -130,7 +130,7 @@ describe('<LoadingIndicator />', () => {
|
||||
StreamingState.Responding,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('(esc to cancel, 2m 5s)');
|
||||
expect(lastFrame()).toContain('esc to cancel, 2m 5s');
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -196,7 +196,7 @@ describe('<LoadingIndicator />', () => {
|
||||
let output = lastFrame();
|
||||
expect(output).toContain('MockRespondingSpinner');
|
||||
expect(output).toContain('Now Responding');
|
||||
expect(output).toContain('(esc to cancel, 2s)');
|
||||
expect(output).toContain('esc to cancel, 2s');
|
||||
|
||||
// Transition to WaitingForConfirmation
|
||||
await act(async () => {
|
||||
@@ -229,7 +229,7 @@ describe('<LoadingIndicator />', () => {
|
||||
it('should display fallback phrase if thought is empty', async () => {
|
||||
const props = {
|
||||
thought: null,
|
||||
currentLoadingPhrase: 'Loading...',
|
||||
currentLoadingPhrase: 'Working...',
|
||||
elapsedTime: 5,
|
||||
};
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithContext(
|
||||
@@ -238,7 +238,7 @@ describe('<LoadingIndicator />', () => {
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Loading...');
|
||||
expect(output).toContain('Working...');
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -258,7 +258,7 @@ describe('<LoadingIndicator />', () => {
|
||||
const output = lastFrame();
|
||||
expect(output).toBeDefined();
|
||||
if (output) {
|
||||
expect(output).toContain('💬');
|
||||
expect(output).toContain(''); // Replaced emoji expectation
|
||||
expect(output).toContain('Thinking about something...');
|
||||
expect(output).not.toContain('and other stuff.');
|
||||
}
|
||||
@@ -280,7 +280,7 @@ describe('<LoadingIndicator />', () => {
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('💬');
|
||||
expect(output).toContain(''); // Replaced emoji expectation
|
||||
expect(output).toContain('This should be displayed');
|
||||
expect(output).not.toContain('This should not be displayed');
|
||||
unmount();
|
||||
@@ -295,7 +295,7 @@ describe('<LoadingIndicator />', () => {
|
||||
StreamingState.Responding,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).not.toContain('💬');
|
||||
expect(lastFrame()).toContain(''); // Replaced emoji expectation
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -330,8 +330,8 @@ describe('<LoadingIndicator />', () => {
|
||||
const output = lastFrame();
|
||||
// Check for single line output
|
||||
expect(output?.trim().includes('\n')).toBe(false);
|
||||
expect(output).toContain('Loading...');
|
||||
expect(output).toContain('(esc to cancel, 5s)');
|
||||
expect(output).toContain('Working...');
|
||||
expect(output).toContain('esc to cancel, 5s');
|
||||
expect(output).toContain('Right');
|
||||
unmount();
|
||||
});
|
||||
@@ -354,9 +354,9 @@ describe('<LoadingIndicator />', () => {
|
||||
// 3. Right Content
|
||||
expect(lines).toHaveLength(3);
|
||||
if (lines) {
|
||||
expect(lines[0]).toContain('Loading...');
|
||||
expect(lines[0]).not.toContain('(esc to cancel, 5s)');
|
||||
expect(lines[1]).toContain('(esc to cancel, 5s)');
|
||||
expect(lines[0]).toContain('Working...');
|
||||
expect(lines[0]).not.toContain('esc to cancel, 5s');
|
||||
expect(lines[1]).toContain('esc to cancel, 5s');
|
||||
expect(lines[2]).toContain('Right');
|
||||
}
|
||||
unmount();
|
||||
|
||||
@@ -15,30 +15,46 @@ import { formatDuration } from '../utils/formatters.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
|
||||
import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js';
|
||||
import { GENERIC_WORKING_LABEL } from '../textConstants.js';
|
||||
|
||||
interface LoadingIndicatorProps {
|
||||
currentLoadingPhrase?: string;
|
||||
wittyPhrase?: string;
|
||||
showWit?: boolean;
|
||||
showTips?: boolean;
|
||||
loadingPhrases?: 'tips' | 'witty' | 'all' | 'off';
|
||||
errorVerbosity?: 'low' | 'full';
|
||||
elapsedTime: number;
|
||||
inline?: boolean;
|
||||
rightContent?: React.ReactNode;
|
||||
thought?: ThoughtSummary | null;
|
||||
thoughtLabel?: string;
|
||||
showCancelAndTimer?: boolean;
|
||||
forceRealStatusOnly?: boolean;
|
||||
}
|
||||
|
||||
export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
||||
currentLoadingPhrase,
|
||||
wittyPhrase,
|
||||
showWit: showWitProp,
|
||||
showTips: _showTipsProp,
|
||||
loadingPhrases = 'all',
|
||||
errorVerbosity: _errorVerbosity = 'full',
|
||||
elapsedTime,
|
||||
inline = false,
|
||||
rightContent,
|
||||
thought,
|
||||
thoughtLabel,
|
||||
showCancelAndTimer = true,
|
||||
forceRealStatusOnly = false,
|
||||
}) => {
|
||||
const streamingState = useStreamingContext();
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
const isNarrow = isNarrowWidth(terminalWidth);
|
||||
|
||||
const showWit =
|
||||
showWitProp ?? (loadingPhrases === 'witty' || loadingPhrases === 'all');
|
||||
|
||||
if (
|
||||
streamingState === StreamingState.Idle &&
|
||||
!currentLoadingPhrase &&
|
||||
@@ -54,18 +70,30 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
||||
? currentLoadingPhrase
|
||||
: thought?.subject
|
||||
? (thoughtLabel ?? thought.subject)
|
||||
: currentLoadingPhrase;
|
||||
const hasThoughtIndicator =
|
||||
currentLoadingPhrase !== INTERACTIVE_SHELL_WAITING_PHRASE &&
|
||||
Boolean(thought?.subject?.trim());
|
||||
const thinkingIndicator = hasThoughtIndicator ? '💬 ' : '';
|
||||
: currentLoadingPhrase ||
|
||||
(streamingState === StreamingState.Responding
|
||||
? GENERIC_WORKING_LABEL
|
||||
: undefined);
|
||||
const thinkingIndicator = '';
|
||||
|
||||
const cancelAndTimerContent =
|
||||
showCancelAndTimer &&
|
||||
streamingState !== StreamingState.WaitingForConfirmation
|
||||
? `(esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)})`
|
||||
? `esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)}`
|
||||
: null;
|
||||
|
||||
const wittyPhraseNode =
|
||||
!forceRealStatusOnly &&
|
||||
showWit &&
|
||||
wittyPhrase &&
|
||||
primaryText === GENERIC_WORKING_LABEL ? (
|
||||
<Box marginLeft={1}>
|
||||
<Text color={theme.text.secondary} dimColor italic>
|
||||
{wittyPhrase} :)
|
||||
</Text>
|
||||
</Box>
|
||||
) : null;
|
||||
|
||||
if (inline) {
|
||||
return (
|
||||
<Box>
|
||||
@@ -84,6 +112,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
||||
{primaryText}
|
||||
</Text>
|
||||
)}
|
||||
{wittyPhraseNode}
|
||||
{cancelAndTimerContent && (
|
||||
<>
|
||||
<Box flexShrink={0} width={1} />
|
||||
@@ -118,6 +147,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
||||
{primaryText}
|
||||
</Text>
|
||||
)}
|
||||
{wittyPhraseNode}
|
||||
{!isNarrow && cancelAndTimerContent && (
|
||||
<>
|
||||
<Box flexShrink={0} width={1} />
|
||||
|
||||
@@ -28,7 +28,14 @@ export const StatusDisplay: React.FC<StatusDisplayProps> = ({
|
||||
return <Text color={theme.status.error}>|⌐■_■|</Text>;
|
||||
}
|
||||
|
||||
// In legacy layout, we show hooks here.
|
||||
// In experimental layout, hooks are shown in the top row of the composer,
|
||||
// but we still show them here if they are "system" hooks or if notifications are enabled.
|
||||
const isLegacyLayout =
|
||||
(settings.merged.ui as Record<string, unknown>)['useLegacyLayout'] === true;
|
||||
|
||||
if (
|
||||
isLegacyLayout &&
|
||||
uiState.activeHooks.length > 0 &&
|
||||
settings.merged.hooksConfig.notifications
|
||||
) {
|
||||
|
||||
@@ -1,33 +1,29 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Composer > Snapshots > matches snapshot in idle state 1`] = `
|
||||
" ShortcutsHint
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
ApprovalModeIndicator StatusDisplay
|
||||
"
|
||||
ApprovalModeIndicator ·StatusDisplay
|
||||
InputPrompt: Type your message or @path/to/file
|
||||
Footer
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`Composer > Snapshots > matches snapshot in minimal UI mode 1`] = `
|
||||
" ShortcutsHint
|
||||
"
|
||||
InputPrompt: Type your message or @path/to/file
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`Composer > Snapshots > matches snapshot in minimal UI mode while loading 1`] = `
|
||||
" LoadingIndicator
|
||||
"
|
||||
LoadingIndicator
|
||||
InputPrompt: Type your message or @path/to/file
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`Composer > Snapshots > matches snapshot in narrow view 1`] = `
|
||||
"
|
||||
ShortcutsHint
|
||||
────────────────────────────────────────
|
||||
ApprovalModeIndicator
|
||||
|
||||
StatusDisplay
|
||||
ApprovalModeIndicator ·StatusDisplay
|
||||
InputPrompt: Type your message or
|
||||
@path/to/file
|
||||
Footer
|
||||
@@ -35,8 +31,9 @@ Footer
|
||||
`;
|
||||
|
||||
exports[`Composer > Snapshots > matches snapshot while streaming 1`] = `
|
||||
" LoadingIndicator: Thinking
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
LoadingIndicator: Thinking
|
||||
|
||||
ApprovalModeIndicator
|
||||
InputPrompt: Type your message or @path/to/file
|
||||
Footer
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<ContextSummaryDisplay /> > should not render empty parts 1`] = `
|
||||
" - 1 open file (ctrl+g to view)
|
||||
" 1 open file (ctrl+g to view)
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<ContextSummaryDisplay /> > should render on a single line on a wide screen 1`] = `
|
||||
" 1 open file (ctrl+g to view) | 1 GEMINI.md file | 1 MCP server | 1 skill
|
||||
" 1 open file (ctrl+g to view) · 1 GEMINI.md file · 1 MCP server · 1 skill
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<ContextSummaryDisplay /> > should render on multiple lines on a narrow screen 1`] = `
|
||||
" - 1 open file (ctrl+g to view)
|
||||
- 1 GEMINI.md file
|
||||
- 1 MCP server
|
||||
- 1 skill
|
||||
" 1 open file (ctrl+g to view) · 1 GEMINI.md file · 1 MCP server · 1 skill
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -389,7 +389,7 @@ exports[`<HistoryItemDisplay /> > renders InfoMessage for "info" type with multi
|
||||
`;
|
||||
|
||||
exports[`<HistoryItemDisplay /> > thinking items > renders thinking item when enabled 1`] = `
|
||||
" Thinking
|
||||
│ test
|
||||
" Thinking
|
||||
│ test
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<LoadingIndicator /> > should truncate long primary text instead of wrapping 1`] = `
|
||||
"MockRespondin This is an extremely long loading phrase that shoul… (esc to
|
||||
gSpinner cancel, 5s)
|
||||
"MockRespondin This is an extremely long loading phrase that should …esc to
|
||||
gSpinner cancel, 5s
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -11,8 +11,8 @@ exports[`ToolConfirmationQueue > calculates availableContentHeight based on avai
|
||||
│ Apply this change? │
|
||||
│ │
|
||||
│ ● 1. Allow once │
|
||||
│ 2. Allow for this session │
|
||||
│ 3. Modify with external editor │
|
||||
│ 2. Allow for this session Enter to select · ↑/↓ to navigate · Esc to │
|
||||
│ 3. Modify with external edi…cancel │
|
||||
│ 4. No, suggest changes (esc) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
@@ -33,8 +33,8 @@ exports[`ToolConfirmationQueue > does not render expansion hint when constrainHe
|
||||
│ Apply this change? │
|
||||
│ │
|
||||
│ ● 1. Allow once │
|
||||
│ 2. Allow for this session │
|
||||
│ 3. Modify with external editor │
|
||||
│ 2. Allow for this session Enter to select · ↑/↓ to navigate · Esc to │
|
||||
│ 3. Modify with external edi…cancel │
|
||||
│ 4. No, suggest changes (esc) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
@@ -101,8 +101,8 @@ exports[`ToolConfirmationQueue > renders expansion hint when content is long and
|
||||
│ Apply this change? │
|
||||
│ │
|
||||
│ ● 1. Allow once │
|
||||
│ 2. Allow for this session │
|
||||
│ 3. Modify with external editor │
|
||||
│ 2. Allow for this session Enter to select · ↑/↓ to navigate · Esc to │
|
||||
│ 3. Modify with external edi…cancel │
|
||||
│ 4. No, suggest changes (esc) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
@@ -120,8 +120,8 @@ exports[`ToolConfirmationQueue > renders the confirming tool with progress indic
|
||||
│ Allow execution of: 'ls'? │
|
||||
│ │
|
||||
│ ● 1. Allow once │
|
||||
│ 2. Allow for this session │
|
||||
│ 3. No, suggest changes (esc) │
|
||||
│ 2. Allow for this session Enter to select · ↑/↓ to navigate · Esc to │
|
||||
│ 3. No, suggest changes (e… cancel │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
|
||||
@@ -52,9 +52,9 @@ export const ThinkingMessage: React.FC<ThinkingMessageProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Box width="100%" marginBottom={1} paddingLeft={1} flexDirection="column">
|
||||
<Box width="100%" marginBottom={1} flexDirection="column">
|
||||
{summary && (
|
||||
<Box paddingLeft={2}>
|
||||
<Box paddingLeft={1}>
|
||||
<Text color={theme.text.primary} bold italic>
|
||||
{summary}
|
||||
</Text>
|
||||
|
||||
@@ -574,7 +574,7 @@ describe('ToolConfirmationMessage', () => {
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('MCP Tool Details:');
|
||||
expect(output).toContain('(press Ctrl+O to expand MCP tool details)');
|
||||
expect(output).toContain('Ctrl+O to expand details');
|
||||
expect(output).not.toContain('https://www.google.co.jp');
|
||||
expect(output).not.toContain('Navigates browser to a URL.');
|
||||
unmount();
|
||||
@@ -606,7 +606,7 @@ describe('ToolConfirmationMessage', () => {
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('MCP Tool Details:');
|
||||
expect(output).toContain('(press Ctrl+O to expand MCP tool details)');
|
||||
expect(output).toContain('Ctrl+O to expand details');
|
||||
expect(output).not.toContain('Invocation Arguments:');
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
import { AskUserDialog } from '../AskUserDialog.js';
|
||||
import { ExitPlanModeDialog } from '../ExitPlanModeDialog.js';
|
||||
import { WarningMessage } from './WarningMessage.js';
|
||||
import { DialogFooter } from '../shared/DialogFooter.js';
|
||||
import {
|
||||
getDeceptiveUrlDetails,
|
||||
toUnicodeUrl,
|
||||
@@ -603,17 +604,8 @@ export const ToolConfirmationMessage: React.FC<
|
||||
{hasMcpToolDetails && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color={theme.text.primary}>MCP Tool Details:</Text>
|
||||
{isMcpToolDetailsExpanded ? (
|
||||
<>
|
||||
<Text color={theme.text.secondary}>
|
||||
(press {expandDetailsHintKey} to collapse MCP tool details)
|
||||
</Text>
|
||||
<Text color={theme.text.link}>{mcpToolDetailsText}</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text color={theme.text.secondary}>
|
||||
(press {expandDetailsHintKey} to expand MCP tool details)
|
||||
</Text>
|
||||
{isMcpToolDetailsExpanded && (
|
||||
<Text color={theme.text.link}>{mcpToolDetailsText}</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
@@ -632,7 +624,6 @@ export const ToolConfirmationMessage: React.FC<
|
||||
isMcpToolDetailsExpanded,
|
||||
hasMcpToolDetails,
|
||||
mcpToolDetailsText,
|
||||
expandDetailsHintKey,
|
||||
getPreferredEditor,
|
||||
]);
|
||||
|
||||
@@ -698,6 +689,17 @@ export const ToolConfirmationMessage: React.FC<
|
||||
onSelect={handleSelect}
|
||||
isFocused={isFocused}
|
||||
/>
|
||||
<DialogFooter
|
||||
primaryAction="Enter to select"
|
||||
navigationActions="↑/↓ to navigate"
|
||||
extraParts={
|
||||
hasMcpToolDetails
|
||||
? [
|
||||
`${expandDetailsHintKey} to ${isMcpToolDetailsExpanded ? 'collapse' : 'expand'} details`,
|
||||
]
|
||||
: []
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -123,7 +123,7 @@ export const FocusHint: React.FC<{
|
||||
|
||||
return (
|
||||
<Box marginLeft={1} flexShrink={0}>
|
||||
<Text color={theme.text.accent}>
|
||||
<Text color={theme.status.warning}>
|
||||
{isThisShellFocused
|
||||
? `(${formatCommand(Command.UNFOCUS_SHELL_INPUT)} to unfocus)`
|
||||
: `(${formatCommand(Command.FOCUS_SHELL_INPUT)} to focus)`}
|
||||
|
||||
@@ -8,7 +8,7 @@ Tip: Toggle auto-edit (Shift+Tab) to allow redirection in the future.
|
||||
Allow execution of: 'echo, redirection (>)'?
|
||||
|
||||
● 1. Allow once
|
||||
2. Allow for this session
|
||||
2. Allow for this session Enter to select · ↑/↓ to navigate · Esc to cancel
|
||||
3. No, suggest changes (esc)
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`ThinkingMessage > indents summary line correctly 1`] = `
|
||||
" Summary line
|
||||
│ First body line
|
||||
" Summary line
|
||||
│ First body line
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ThinkingMessage > normalizes escaped newline tokens 1`] = `
|
||||
" Matching the Blocks
|
||||
│ Some more text
|
||||
" Matching the Blocks
|
||||
│ Some more text
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ThinkingMessage > renders full mode with left border and full text 1`] = `
|
||||
" Planning
|
||||
│ I am planning the solution.
|
||||
" Planning
|
||||
│ I am planning the solution.
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ThinkingMessage > renders subject line 1`] = `
|
||||
" Planning
|
||||
│ test
|
||||
" Planning
|
||||
│ test
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ThinkingMessage > uses description when subject is empty 1`] = `
|
||||
" Processing details
|
||||
" Processing details
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -7,7 +7,7 @@ whoami
|
||||
Allow execution of 3 commands?
|
||||
|
||||
● 1. Allow once
|
||||
2. Allow for this session
|
||||
2. Allow for this session Enter to select · ↑/↓ to navigate · Esc to cancel
|
||||
3. No, suggest changes (esc)
|
||||
"
|
||||
`;
|
||||
@@ -20,7 +20,7 @@ URLs to fetch:
|
||||
Do you want to proceed?
|
||||
|
||||
● 1. Allow once
|
||||
2. Allow for this session
|
||||
2. Allow for this session Enter to select · ↑/↓ to navigate · Esc to cancel
|
||||
3. No, suggest changes (esc)
|
||||
"
|
||||
`;
|
||||
@@ -30,7 +30,7 @@ exports[`ToolConfirmationMessage > should not display urls if prompt and url are
|
||||
Do you want to proceed?
|
||||
|
||||
● 1. Allow once
|
||||
2. Allow for this session
|
||||
2. Allow for this session Enter to select · ↑/↓ to navigate · Esc to cancel
|
||||
3. No, suggest changes (esc)
|
||||
"
|
||||
`;
|
||||
@@ -41,7 +41,7 @@ Tool: testtool
|
||||
Allow execution of MCP tool "testtool" from server "testserver"?
|
||||
|
||||
● 1. Allow once
|
||||
2. Allow tool for this session
|
||||
2. Allow tool for this session Enter to select · ↑/↓ to navigate · Esc to cancel
|
||||
3. Allow all server tools for this session
|
||||
4. No, suggest changes (esc)
|
||||
"
|
||||
@@ -56,7 +56,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for edit confirmations'
|
||||
Apply this change?
|
||||
|
||||
● 1. Allow once
|
||||
2. Modify with external editor
|
||||
2. Modify with external editorEnter to select · ↑/↓ to navigate · Esc to cancel
|
||||
3. No, suggest changes (esc)
|
||||
"
|
||||
`;
|
||||
@@ -70,7 +70,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for edit confirmations'
|
||||
Apply this change?
|
||||
|
||||
● 1. Allow once
|
||||
2. Allow for this session
|
||||
2. Allow for this session Enter to select · ↑/↓ to navigate · Esc to cancel
|
||||
3. Modify with external editor
|
||||
4. No, suggest changes (esc)
|
||||
"
|
||||
@@ -81,7 +81,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for exec confirmations'
|
||||
Allow execution of: 'echo'?
|
||||
|
||||
● 1. Allow once
|
||||
2. No, suggest changes (esc)
|
||||
2. No, suggest changes (esc)Enter to select · ↑/↓ to navigate · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -90,7 +90,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for exec confirmations'
|
||||
Allow execution of: 'echo'?
|
||||
|
||||
● 1. Allow once
|
||||
2. Allow for this session
|
||||
2. Allow for this session Enter to select · ↑/↓ to navigate · Esc to cancel
|
||||
3. No, suggest changes (esc)
|
||||
"
|
||||
`;
|
||||
@@ -100,7 +100,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for info confirmations'
|
||||
Do you want to proceed?
|
||||
|
||||
● 1. Allow once
|
||||
2. No, suggest changes (esc)
|
||||
2. No, suggest changes (esc)Enter to select · ↑/↓ to navigate · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -109,7 +109,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for info confirmations'
|
||||
Do you want to proceed?
|
||||
|
||||
● 1. Allow once
|
||||
2. Allow for this session
|
||||
2. Allow for this session Enter to select · ↑/↓ to navigate · Esc to cancel
|
||||
3. No, suggest changes (esc)
|
||||
"
|
||||
`;
|
||||
@@ -120,7 +120,7 @@ Tool: test-tool
|
||||
Allow execution of MCP tool "test-tool" from server "test-server"?
|
||||
|
||||
● 1. Allow once
|
||||
2. No, suggest changes (esc)
|
||||
2. No, suggest changes (esc)Enter to select · ↑/↓ to navigate · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -130,7 +130,7 @@ Tool: test-tool
|
||||
Allow execution of MCP tool "test-tool" from server "test-server"?
|
||||
|
||||
● 1. Allow once
|
||||
2. Allow tool for this session
|
||||
2. Allow tool for this session Enter to select · ↑/↓ to navigate · Esc to cancel
|
||||
3. Allow all server tools for this session
|
||||
4. No, suggest changes (esc)
|
||||
"
|
||||
|
||||
@@ -32,6 +32,11 @@ export interface HalfLinePaddedBoxProps {
|
||||
*/
|
||||
useBackgroundColor?: boolean;
|
||||
|
||||
/**
|
||||
* Optional horizontal margin.
|
||||
*/
|
||||
marginX?: number;
|
||||
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -52,6 +57,7 @@ const HalfLinePaddedBoxInternal: React.FC<HalfLinePaddedBoxProps> = ({
|
||||
backgroundBaseColor,
|
||||
backgroundOpacity,
|
||||
children,
|
||||
marginX = 0,
|
||||
}) => {
|
||||
const { terminalWidth } = useUIState();
|
||||
const terminalBg = theme.background.primary || 'black';
|
||||
@@ -80,6 +86,8 @@ const HalfLinePaddedBoxInternal: React.FC<HalfLinePaddedBoxProps> = ({
|
||||
}
|
||||
|
||||
const isITerm = isITerm2();
|
||||
const barWidth = Math.max(0, terminalWidth - marginX * 2);
|
||||
const marginSpaces = ' '.repeat(marginX);
|
||||
|
||||
if (isITerm) {
|
||||
return (
|
||||
@@ -91,10 +99,15 @@ const HalfLinePaddedBoxInternal: React.FC<HalfLinePaddedBoxProps> = ({
|
||||
flexShrink={0}
|
||||
>
|
||||
<Box width={terminalWidth} flexDirection="row">
|
||||
<Text color={backgroundColor}>{'▄'.repeat(terminalWidth)}</Text>
|
||||
<Text color={terminalBg}>
|
||||
{marginSpaces}
|
||||
<Text color={backgroundColor}>{'▄'.repeat(barWidth)}</Text>
|
||||
{marginSpaces}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box
|
||||
width={terminalWidth}
|
||||
width={barWidth}
|
||||
marginLeft={marginX}
|
||||
flexDirection="column"
|
||||
alignItems="stretch"
|
||||
backgroundColor={backgroundColor}
|
||||
@@ -102,7 +115,11 @@ const HalfLinePaddedBoxInternal: React.FC<HalfLinePaddedBoxProps> = ({
|
||||
{children}
|
||||
</Box>
|
||||
<Box width={terminalWidth} flexDirection="row">
|
||||
<Text color={backgroundColor}>{'▀'.repeat(terminalWidth)}</Text>
|
||||
<Text color={terminalBg}>
|
||||
{marginSpaces}
|
||||
<Text color={backgroundColor}>{'▀'.repeat(barWidth)}</Text>
|
||||
{marginSpaces}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
@@ -115,17 +132,27 @@ const HalfLinePaddedBoxInternal: React.FC<HalfLinePaddedBoxProps> = ({
|
||||
alignItems="stretch"
|
||||
minHeight={1}
|
||||
flexShrink={0}
|
||||
backgroundColor={backgroundColor}
|
||||
>
|
||||
<Box width={terminalWidth} flexDirection="row">
|
||||
<Text backgroundColor={backgroundColor} color={terminalBg}>
|
||||
{'▀'.repeat(terminalWidth)}
|
||||
<Text color={terminalBg}>
|
||||
{marginSpaces}
|
||||
<Text backgroundColor={backgroundColor}>{'▀'.repeat(barWidth)}</Text>
|
||||
{marginSpaces}
|
||||
</Text>
|
||||
</Box>
|
||||
{children}
|
||||
<Box
|
||||
width={barWidth}
|
||||
marginLeft={marginX}
|
||||
backgroundColor={backgroundColor}
|
||||
flexDirection="column"
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
<Box width={terminalWidth} flexDirection="row">
|
||||
<Text color={terminalBg} backgroundColor={backgroundColor}>
|
||||
{'▄'.repeat(terminalWidth)}
|
||||
<Text color={terminalBg}>
|
||||
{marginSpaces}
|
||||
<Text backgroundColor={backgroundColor}>{'▄'.repeat(barWidth)}</Text>
|
||||
{marginSpaces}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -10,10 +10,12 @@ import { theme } from '../../semantic-colors.js';
|
||||
|
||||
interface HorizontalLineProps {
|
||||
color?: string;
|
||||
dim?: boolean;
|
||||
}
|
||||
|
||||
export const HorizontalLine: React.FC<HorizontalLineProps> = ({
|
||||
color = theme.border.default,
|
||||
dim = false,
|
||||
}) => (
|
||||
<Box
|
||||
width="100%"
|
||||
@@ -23,5 +25,6 @@ export const HorizontalLine: React.FC<HorizontalLineProps> = ({
|
||||
borderLeft={false}
|
||||
borderRight={false}
|
||||
borderColor={color}
|
||||
borderDimColor={dim}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -6,160 +6,160 @@
|
||||
|
||||
export const INFORMATIVE_TIPS = [
|
||||
//Settings tips start here
|
||||
'Set your preferred editor for opening files (/settings)…',
|
||||
'Toggle Vim mode for a modal editing experience (/settings)…',
|
||||
'Disable automatic updates if you prefer manual control (/settings)…',
|
||||
'Turn off nagging update notifications (settings.json)…',
|
||||
'Enable checkpointing to recover your session after a crash (settings.json)…',
|
||||
'Change CLI output format to JSON for scripting (/settings)…',
|
||||
'Personalize your CLI with a new color theme (/settings)…',
|
||||
'Create and use your own custom themes (settings.json)…',
|
||||
'Hide window title for a more minimal UI (/settings)…',
|
||||
"Don't like these tips? You can hide them (/settings)…",
|
||||
'Hide the startup banner for a cleaner launch (/settings)…',
|
||||
'Hide the context summary above the input (/settings)…',
|
||||
'Reclaim vertical space by hiding the footer (/settings)…',
|
||||
'Hide individual footer elements like CWD or sandbox status (/settings)…',
|
||||
'Hide the context window percentage in the footer (/settings)…',
|
||||
'Show memory usage for performance monitoring (/settings)…',
|
||||
'Show line numbers in the chat for easier reference (/settings)…',
|
||||
'Show citations to see where the model gets information (/settings)…',
|
||||
'Customize loading phrases: tips, witty, all, or off (/settings)…',
|
||||
'Add custom witty phrases to the loading screen (settings.json)…',
|
||||
'Use alternate screen buffer to preserve shell history (/settings)…',
|
||||
'Choose a specific Gemini model for conversations (/settings)…',
|
||||
'Limit the number of turns in your session history (/settings)…',
|
||||
'Automatically summarize large tool outputs to save tokens (settings.json)…',
|
||||
'Control when chat history gets compressed based on context compression threshold (settings.json)…',
|
||||
'Define custom context file names, like CONTEXT.md (settings.json)…',
|
||||
'Set max directories to scan for context files (/settings)…',
|
||||
'Expand your workspace with additional directories (/directory)…',
|
||||
'Control how /memory refresh 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 refresh 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 /chat list…',
|
||||
'Save your current conversation with /chat save <tag>…',
|
||||
'Resume a saved conversation with /chat resume <tag>…',
|
||||
'Delete a conversation checkpoint with /chat delete <tag>…',
|
||||
'Share your conversation to a file with /chat share <file>…',
|
||||
'Clear the screen and history with /clear…',
|
||||
'Save tokens by summarizing the context with /compress…',
|
||||
'Copy the last response to your clipboard with /copy…',
|
||||
'Open the full documentation in your browser with /docs…',
|
||||
'Add directories to your workspace with /directory add <path>…',
|
||||
'Show all directories in your workspace with /directory show…',
|
||||
'Use /dir as a shortcut for /directory…',
|
||||
'Set your preferred external editor with /editor…',
|
||||
'List all active extensions with /extensions list…',
|
||||
'Update all or specific extensions with /extensions update…',
|
||||
'Get help on commands with /help…',
|
||||
'Manage IDE integration with /ide…',
|
||||
'Create a project-specific GEMINI.md file with /init…',
|
||||
'List configured MCP servers and tools with /mcp list…',
|
||||
'Authenticate with an OAuth-enabled MCP server with /mcp auth…',
|
||||
'Restart MCP servers with /mcp refresh…',
|
||||
'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 refresh…',
|
||||
'List the paths of the GEMINI.md files in use with /memory list…',
|
||||
'Choose your Gemini model with /model…',
|
||||
'Display the privacy notice with /privacy…',
|
||||
'Restore project files to a previous state with /restore…',
|
||||
'Exit the CLI with /quit or /exit…',
|
||||
'Check model-specific usage stats with /stats model…',
|
||||
'Check tool-specific usage stats with /stats tools…',
|
||||
"Change the CLI's color theme with /theme…",
|
||||
'List all available tools with /tools…',
|
||||
'View and edit settings with the /settings editor…',
|
||||
'Toggle Vim keybindings on and off with /vim…',
|
||||
'Set up GitHub Actions with /setup-github…',
|
||||
'Configure terminal keybindings for multiline input with /terminal-setup…',
|
||||
'Find relevant documentation with /find-docs…',
|
||||
'Execute any shell command with !<command>…',
|
||||
'Show version info with /about',
|
||||
'Change your authentication method with /auth',
|
||||
'File a bug report directly with /bug',
|
||||
'List your saved chat checkpoints with /chat list',
|
||||
'Save your current conversation with /chat save <tag>',
|
||||
'Resume a saved conversation with /chat resume <tag>',
|
||||
'Delete a conversation checkpoint with /chat delete <tag>',
|
||||
'Share your conversation to a file with /chat share <file>',
|
||||
'Clear the screen and history with /clear',
|
||||
'Save tokens by summarizing the context with /compress',
|
||||
'Copy the last response to your clipboard with /copy',
|
||||
'Open the full documentation in your browser with /docs',
|
||||
'Add directories to your workspace with /directory add <path>',
|
||||
'Show all directories in your workspace with /directory show',
|
||||
'Use /dir as a shortcut for /directory',
|
||||
'Set your preferred external editor with /editor',
|
||||
'List all active extensions with /extensions list',
|
||||
'Update all or specific extensions with /extensions update',
|
||||
'Get help on commands with /help',
|
||||
'Manage IDE integration with /ide',
|
||||
'Create a project-specific GEMINI.md file with /init',
|
||||
'List configured MCP servers and tools with /mcp list',
|
||||
'Authenticate with an OAuth-enabled MCP server with /mcp auth',
|
||||
'Restart MCP servers with /mcp refresh',
|
||||
'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 refresh',
|
||||
'List the paths of the GEMINI.md files in use with /memory list',
|
||||
'Choose your Gemini model with /model',
|
||||
'Display the privacy notice with /privacy',
|
||||
'Restore project files to a previous state with /restore',
|
||||
'Exit the CLI with /quit or /exit',
|
||||
'Check model-specific usage stats with /stats model',
|
||||
'Check tool-specific usage stats with /stats tools',
|
||||
"Change the CLI's color theme with /theme",
|
||||
'List all available tools with /tools',
|
||||
'View and edit settings with the /settings editor',
|
||||
'Toggle Vim keybindings on and off with /vim',
|
||||
'Set up GitHub Actions with /setup-github',
|
||||
'Configure terminal keybindings for multiline input with /terminal-setup',
|
||||
'Find relevant documentation with /find-docs',
|
||||
'Execute any shell command with !<command>',
|
||||
// Command tips end here
|
||||
];
|
||||
|
||||
@@ -6,113 +6,113 @@
|
||||
|
||||
export const WITTY_LOADING_PHRASES = [
|
||||
"I'm Feeling Lucky",
|
||||
'Shipping awesomeness… ',
|
||||
'Painting the serifs back on…',
|
||||
'Navigating the slime mold…',
|
||||
'Consulting the digital spirits…',
|
||||
'Reticulating splines…',
|
||||
'Warming up the AI hamsters…',
|
||||
'Asking the magic conch shell…',
|
||||
'Generating witty retort…',
|
||||
'Polishing the algorithms…',
|
||||
"Don't rush perfection (or my code)…",
|
||||
'Brewing fresh bytes…',
|
||||
'Counting electrons…',
|
||||
'Engaging cognitive processors…',
|
||||
'Checking for syntax errors in the universe…',
|
||||
'One moment, optimizing humor…',
|
||||
'Shuffling punchlines…',
|
||||
'Untangling neural nets…',
|
||||
'Compiling brilliance…',
|
||||
'Loading wit.exe…',
|
||||
'Summoning the cloud of wisdom…',
|
||||
'Preparing a witty response…',
|
||||
"Just a sec, I'm debugging reality…",
|
||||
'Confuzzling the options…',
|
||||
'Tuning the cosmic frequencies…',
|
||||
'Crafting a response worthy of your patience…',
|
||||
'Compiling the 1s and 0s…',
|
||||
'Resolving dependencies… and existential crises…',
|
||||
'Defragmenting memories… both RAM and personal…',
|
||||
'Rebooting the humor module…',
|
||||
'Caching the essentials (mostly cat memes)…',
|
||||
'Shipping awesomeness',
|
||||
'Painting the serifs back on',
|
||||
'Navigating the slime mold',
|
||||
'Consulting the digital spirits',
|
||||
'Reticulating splines',
|
||||
'Warming up the AI hamsters',
|
||||
'Asking the magic conch shell',
|
||||
'Generating witty retort',
|
||||
'Polishing the algorithms',
|
||||
"Don't rush perfection (or my code)",
|
||||
'Brewing fresh bytes',
|
||||
'Counting electrons',
|
||||
'Engaging cognitive processors',
|
||||
'Checking for syntax errors in the universe',
|
||||
'One moment, optimizing humor',
|
||||
'Shuffling punchlines',
|
||||
'Untangling neural nets',
|
||||
'Compiling brilliance',
|
||||
'Loading wit.exe',
|
||||
'Summoning the cloud of wisdom',
|
||||
'Preparing a witty response',
|
||||
"Just a sec, I'm debugging reality",
|
||||
'Confuzzling the options',
|
||||
'Tuning the cosmic frequencies',
|
||||
'Crafting a response worthy of your patience',
|
||||
'Compiling the 1s and 0s',
|
||||
'Resolving dependencies… and existential crises',
|
||||
'Defragmenting memories… both RAM and personal',
|
||||
'Rebooting the humor module',
|
||||
'Caching the essentials (mostly cat memes)',
|
||||
'Optimizing for ludicrous speed',
|
||||
"Swapping bits… don't tell the bytes…",
|
||||
'Garbage collecting… be right back…',
|
||||
'Assembling the interwebs…',
|
||||
'Converting coffee into code…',
|
||||
'Updating the syntax for reality…',
|
||||
'Rewiring the synapses…',
|
||||
'Looking for a misplaced semicolon…',
|
||||
"Greasin' the cogs of the machine…",
|
||||
'Pre-heating the servers…',
|
||||
'Calibrating the flux capacitor…',
|
||||
'Engaging the improbability drive…',
|
||||
'Channeling the Force…',
|
||||
'Aligning the stars for optimal response…',
|
||||
'So say we all…',
|
||||
'Loading the next great idea…',
|
||||
"Just a moment, I'm in the zone…",
|
||||
'Preparing to dazzle you with brilliance…',
|
||||
"Just a tick, I'm polishing my wit…",
|
||||
"Hold tight, I'm crafting a masterpiece…",
|
||||
"Just a jiffy, I'm debugging the universe…",
|
||||
"Just a moment, I'm aligning the pixels…",
|
||||
"Just a sec, I'm optimizing the humor…",
|
||||
"Just a moment, I'm tuning the algorithms…",
|
||||
'Warp speed engaged…',
|
||||
'Mining for more Dilithium crystals…',
|
||||
"Don't panic…",
|
||||
'Following the white rabbit…',
|
||||
'The truth is in here… somewhere…',
|
||||
'Blowing on the cartridge…',
|
||||
"Swapping bits… don't tell the bytes",
|
||||
'Garbage collecting… be right back',
|
||||
'Assembling the interwebs',
|
||||
'Converting coffee into code',
|
||||
'Updating the syntax for reality',
|
||||
'Rewiring the synapses',
|
||||
'Looking for a misplaced semicolon',
|
||||
"Greasin' the cogs of the machine",
|
||||
'Pre-heating the servers',
|
||||
'Calibrating the flux capacitor',
|
||||
'Engaging the improbability drive',
|
||||
'Channeling the Force',
|
||||
'Aligning the stars for optimal response',
|
||||
'So say we all',
|
||||
'Loading the next great idea',
|
||||
"Just a moment, I'm in the zone",
|
||||
'Preparing to dazzle you with brilliance',
|
||||
"Just a tick, I'm polishing my wit",
|
||||
"Hold tight, I'm crafting a masterpiece",
|
||||
"Just a jiffy, I'm debugging the universe",
|
||||
"Just a moment, I'm aligning the pixels",
|
||||
"Just a sec, I'm optimizing the humor",
|
||||
"Just a moment, I'm tuning the algorithms",
|
||||
'Warp speed engaged',
|
||||
'Mining for more Dilithium crystals',
|
||||
"Don't panic",
|
||||
'Following the white rabbit',
|
||||
'The truth is in here… somewhere',
|
||||
'Blowing on the cartridge',
|
||||
'Loading… Do a barrel roll!',
|
||||
'Waiting for the respawn…',
|
||||
'Finishing the Kessel Run in less than 12 parsecs…',
|
||||
"The cake is not a lie, it's just still loading…",
|
||||
'Fiddling with the character creation screen…',
|
||||
"Just a moment, I'm finding the right meme…",
|
||||
"Pressing 'A' to continue…",
|
||||
'Herding digital cats…',
|
||||
'Polishing the pixels…',
|
||||
'Finding a suitable loading screen pun…',
|
||||
'Distracting you with this witty phrase…',
|
||||
'Almost there… probably…',
|
||||
'Our hamsters are working as fast as they can…',
|
||||
'Giving Cloudy a pat on the head…',
|
||||
'Petting the cat…',
|
||||
'Rickrolling my boss…',
|
||||
'Slapping the bass…',
|
||||
'Tasting the snozberries…',
|
||||
"I'm going the distance, I'm going for speed…",
|
||||
'Is this the real life? Is this just fantasy?…',
|
||||
"I've got a good feeling about this…",
|
||||
'Poking the bear…',
|
||||
'Doing research on the latest memes…',
|
||||
'Figuring out how to make this more witty…',
|
||||
'Hmmm… let me think…',
|
||||
'What do you call a fish with no eyes? A fsh…',
|
||||
'Why did the computer go to therapy? It had too many bytes…',
|
||||
"Why don't programmers like nature? It has too many bugs…",
|
||||
'Why do programmers prefer dark mode? Because light attracts bugs…',
|
||||
'Why did the developer go broke? Because they used up all their cache…',
|
||||
"What can you do with a broken pencil? Nothing, it's pointless…",
|
||||
'Applying percussive maintenance…',
|
||||
'Searching for the correct USB orientation…',
|
||||
'Ensuring the magic smoke stays inside the wires…',
|
||||
'Rewriting in Rust for no particular reason…',
|
||||
'Trying to exit Vim…',
|
||||
'Spinning up the hamster wheel…',
|
||||
"That's not a bug, it's an undocumented feature…",
|
||||
'Waiting for the respawn',
|
||||
'Finishing the Kessel Run in less than 12 parsecs',
|
||||
"The cake is not a lie, it's just still loading",
|
||||
'Fiddling with the character creation screen',
|
||||
"Just a moment, I'm finding the right meme",
|
||||
"Pressing 'A' to continue",
|
||||
'Herding digital cats',
|
||||
'Polishing the pixels',
|
||||
'Finding a suitable loading screen pun',
|
||||
'Distracting you with this witty phrase',
|
||||
'Almost there… probably',
|
||||
'Our hamsters are working as fast as they can',
|
||||
'Giving Cloudy a pat on the head',
|
||||
'Petting the cat',
|
||||
'Rickrolling my boss',
|
||||
'Slapping the bass',
|
||||
'Tasting the snozberries',
|
||||
"I'm going the distance, I'm going for speed",
|
||||
'Is this the real life? Is this just fantasy?',
|
||||
"I've got a good feeling about this",
|
||||
'Poking the bear',
|
||||
'Doing research on the latest memes',
|
||||
'Figuring out how to make this more witty',
|
||||
'Hmmm… let me think',
|
||||
'What do you call a fish with no eyes? A fsh',
|
||||
'Why did the computer go to therapy? It had too many bytes',
|
||||
"Why don't programmers like nature? It has too many bugs",
|
||||
'Why do programmers prefer dark mode? Because light attracts bugs',
|
||||
'Why did the developer go broke? Because they used up all their cache',
|
||||
"What can you do with a broken pencil? Nothing, it's pointless",
|
||||
'Applying percussive maintenance',
|
||||
'Searching for the correct USB orientation',
|
||||
'Ensuring the magic smoke stays inside the wires',
|
||||
'Rewriting in Rust for no particular reason',
|
||||
'Trying to exit Vim',
|
||||
'Spinning up the hamster wheel',
|
||||
"That's not a bug, it's an undocumented feature",
|
||||
'Engage.',
|
||||
"I'll be back… with an answer.",
|
||||
'My other process is a TARDIS…',
|
||||
'Communing with the machine spirit…',
|
||||
'Letting the thoughts marinate…',
|
||||
'Just remembered where I put my keys…',
|
||||
'Pondering the orb…',
|
||||
'My other process is a TARDIS',
|
||||
'Communing with the machine spirit',
|
||||
'Letting the thoughts marinate',
|
||||
'Just remembered where I put my keys',
|
||||
'Pondering the orb',
|
||||
"I've seen things you people wouldn't believe… like a user who reads loading messages.",
|
||||
'Initiating thoughtful gaze…',
|
||||
'Initiating thoughtful gaze',
|
||||
"What's a computer's favorite snack? Microchips.",
|
||||
"Why do Java developers wear glasses? Because they don't C#.",
|
||||
'Charging the laser… pew pew!',
|
||||
@@ -120,18 +120,18 @@ export const WITTY_LOADING_PHRASES = [
|
||||
'Looking for an adult superviso… I mean, processing.',
|
||||
'Making it go beep boop.',
|
||||
'Buffering… because even AIs need a moment.',
|
||||
'Entangling quantum particles for a faster response…',
|
||||
'Entangling quantum particles for a faster response',
|
||||
'Polishing the chrome… on the algorithms.',
|
||||
'Are you not entertained? (Working on it!)',
|
||||
'Summoning the code gremlins… to help, of course.',
|
||||
'Just waiting for the dial-up tone to finish…',
|
||||
'Just waiting for the dial-up tone to finish',
|
||||
'Recalibrating the humor-o-meter.',
|
||||
'My other loading screen is even funnier.',
|
||||
"Pretty sure there's a cat walking on the keyboard somewhere…",
|
||||
"Pretty sure there's a cat walking on the keyboard somewhere",
|
||||
'Enhancing… Enhancing… Still loading.',
|
||||
"It's not a bug, it's a feature… of this loading screen.",
|
||||
'Have you tried turning it off and on again? (The loading screen, not me.)',
|
||||
'Constructing additional pylons…',
|
||||
'Constructing additional pylons',
|
||||
'New line? That’s Ctrl+J.',
|
||||
'Releasing the HypnoDrones…',
|
||||
'Releasing the HypnoDrones',
|
||||
];
|
||||
|
||||
@@ -168,6 +168,8 @@ export interface UIState {
|
||||
cleanUiDetailsVisible: boolean;
|
||||
elapsedTime: number;
|
||||
currentLoadingPhrase: string | undefined;
|
||||
currentTip: string | undefined;
|
||||
currentWittyPhrase: string | undefined;
|
||||
historyRemountKey: number;
|
||||
activeHooks: ActiveHook[];
|
||||
messageQueue: string[];
|
||||
|
||||
@@ -2,10 +2,8 @@
|
||||
|
||||
exports[`usePhraseCycler > should prioritize interactive shell waiting over normal waiting immediately 1`] = `"Waiting for user confirmation..."`;
|
||||
|
||||
exports[`usePhraseCycler > should prioritize interactive shell waiting over normal waiting immediately 2`] = `"Interactive shell awaiting input... press tab to focus shell"`;
|
||||
|
||||
exports[`usePhraseCycler > should reset phrase when transitioning from waiting to active 1`] = `"Waiting for user confirmation..."`;
|
||||
exports[`usePhraseCycler > should prioritize interactive shell waiting over normal waiting immediately 2`] = `"! Shell awaiting input (Tab to focus)"`;
|
||||
|
||||
exports[`usePhraseCycler > should show "Waiting for user confirmation..." when isWaiting is true 1`] = `"Waiting for user confirmation..."`;
|
||||
|
||||
exports[`usePhraseCycler > should show interactive shell waiting message immediately when isInteractiveShellWaiting is true 1`] = `"Interactive shell awaiting input... press tab to focus shell"`;
|
||||
exports[`usePhraseCycler > should show interactive shell waiting message immediately when shouldShowFocusHint is true 1`] = `"! Shell awaiting input (Tab to focus)"`;
|
||||
|
||||
@@ -43,6 +43,7 @@ export const useHookDisplayState = () => {
|
||||
{
|
||||
name: payload.hookName,
|
||||
eventName: payload.eventName,
|
||||
source: payload.source,
|
||||
index: payload.hookIndex,
|
||||
total: payload.totalHooks,
|
||||
},
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
import { WITTY_LOADING_PHRASES } from '../constants/wittyPhrases.js';
|
||||
import { INFORMATIVE_TIPS } from '../constants/tips.js';
|
||||
import type { RetryAttemptPayload } from '@google/gemini-cli-core';
|
||||
import type { LoadingPhrasesMode } from '../../config/settings.js';
|
||||
|
||||
describe('useLoadingIndicator', () => {
|
||||
beforeEach(() => {
|
||||
@@ -34,7 +33,7 @@ describe('useLoadingIndicator', () => {
|
||||
initialStreamingState: StreamingState,
|
||||
initialShouldShowFocusHint: boolean = false,
|
||||
initialRetryStatus: RetryAttemptPayload | null = null,
|
||||
loadingPhrasesMode: LoadingPhrasesMode = 'all',
|
||||
loadingPhrases: 'tips' | 'witty' | 'all' | 'off' = 'all',
|
||||
initialErrorVerbosity: 'low' | 'full' = 'full',
|
||||
) => {
|
||||
let hookResult: ReturnType<typeof useLoadingIndicator>;
|
||||
@@ -42,20 +41,20 @@ describe('useLoadingIndicator', () => {
|
||||
streamingState,
|
||||
shouldShowFocusHint,
|
||||
retryStatus,
|
||||
mode,
|
||||
loadingPhrases,
|
||||
errorVerbosity,
|
||||
}: {
|
||||
streamingState: StreamingState;
|
||||
shouldShowFocusHint?: boolean;
|
||||
retryStatus?: RetryAttemptPayload | null;
|
||||
mode?: LoadingPhrasesMode;
|
||||
errorVerbosity: 'low' | 'full';
|
||||
loadingPhrases?: 'tips' | 'witty' | 'all' | 'off';
|
||||
errorVerbosity?: 'low' | 'full';
|
||||
}) {
|
||||
hookResult = useLoadingIndicator({
|
||||
streamingState,
|
||||
shouldShowFocusHint: !!shouldShowFocusHint,
|
||||
retryStatus: retryStatus || null,
|
||||
loadingPhrasesMode: mode,
|
||||
loadingPhrases,
|
||||
errorVerbosity,
|
||||
});
|
||||
return null;
|
||||
@@ -65,7 +64,7 @@ describe('useLoadingIndicator', () => {
|
||||
streamingState={initialStreamingState}
|
||||
shouldShowFocusHint={initialShouldShowFocusHint}
|
||||
retryStatus={initialRetryStatus}
|
||||
mode={loadingPhrasesMode}
|
||||
loadingPhrases={loadingPhrases}
|
||||
errorVerbosity={initialErrorVerbosity}
|
||||
/>,
|
||||
);
|
||||
@@ -79,12 +78,12 @@ describe('useLoadingIndicator', () => {
|
||||
streamingState: StreamingState;
|
||||
shouldShowFocusHint?: boolean;
|
||||
retryStatus?: RetryAttemptPayload | null;
|
||||
mode?: LoadingPhrasesMode;
|
||||
loadingPhrases?: 'tips' | 'witty' | 'all' | 'off';
|
||||
errorVerbosity?: 'low' | 'full';
|
||||
}) =>
|
||||
rerender(
|
||||
<TestComponent
|
||||
mode={loadingPhrasesMode}
|
||||
loadingPhrases={loadingPhrases}
|
||||
errorVerbosity={initialErrorVerbosity}
|
||||
{...newProps}
|
||||
/>,
|
||||
@@ -93,24 +92,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 +118,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 +159,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 +182,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 +193,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,79 +209,5 @@ 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', () => {
|
||||
const retryStatus = {
|
||||
model: 'gemini-pro',
|
||||
attempt: 2,
|
||||
maxAttempts: 3,
|
||||
delayMs: 1000,
|
||||
};
|
||||
const { result } = renderLoadingIndicatorHook(
|
||||
StreamingState.Responding,
|
||||
false,
|
||||
retryStatus,
|
||||
);
|
||||
|
||||
expect(result.current.currentLoadingPhrase).toContain('Trying to reach');
|
||||
expect(result.current.currentLoadingPhrase).toContain('Attempt 3/3');
|
||||
});
|
||||
|
||||
it('should hide low-verbosity retry status for early retry attempts', () => {
|
||||
const retryStatus = {
|
||||
model: 'gemini-pro',
|
||||
attempt: 1,
|
||||
maxAttempts: 5,
|
||||
delayMs: 1000,
|
||||
};
|
||||
const { result } = renderLoadingIndicatorHook(
|
||||
StreamingState.Responding,
|
||||
false,
|
||||
retryStatus,
|
||||
'all',
|
||||
'low',
|
||||
);
|
||||
|
||||
expect(result.current.currentLoadingPhrase).not.toBe(
|
||||
"This is taking a bit longer, we're still on it.",
|
||||
);
|
||||
});
|
||||
|
||||
it('should show a generic retry phrase in low error verbosity mode for later retries', () => {
|
||||
const retryStatus = {
|
||||
model: 'gemini-pro',
|
||||
attempt: 2,
|
||||
maxAttempts: 5,
|
||||
delayMs: 1000,
|
||||
};
|
||||
const { result } = renderLoadingIndicatorHook(
|
||||
StreamingState.Responding,
|
||||
false,
|
||||
retryStatus,
|
||||
'all',
|
||||
'low',
|
||||
);
|
||||
|
||||
expect(result.current.currentLoadingPhrase).toBe(
|
||||
"This is taking a bit longer, we're still on it.",
|
||||
);
|
||||
});
|
||||
|
||||
it('should show no phrases when loadingPhrasesMode is "off"', () => {
|
||||
const { result } = renderLoadingIndicatorHook(
|
||||
StreamingState.Responding,
|
||||
false,
|
||||
null,
|
||||
'off',
|
||||
);
|
||||
|
||||
expect(result.current.currentLoadingPhrase).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
getDisplayString,
|
||||
type RetryAttemptPayload,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { LoadingPhrasesMode } from '../../config/settings.js';
|
||||
|
||||
const LOW_VERBOSITY_RETRY_HINT_ATTEMPT_THRESHOLD = 2;
|
||||
|
||||
@@ -20,18 +19,20 @@ export interface UseLoadingIndicatorProps {
|
||||
streamingState: StreamingState;
|
||||
shouldShowFocusHint: boolean;
|
||||
retryStatus: RetryAttemptPayload | null;
|
||||
loadingPhrasesMode?: LoadingPhrasesMode;
|
||||
loadingPhrases?: 'tips' | 'witty' | 'all' | 'off';
|
||||
customWittyPhrases?: string[];
|
||||
errorVerbosity: 'low' | 'full';
|
||||
errorVerbosity?: 'low' | 'full';
|
||||
maxLength?: number;
|
||||
}
|
||||
|
||||
export const useLoadingIndicator = ({
|
||||
streamingState,
|
||||
shouldShowFocusHint,
|
||||
retryStatus,
|
||||
loadingPhrasesMode,
|
||||
loadingPhrases = 'tips',
|
||||
customWittyPhrases,
|
||||
errorVerbosity,
|
||||
errorVerbosity = 'full',
|
||||
maxLength,
|
||||
}: UseLoadingIndicatorProps) => {
|
||||
const [timerResetKey, setTimerResetKey] = useState(0);
|
||||
const isTimerActive = streamingState === StreamingState.Responding;
|
||||
@@ -40,12 +41,18 @@ export const useLoadingIndicator = ({
|
||||
|
||||
const isPhraseCyclingActive = streamingState === StreamingState.Responding;
|
||||
const isWaiting = streamingState === StreamingState.WaitingForConfirmation;
|
||||
const currentLoadingPhrase = usePhraseCycler(
|
||||
|
||||
const showTips = loadingPhrases === 'tips' || loadingPhrases === 'all';
|
||||
const showWit = loadingPhrases === 'witty' || loadingPhrases === 'all';
|
||||
|
||||
const { currentTip, currentWittyPhrase } = usePhraseCycler(
|
||||
isPhraseCyclingActive,
|
||||
isWaiting,
|
||||
shouldShowFocusHint,
|
||||
loadingPhrasesMode,
|
||||
showTips,
|
||||
showWit,
|
||||
customWittyPhrases,
|
||||
maxLength,
|
||||
);
|
||||
|
||||
const [retainedElapsedTime, setRetainedElapsedTime] = useState(0);
|
||||
@@ -86,6 +93,8 @@ export const useLoadingIndicator = ({
|
||||
streamingState === StreamingState.WaitingForConfirmation
|
||||
? retainedElapsedTime
|
||||
: elapsedTimeFromTimer,
|
||||
currentLoadingPhrase: retryPhrase || currentLoadingPhrase,
|
||||
currentLoadingPhrase: retryPhrase || currentTip || currentWittyPhrase,
|
||||
currentTip,
|
||||
currentWittyPhrase,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -14,30 +14,35 @@ import {
|
||||
} from './usePhraseCycler.js';
|
||||
import { INFORMATIVE_TIPS } from '../constants/tips.js';
|
||||
import { WITTY_LOADING_PHRASES } from '../constants/wittyPhrases.js';
|
||||
import type { LoadingPhrasesMode } from '../../config/settings.js';
|
||||
|
||||
// Test component to consume the hook
|
||||
const TestComponent = ({
|
||||
isActive,
|
||||
isWaiting,
|
||||
isInteractiveShellWaiting = false,
|
||||
loadingPhrasesMode = 'all',
|
||||
shouldShowFocusHint = false,
|
||||
showTips = true,
|
||||
showWit = true,
|
||||
customPhrases,
|
||||
}: {
|
||||
isActive: boolean;
|
||||
isWaiting: boolean;
|
||||
isInteractiveShellWaiting?: boolean;
|
||||
loadingPhrasesMode?: LoadingPhrasesMode;
|
||||
shouldShowFocusHint?: boolean;
|
||||
showTips?: boolean;
|
||||
showWit?: boolean;
|
||||
customPhrases?: string[];
|
||||
}) => {
|
||||
const phrase = usePhraseCycler(
|
||||
const { currentTip, currentWittyPhrase } = usePhraseCycler(
|
||||
isActive,
|
||||
isWaiting,
|
||||
isInteractiveShellWaiting,
|
||||
loadingPhrasesMode,
|
||||
shouldShowFocusHint,
|
||||
showTips,
|
||||
showWit,
|
||||
customPhrases,
|
||||
);
|
||||
return <Text>{phrase}</Text>;
|
||||
// For tests, we'll combine them to verify existence
|
||||
return (
|
||||
<Text>{[currentTip, currentWittyPhrase].filter(Boolean).join(' | ')}</Text>
|
||||
);
|
||||
};
|
||||
|
||||
describe('usePhraseCycler', () => {
|
||||
@@ -75,7 +80,7 @@ describe('usePhraseCycler', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should show interactive shell waiting message immediately when isInteractiveShellWaiting is true', async () => {
|
||||
it('should show interactive shell waiting message immediately when shouldShowFocusHint is true', async () => {
|
||||
const { lastFrame, rerender, waitUntilReady, unmount } = render(
|
||||
<TestComponent isActive={true} isWaiting={false} />,
|
||||
);
|
||||
@@ -86,7 +91,7 @@ describe('usePhraseCycler', () => {
|
||||
<TestComponent
|
||||
isActive={true}
|
||||
isWaiting={false}
|
||||
isInteractiveShellWaiting={true}
|
||||
shouldShowFocusHint={true}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
@@ -108,7 +113,7 @@ describe('usePhraseCycler', () => {
|
||||
<TestComponent
|
||||
isActive={true}
|
||||
isWaiting={true}
|
||||
isInteractiveShellWaiting={true}
|
||||
shouldShowFocusHint={true}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
@@ -133,55 +138,56 @@ describe('usePhraseCycler', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should show a tip on first activation, then a witty phrase', async () => {
|
||||
vi.spyOn(Math, 'random').mockImplementation(() => 0.99); // Subsequent phrases are witty
|
||||
it('should show both a tip and a witty phrase when both are enabled', async () => {
|
||||
vi.spyOn(Math, 'random').mockImplementation(() => 0.5);
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<TestComponent isActive={true} isWaiting={false} />,
|
||||
<TestComponent
|
||||
isActive={true}
|
||||
isWaiting={false}
|
||||
showTips={true}
|
||||
showWit={true}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
// Initial phrase on first activation should be a tip
|
||||
expect(INFORMATIVE_TIPS).toContain(lastFrame().trim());
|
||||
|
||||
// After the first interval, it should be a witty phrase
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS + 100);
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(WITTY_LOADING_PHRASES).toContain(lastFrame().trim());
|
||||
// In the new logic, both are selected independently if enabled.
|
||||
const frame = lastFrame().trim();
|
||||
const parts = frame.split(' | ');
|
||||
expect(parts).toHaveLength(2);
|
||||
expect(INFORMATIVE_TIPS).toContain(parts[0]);
|
||||
expect(WITTY_LOADING_PHRASES).toContain(parts[1]);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should cycle through phrases when isActive is true and not waiting', async () => {
|
||||
vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty for subsequent phrases
|
||||
vi.spyOn(Math, 'random').mockImplementation(() => 0.5);
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<TestComponent isActive={true} isWaiting={false} />,
|
||||
<TestComponent
|
||||
isActive={true}
|
||||
isWaiting={false}
|
||||
showTips={true}
|
||||
showWit={true}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
// Initial phrase on first activation will be a tip
|
||||
|
||||
// After the first interval, it should follow the random pattern (witty phrases due to mock)
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS + 100);
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(WITTY_LOADING_PHRASES).toContain(lastFrame().trim());
|
||||
const frame = lastFrame().trim();
|
||||
const parts = frame.split(' | ');
|
||||
expect(parts).toHaveLength(2);
|
||||
expect(INFORMATIVE_TIPS).toContain(parts[0]);
|
||||
expect(WITTY_LOADING_PHRASES).toContain(parts[1]);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS);
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(WITTY_LOADING_PHRASES).toContain(lastFrame().trim());
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should reset to a phrase when isActive becomes true after being false', async () => {
|
||||
it('should reset to phrases when isActive becomes true after being false', async () => {
|
||||
const customPhrases = ['Phrase A', 'Phrase B'];
|
||||
let callCount = 0;
|
||||
vi.spyOn(Math, 'random').mockImplementation(() => {
|
||||
// For custom phrases, only 1 Math.random call is made per update.
|
||||
// 0 -> index 0 ('Phrase A')
|
||||
// 0.99 -> index 1 ('Phrase B')
|
||||
const val = callCount % 2 === 0 ? 0 : 0.99;
|
||||
callCount++;
|
||||
return val;
|
||||
@@ -192,34 +198,31 @@ describe('usePhraseCycler', () => {
|
||||
isActive={false}
|
||||
isWaiting={false}
|
||||
customPhrases={customPhrases}
|
||||
showWit={true}
|
||||
showTips={false}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
// Activate -> On first activation will show tip on initial call, then first interval will use first mock value for 'Phrase A'
|
||||
// Activate
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<TestComponent
|
||||
isActive={true}
|
||||
isWaiting={false}
|
||||
customPhrases={customPhrases}
|
||||
showWit={true}
|
||||
showTips={false}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS); // First interval after initial state -> callCount 0 -> 'Phrase A'
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(customPhrases).toContain(lastFrame().trim()); // Should be one of the custom phrases
|
||||
|
||||
// Second interval -> callCount 1 -> returns 0.99 -> 'Phrase B'
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS);
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(customPhrases).toContain(lastFrame().trim()); // Should be one of the custom phrases
|
||||
expect(customPhrases).toContain(lastFrame().trim());
|
||||
|
||||
// Deactivate -> resets to undefined (empty string in output)
|
||||
await act(async () => {
|
||||
@@ -228,6 +231,8 @@ describe('usePhraseCycler', () => {
|
||||
isActive={false}
|
||||
isWaiting={false}
|
||||
customPhrases={customPhrases}
|
||||
showWit={true}
|
||||
showTips={false}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
@@ -235,24 +240,6 @@ describe('usePhraseCycler', () => {
|
||||
|
||||
// The phrase should be empty after reset
|
||||
expect(lastFrame({ allowEmpty: true }).trim()).toBe('');
|
||||
|
||||
// Activate again -> this will show a tip on first activation, then cycle from where mock is
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<TestComponent
|
||||
isActive={true}
|
||||
isWaiting={false}
|
||||
customPhrases={customPhrases}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS); // First interval after re-activation -> should contain phrase
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(customPhrases).toContain(lastFrame().trim()); // Should be one of the custom phrases
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -264,7 +251,7 @@ describe('usePhraseCycler', () => {
|
||||
|
||||
const clearIntervalSpy = vi.spyOn(global, 'clearInterval');
|
||||
unmount();
|
||||
expect(clearIntervalSpy).toHaveBeenCalledOnce();
|
||||
expect(clearIntervalSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use custom phrases when provided', async () => {
|
||||
@@ -293,7 +280,8 @@ describe('usePhraseCycler', () => {
|
||||
<TestComponent
|
||||
isActive={config.isActive}
|
||||
isWaiting={false}
|
||||
loadingPhrasesMode="witty"
|
||||
showTips={false}
|
||||
showWit={true}
|
||||
customPhrases={config.customPhrases}
|
||||
/>
|
||||
);
|
||||
@@ -304,7 +292,7 @@ describe('usePhraseCycler', () => {
|
||||
|
||||
// After first interval, it should use custom phrases
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS + 100);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
@@ -323,78 +311,24 @@ describe('usePhraseCycler', () => {
|
||||
await waitUntilReady();
|
||||
expect(customPhrases).toContain(lastFrame({ allowEmpty: true }).trim());
|
||||
|
||||
randomMock.mockReturnValue(0.99);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS);
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(customPhrases).toContain(lastFrame({ allowEmpty: true }).trim());
|
||||
|
||||
// Test fallback to default phrases.
|
||||
randomMock.mockRestore();
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.5); // Always witty
|
||||
|
||||
await act(async () => {
|
||||
setStateExternally?.({
|
||||
isActive: true,
|
||||
customPhrases: [] as string[],
|
||||
});
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS); // Wait for first cycle
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
expect(WITTY_LOADING_PHRASES).toContain(lastFrame().trim());
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should fall back to witty phrases if custom phrases are an empty array', async () => {
|
||||
vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty for subsequent phrases
|
||||
vi.spyOn(Math, 'random').mockImplementation(() => 0.5);
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<TestComponent isActive={true} isWaiting={false} customPhrases={[]} />,
|
||||
<TestComponent
|
||||
isActive={true}
|
||||
isWaiting={false}
|
||||
showTips={false}
|
||||
showWit={true}
|
||||
customPhrases={[]}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS); // Next phrase after tip
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(WITTY_LOADING_PHRASES).toContain(lastFrame().trim());
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should reset phrase when transitioning from waiting to active', async () => {
|
||||
vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty for subsequent phrases
|
||||
const { lastFrame, rerender, waitUntilReady, unmount } = render(
|
||||
<TestComponent isActive={true} isWaiting={false} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
// Cycle to a different phrase (should be witty due to mock)
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS);
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(WITTY_LOADING_PHRASES).toContain(lastFrame().trim());
|
||||
|
||||
// Go to waiting state
|
||||
await act(async () => {
|
||||
rerender(<TestComponent isActive={false} isWaiting={true} />);
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(lastFrame().trim()).toMatchSnapshot();
|
||||
|
||||
// Go back to active cycling - should pick a phrase based on the logic (witty due to mock)
|
||||
await act(async () => {
|
||||
rerender(<TestComponent isActive={true} isWaiting={false} />);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS); // Skip the tip and get next phrase
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(WITTY_LOADING_PHRASES).toContain(lastFrame().trim());
|
||||
|
||||
@@ -7,112 +7,177 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { INFORMATIVE_TIPS } from '../constants/tips.js';
|
||||
import { WITTY_LOADING_PHRASES } from '../constants/wittyPhrases.js';
|
||||
import type { LoadingPhrasesMode } from '../../config/settings.js';
|
||||
|
||||
export const PHRASE_CHANGE_INTERVAL_MS = 15000;
|
||||
export const PHRASE_CHANGE_INTERVAL_MS = 10000;
|
||||
export const WITTY_PHRASE_CHANGE_INTERVAL_MS = 5000;
|
||||
export const INTERACTIVE_SHELL_WAITING_PHRASE =
|
||||
'Interactive shell awaiting input... press tab to focus shell';
|
||||
'! Shell awaiting input (Tab to focus)';
|
||||
|
||||
/**
|
||||
* Custom hook to manage cycling through loading phrases.
|
||||
* @param isActive Whether the phrase cycling should be active.
|
||||
* @param isWaiting Whether to show a specific waiting phrase.
|
||||
* @param shouldShowFocusHint Whether to show the shell focus hint.
|
||||
* @param loadingPhrasesMode Which phrases to show: tips, witty, all, or off.
|
||||
* @param showTips Whether to show informative tips.
|
||||
* @param showWit Whether to show witty phrases.
|
||||
* @param customPhrases Optional list of custom phrases to use instead of built-in witty phrases.
|
||||
* @param maxLength Optional maximum length for the selected phrase.
|
||||
* @returns The current loading phrase.
|
||||
*/
|
||||
export const usePhraseCycler = (
|
||||
isActive: boolean,
|
||||
isWaiting: boolean,
|
||||
shouldShowFocusHint: boolean,
|
||||
loadingPhrasesMode: LoadingPhrasesMode = 'tips',
|
||||
showTips: boolean = true,
|
||||
showWit: boolean = true,
|
||||
customPhrases?: string[],
|
||||
maxLength?: number,
|
||||
) => {
|
||||
const [currentLoadingPhrase, setCurrentLoadingPhrase] = useState<
|
||||
const [currentTipState, setCurrentTipState] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [currentWittyPhraseState, setCurrentWittyPhraseState] = useState<
|
||||
string | undefined
|
||||
>(undefined);
|
||||
|
||||
const phraseIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const hasShownFirstRequestTipRef = useRef(false);
|
||||
const tipIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const wittyIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastTipChangeTimeRef = useRef<number>(0);
|
||||
const lastWittyChangeTimeRef = useRef<number>(0);
|
||||
const lastSelectedTipRef = useRef<string | undefined>(undefined);
|
||||
const lastSelectedWittyPhraseRef = useRef<string | undefined>(undefined);
|
||||
const MIN_TIP_DISPLAY_TIME_MS = 10000;
|
||||
const MIN_WIT_DISPLAY_TIME_MS = 5000;
|
||||
|
||||
useEffect(() => {
|
||||
// Always clear on re-run
|
||||
if (phraseIntervalRef.current) {
|
||||
clearInterval(phraseIntervalRef.current);
|
||||
phraseIntervalRef.current = null;
|
||||
}
|
||||
const clearTimers = () => {
|
||||
if (tipIntervalRef.current) {
|
||||
clearInterval(tipIntervalRef.current);
|
||||
tipIntervalRef.current = null;
|
||||
}
|
||||
if (wittyIntervalRef.current) {
|
||||
clearInterval(wittyIntervalRef.current);
|
||||
wittyIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
if (shouldShowFocusHint) {
|
||||
setCurrentLoadingPhrase(INTERACTIVE_SHELL_WAITING_PHRASE);
|
||||
clearTimers();
|
||||
|
||||
if (shouldShowFocusHint || isWaiting) {
|
||||
// These are handled by the return value directly for immediate feedback
|
||||
return;
|
||||
}
|
||||
|
||||
if (isWaiting) {
|
||||
setCurrentLoadingPhrase('Waiting for user confirmation...');
|
||||
if (!isActive || (!showTips && !showWit)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isActive || loadingPhrasesMode === 'off') {
|
||||
setCurrentLoadingPhrase(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const wittyPhrases =
|
||||
const wittyPhrasesList =
|
||||
customPhrases && customPhrases.length > 0
|
||||
? customPhrases
|
||||
: WITTY_LOADING_PHRASES;
|
||||
|
||||
const setRandomPhrase = () => {
|
||||
let phraseList: readonly string[];
|
||||
|
||||
switch (loadingPhrasesMode) {
|
||||
case 'tips':
|
||||
phraseList = INFORMATIVE_TIPS;
|
||||
break;
|
||||
case 'witty':
|
||||
phraseList = wittyPhrases;
|
||||
break;
|
||||
case 'all':
|
||||
// Show a tip on the first request after startup, then continue with 1/6 chance
|
||||
if (!hasShownFirstRequestTipRef.current) {
|
||||
phraseList = INFORMATIVE_TIPS;
|
||||
hasShownFirstRequestTipRef.current = true;
|
||||
} else {
|
||||
const showTip = Math.random() < 1 / 6;
|
||||
phraseList = showTip ? INFORMATIVE_TIPS : wittyPhrases;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
phraseList = INFORMATIVE_TIPS;
|
||||
break;
|
||||
const setRandomTip = (force: boolean = false) => {
|
||||
if (!showTips) {
|
||||
setCurrentTipState(undefined);
|
||||
lastSelectedTipRef.current = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const randomIndex = Math.floor(Math.random() * phraseList.length);
|
||||
setCurrentLoadingPhrase(phraseList[randomIndex]);
|
||||
};
|
||||
const now = Date.now();
|
||||
if (
|
||||
!force &&
|
||||
now - lastTipChangeTimeRef.current < MIN_TIP_DISPLAY_TIME_MS &&
|
||||
lastSelectedTipRef.current
|
||||
) {
|
||||
setCurrentTipState(lastSelectedTipRef.current);
|
||||
return;
|
||||
}
|
||||
|
||||
// Select an initial random phrase
|
||||
setRandomPhrase();
|
||||
const filteredTips =
|
||||
maxLength !== undefined
|
||||
? INFORMATIVE_TIPS.filter((p) => p.length <= maxLength)
|
||||
: INFORMATIVE_TIPS;
|
||||
|
||||
phraseIntervalRef.current = setInterval(() => {
|
||||
// Select a new random phrase
|
||||
setRandomPhrase();
|
||||
}, PHRASE_CHANGE_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
if (phraseIntervalRef.current) {
|
||||
clearInterval(phraseIntervalRef.current);
|
||||
phraseIntervalRef.current = null;
|
||||
if (filteredTips.length > 0) {
|
||||
const selected =
|
||||
filteredTips[Math.floor(Math.random() * filteredTips.length)];
|
||||
setCurrentTipState(selected);
|
||||
lastSelectedTipRef.current = selected;
|
||||
lastTipChangeTimeRef.current = now;
|
||||
}
|
||||
};
|
||||
|
||||
const setRandomWitty = (force: boolean = false) => {
|
||||
if (!showWit) {
|
||||
setCurrentWittyPhraseState(undefined);
|
||||
lastSelectedWittyPhraseRef.current = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (
|
||||
!force &&
|
||||
now - lastWittyChangeTimeRef.current < MIN_WIT_DISPLAY_TIME_MS &&
|
||||
lastSelectedWittyPhraseRef.current
|
||||
) {
|
||||
setCurrentWittyPhraseState(lastSelectedWittyPhraseRef.current);
|
||||
return;
|
||||
}
|
||||
|
||||
const filteredWitty =
|
||||
maxLength !== undefined
|
||||
? wittyPhrasesList.filter((p) => p.length <= maxLength)
|
||||
: wittyPhrasesList;
|
||||
|
||||
if (filteredWitty.length > 0) {
|
||||
const selected =
|
||||
filteredWitty[Math.floor(Math.random() * filteredWitty.length)];
|
||||
setCurrentWittyPhraseState(selected);
|
||||
lastSelectedWittyPhraseRef.current = selected;
|
||||
lastWittyChangeTimeRef.current = now;
|
||||
}
|
||||
};
|
||||
|
||||
// Select initial random phrases or resume previous ones
|
||||
setRandomTip(false);
|
||||
setRandomWitty(false);
|
||||
|
||||
if (showTips) {
|
||||
tipIntervalRef.current = setInterval(() => {
|
||||
setRandomTip(true);
|
||||
}, PHRASE_CHANGE_INTERVAL_MS);
|
||||
}
|
||||
|
||||
if (showWit) {
|
||||
wittyIntervalRef.current = setInterval(() => {
|
||||
setRandomWitty(true);
|
||||
}, WITTY_PHRASE_CHANGE_INTERVAL_MS);
|
||||
}
|
||||
|
||||
return clearTimers;
|
||||
}, [
|
||||
isActive,
|
||||
isWaiting,
|
||||
shouldShowFocusHint,
|
||||
loadingPhrasesMode,
|
||||
showTips,
|
||||
showWit,
|
||||
customPhrases,
|
||||
maxLength,
|
||||
]);
|
||||
|
||||
return currentLoadingPhrase;
|
||||
let currentTip = undefined;
|
||||
let currentWittyPhrase = undefined;
|
||||
|
||||
if (shouldShowFocusHint) {
|
||||
currentTip = INTERACTIVE_SHELL_WAITING_PHRASE;
|
||||
} else if (isWaiting) {
|
||||
currentTip = 'Waiting for user confirmation...';
|
||||
} else if (isActive) {
|
||||
currentTip = currentTipState;
|
||||
currentWittyPhrase = currentWittyPhraseState;
|
||||
}
|
||||
|
||||
return { currentTip, currentWittyPhrase };
|
||||
};
|
||||
|
||||
@@ -31,9 +31,6 @@ export const DefaultAppLayout: React.FC = () => {
|
||||
flexDirection="column"
|
||||
width={uiState.terminalWidth}
|
||||
height={isAlternateBuffer ? terminalHeight : undefined}
|
||||
paddingBottom={
|
||||
isAlternateBuffer && !uiState.copyModeEnabled ? 1 : undefined
|
||||
}
|
||||
flexShrink={0}
|
||||
flexGrow={0}
|
||||
overflow="hidden"
|
||||
|
||||
@@ -38,7 +38,7 @@ export const ScreenReaderAppLayout: React.FC = () => {
|
||||
addItem={uiState.historyManager.addItem}
|
||||
/>
|
||||
) : (
|
||||
<Composer />
|
||||
<Composer isFocused={true} />
|
||||
)}
|
||||
|
||||
<ExitWarning />
|
||||
|
||||
@@ -18,3 +18,5 @@ export const REDIRECTION_WARNING_NOTE_TEXT =
|
||||
export const REDIRECTION_WARNING_TIP_LABEL = 'Tip: '; // Padded to align with "Note: "
|
||||
export const REDIRECTION_WARNING_TIP_TEXT =
|
||||
'Toggle auto-edit (Shift+Tab) to allow redirection in the future.';
|
||||
|
||||
export const GENERIC_WORKING_LABEL = 'Working...';
|
||||
|
||||
@@ -504,6 +504,7 @@ export interface PermissionConfirmationRequest {
|
||||
export interface ActiveHook {
|
||||
name: string;
|
||||
eventName: string;
|
||||
source?: string;
|
||||
index?: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
@@ -302,6 +302,7 @@ export class HookEventHandler {
|
||||
coreEvents.emitHookStart({
|
||||
hookName: this.getHookName(config),
|
||||
eventName,
|
||||
source: config.source,
|
||||
hookIndex: index + 1,
|
||||
totalHooks: plan.hookConfigs.length,
|
||||
});
|
||||
|
||||
@@ -88,9 +88,12 @@ export interface HookPayload {
|
||||
* Payload for the 'hook-start' event.
|
||||
*/
|
||||
export interface HookStartPayload extends HookPayload {
|
||||
/**
|
||||
* The source of the hook configuration.
|
||||
*/
|
||||
source?: string;
|
||||
/**
|
||||
* The 1-based index of the current hook in the execution sequence.
|
||||
* Used for progress indication (e.g. "Hook 1/3").
|
||||
*/
|
||||
hookIndex?: number;
|
||||
/**
|
||||
|
||||
@@ -359,6 +359,35 @@
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"collapseDrawerDuringApproval": {
|
||||
"title": "Collapse Drawer During Approval",
|
||||
"description": "Collapse the entire drawer (status, context, input, footer) when a tool approval request is displayed.",
|
||||
"markdownDescription": "Collapse the entire drawer (status, context, input, footer) when a tool approval request is displayed.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `true`",
|
||||
"default": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"newFooterLayout": {
|
||||
"title": "New Footer Layout",
|
||||
"description": "Use the new 2-row layout with inline tips.",
|
||||
"markdownDescription": "Use the new 2-row layout with inline tips.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `legacy`",
|
||||
"default": "legacy",
|
||||
"type": "string",
|
||||
"enum": ["legacy", "new", "new_divider_down"]
|
||||
},
|
||||
"showTips": {
|
||||
"title": "Show Tips",
|
||||
"description": "Show informative tips on the right side of the status line.",
|
||||
"markdownDescription": "Show informative tips on the right side of the status line.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `true`",
|
||||
"default": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"showWit": {
|
||||
"title": "Show Witty Phrases",
|
||||
"description": "Show witty phrases while waiting.",
|
||||
"markdownDescription": "Show witty phrases while waiting.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `true`",
|
||||
"default": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"showMemoryUsage": {
|
||||
"title": "Show Memory Usage",
|
||||
"description": "Display memory usage information in the UI",
|
||||
@@ -422,14 +451,6 @@
|
||||
"default": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"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`",
|
||||
"default": "tips",
|
||||
"type": "string",
|
||||
"enum": ["tips", "witty", "all", "off"]
|
||||
},
|
||||
"errorVerbosity": {
|
||||
"title": "Error Verbosity",
|
||||
"description": "Controls whether recoverable errors are hidden (low) or fully shown (full).",
|
||||
|
||||
Reference in New Issue
Block a user