mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 12:54:07 -07:00
feat: Ctrl+O to expand paste placeholder (#18103)
This commit is contained in:
@@ -106,6 +106,7 @@ available combinations.
|
|||||||
| Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl + Y` |
|
| Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl + Y` |
|
||||||
| Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). | `Shift + Tab` |
|
| Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). | `Shift + Tab` |
|
||||||
| Expand a height-constrained response to show additional lines when not in alternate buffer mode. | `Ctrl + O`<br />`Ctrl + S` |
|
| Expand a height-constrained response to show additional lines when not in alternate buffer mode. | `Ctrl + O`<br />`Ctrl + S` |
|
||||||
|
| Expand or collapse a paste placeholder when cursor is over placeholder. | `Ctrl + O` |
|
||||||
| Toggle current background shell visibility. | `Ctrl + B` |
|
| Toggle current background shell visibility. | `Ctrl + B` |
|
||||||
| Toggle background shell list. | `Ctrl + L` |
|
| Toggle background shell list. | `Ctrl + L` |
|
||||||
| Kill the active background shell. | `Ctrl + K` |
|
| Kill the active background shell. | `Ctrl + K` |
|
||||||
@@ -139,6 +140,7 @@ available combinations.
|
|||||||
single-line input, navigate backward or forward through prompt history.
|
single-line input, navigate backward or forward through prompt history.
|
||||||
- `Number keys (1-9, multi-digit)` inside selection dialogs: Jump directly to
|
- `Number keys (1-9, multi-digit)` inside selection dialogs: Jump directly to
|
||||||
the numbered radio option and confirm when the full number is entered.
|
the numbered radio option and confirm when the full number is entered.
|
||||||
- `Double-click` on a paste placeholder (`[Pasted Text: X lines]`) in alternate
|
- `Ctrl + O`: Expand or collapse paste placeholders (`[Pasted Text: X lines]`)
|
||||||
buffer mode: Expand to view full content inline. Double-click again to
|
inline when the cursor is over the placeholder.
|
||||||
collapse.
|
- `Double-click` on a paste placeholder (alternate buffer mode only): Expand to
|
||||||
|
view full content inline. Double-click again to collapse.
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export enum Command {
|
|||||||
TOGGLE_YOLO = 'app.toggleYolo',
|
TOGGLE_YOLO = 'app.toggleYolo',
|
||||||
CYCLE_APPROVAL_MODE = 'app.cycleApprovalMode',
|
CYCLE_APPROVAL_MODE = 'app.cycleApprovalMode',
|
||||||
SHOW_MORE_LINES = 'app.showMoreLines',
|
SHOW_MORE_LINES = 'app.showMoreLines',
|
||||||
|
EXPAND_PASTE = 'app.expandPaste',
|
||||||
FOCUS_SHELL_INPUT = 'app.focusShellInput',
|
FOCUS_SHELL_INPUT = 'app.focusShellInput',
|
||||||
UNFOCUS_SHELL_INPUT = 'app.unfocusShellInput',
|
UNFOCUS_SHELL_INPUT = 'app.unfocusShellInput',
|
||||||
CLEAR_SCREEN = 'app.clearScreen',
|
CLEAR_SCREEN = 'app.clearScreen',
|
||||||
@@ -289,6 +290,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
|||||||
{ key: 'o', ctrl: true },
|
{ key: 'o', ctrl: true },
|
||||||
{ key: 's', ctrl: true },
|
{ key: 's', ctrl: true },
|
||||||
],
|
],
|
||||||
|
[Command.EXPAND_PASTE]: [{ key: 'o', ctrl: true }],
|
||||||
[Command.FOCUS_SHELL_INPUT]: [{ key: 'tab', shift: false }],
|
[Command.FOCUS_SHELL_INPUT]: [{ key: 'tab', shift: false }],
|
||||||
[Command.UNFOCUS_SHELL_INPUT]: [{ key: 'tab', shift: true }],
|
[Command.UNFOCUS_SHELL_INPUT]: [{ key: 'tab', shift: true }],
|
||||||
[Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }],
|
[Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }],
|
||||||
@@ -399,6 +401,7 @@ export const commandCategories: readonly CommandCategory[] = [
|
|||||||
Command.TOGGLE_YOLO,
|
Command.TOGGLE_YOLO,
|
||||||
Command.CYCLE_APPROVAL_MODE,
|
Command.CYCLE_APPROVAL_MODE,
|
||||||
Command.SHOW_MORE_LINES,
|
Command.SHOW_MORE_LINES,
|
||||||
|
Command.EXPAND_PASTE,
|
||||||
Command.TOGGLE_BACKGROUND_SHELL,
|
Command.TOGGLE_BACKGROUND_SHELL,
|
||||||
Command.TOGGLE_BACKGROUND_SHELL_LIST,
|
Command.TOGGLE_BACKGROUND_SHELL_LIST,
|
||||||
Command.KILL_BACKGROUND_SHELL,
|
Command.KILL_BACKGROUND_SHELL,
|
||||||
@@ -499,6 +502,8 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
|
|||||||
'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only).',
|
'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only).',
|
||||||
[Command.SHOW_MORE_LINES]:
|
[Command.SHOW_MORE_LINES]:
|
||||||
'Expand a height-constrained response to show additional lines when not in alternate buffer mode.',
|
'Expand a height-constrained response to show additional lines when not in alternate buffer mode.',
|
||||||
|
[Command.EXPAND_PASTE]:
|
||||||
|
'Expand or collapse a paste placeholder when cursor is over placeholder.',
|
||||||
[Command.BACKGROUND_SHELL_SELECT]:
|
[Command.BACKGROUND_SHELL_SELECT]:
|
||||||
'Confirm selection in background shell list.',
|
'Confirm selection in background shell list.',
|
||||||
[Command.BACKGROUND_SHELL_ESCAPE]: 'Dismiss background shell list.',
|
[Command.BACKGROUND_SHELL_ESCAPE]: 'Dismiss background shell list.',
|
||||||
|
|||||||
@@ -200,7 +200,6 @@ const mockUIActions: UIActions = {
|
|||||||
setActiveBackgroundShellPid: vi.fn(),
|
setActiveBackgroundShellPid: vi.fn(),
|
||||||
setIsBackgroundShellListOpen: vi.fn(),
|
setIsBackgroundShellListOpen: vi.fn(),
|
||||||
setAuthContext: vi.fn(),
|
setAuthContext: vi.fn(),
|
||||||
handleWarning: vi.fn(),
|
|
||||||
handleRestart: vi.fn(),
|
handleRestart: vi.fn(),
|
||||||
handleNewAgentsSelect: vi.fn(),
|
handleNewAgentsSelect: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js';
|
|||||||
import { useFolderTrust } from './hooks/useFolderTrust.js';
|
import { useFolderTrust } from './hooks/useFolderTrust.js';
|
||||||
import { useIdeTrustListener } from './hooks/useIdeTrustListener.js';
|
import { useIdeTrustListener } from './hooks/useIdeTrustListener.js';
|
||||||
import { type IdeIntegrationNudgeResult } from './IdeIntegrationNudge.js';
|
import { type IdeIntegrationNudgeResult } from './IdeIntegrationNudge.js';
|
||||||
import { appEvents, AppEvent } from '../utils/events.js';
|
import { appEvents, AppEvent, TransientMessageType } from '../utils/events.js';
|
||||||
import { type UpdateObject } from './utils/updateCheck.js';
|
import { type UpdateObject } from './utils/updateCheck.js';
|
||||||
import { setUpdateHandler } from '../utils/handleAutoUpdate.js';
|
import { setUpdateHandler } from '../utils/handleAutoUpdate.js';
|
||||||
import { registerCleanup, runExitCleanup } from '../utils/cleanup.js';
|
import { registerCleanup, runExitCleanup } from '../utils/cleanup.js';
|
||||||
@@ -143,6 +143,7 @@ import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialo
|
|||||||
import { NewAgentsChoice } from './components/NewAgentsNotification.js';
|
import { NewAgentsChoice } from './components/NewAgentsNotification.js';
|
||||||
import { isSlashCommand } from './utils/commandUtils.js';
|
import { isSlashCommand } from './utils/commandUtils.js';
|
||||||
import { useTerminalTheme } from './hooks/useTerminalTheme.js';
|
import { useTerminalTheme } from './hooks/useTerminalTheme.js';
|
||||||
|
import { useTimedMessage } from './hooks/useTimedMessage.js';
|
||||||
import { isITerm2 } from './utils/terminalUtils.js';
|
import { isITerm2 } from './utils/terminalUtils.js';
|
||||||
|
|
||||||
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
|
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
|
||||||
@@ -1289,7 +1290,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
>();
|
>();
|
||||||
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
|
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
|
||||||
const [showIdeRestartPrompt, setShowIdeRestartPrompt] = useState(false);
|
const [showIdeRestartPrompt, setShowIdeRestartPrompt] = useState(false);
|
||||||
const [warningMessage, setWarningMessage] = useState<string | null>(null);
|
|
||||||
|
const [transientMessage, showTransientMessage] = useTimedMessage<{
|
||||||
|
text: string;
|
||||||
|
type: TransientMessageType;
|
||||||
|
}>(WARNING_PROMPT_DURATION_MS);
|
||||||
|
|
||||||
const { isFolderTrustDialogOpen, handleFolderTrustSelect, isRestarting } =
|
const { isFolderTrustDialogOpen, handleFolderTrustSelect, isRestarting } =
|
||||||
useFolderTrust(settings, setIsTrustedFolder, historyManager.addItem);
|
useFolderTrust(settings, setIsTrustedFolder, historyManager.addItem);
|
||||||
@@ -1301,41 +1306,42 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
|
|
||||||
useIncludeDirsTrust(config, isTrustedFolder, historyManager, setCustomDialog);
|
useIncludeDirsTrust(config, isTrustedFolder, historyManager, setCustomDialog);
|
||||||
|
|
||||||
const warningTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
const tabFocusTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const tabFocusTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
const handleWarning = useCallback((message: string) => {
|
useEffect(() => {
|
||||||
setWarningMessage(message);
|
const handleTransientMessage = (payload: {
|
||||||
if (warningTimeoutRef.current) {
|
message: string;
|
||||||
clearTimeout(warningTimeoutRef.current);
|
type: TransientMessageType;
|
||||||
}
|
}) => {
|
||||||
warningTimeoutRef.current = setTimeout(() => {
|
showTransientMessage({ text: payload.message, type: payload.type });
|
||||||
setWarningMessage(null);
|
};
|
||||||
}, WARNING_PROMPT_DURATION_MS);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Handle timeout cleanup on unmount
|
const handleSelectionWarning = () => {
|
||||||
useEffect(
|
showTransientMessage({
|
||||||
() => () => {
|
text: 'Press Ctrl-S to enter selection mode to copy text.',
|
||||||
if (warningTimeoutRef.current) {
|
type: TransientMessageType.Warning,
|
||||||
clearTimeout(warningTimeoutRef.current);
|
});
|
||||||
}
|
};
|
||||||
|
const handlePasteTimeout = () => {
|
||||||
|
showTransientMessage({
|
||||||
|
text: 'Paste Timed out. Possibly due to slow connection.',
|
||||||
|
type: TransientMessageType.Warning,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
appEvents.on(AppEvent.TransientMessage, handleTransientMessage);
|
||||||
|
appEvents.on(AppEvent.SelectionWarning, handleSelectionWarning);
|
||||||
|
appEvents.on(AppEvent.PasteTimeout, handlePasteTimeout);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
appEvents.off(AppEvent.TransientMessage, handleTransientMessage);
|
||||||
|
appEvents.off(AppEvent.SelectionWarning, handleSelectionWarning);
|
||||||
|
appEvents.off(AppEvent.PasteTimeout, handlePasteTimeout);
|
||||||
if (tabFocusTimeoutRef.current) {
|
if (tabFocusTimeoutRef.current) {
|
||||||
clearTimeout(tabFocusTimeoutRef.current);
|
clearTimeout(tabFocusTimeoutRef.current);
|
||||||
}
|
}
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handlePasteTimeout = () => {
|
|
||||||
handleWarning('Paste Timed out. Possibly due to slow connection.');
|
|
||||||
};
|
};
|
||||||
appEvents.on(AppEvent.PasteTimeout, handlePasteTimeout);
|
}, [showTransientMessage]);
|
||||||
return () => {
|
|
||||||
appEvents.off(AppEvent.PasteTimeout, handlePasteTimeout);
|
|
||||||
};
|
|
||||||
}, [handleWarning]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (ideNeedsRestart) {
|
if (ideNeedsRestart) {
|
||||||
@@ -1503,7 +1509,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
const undoMessage = isITerm2()
|
const undoMessage = isITerm2()
|
||||||
? 'Undo has been moved to Option + Z'
|
? 'Undo has been moved to Option + Z'
|
||||||
: 'Undo has been moved to Alt/Option + Z or Cmd + Z';
|
: 'Undo has been moved to Alt/Option + Z or Cmd + Z';
|
||||||
handleWarning(undoMessage);
|
showTransientMessage({
|
||||||
|
text: undoMessage,
|
||||||
|
type: TransientMessageType.Warning,
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
} else if (keyMatchers[Command.SHOW_FULL_TODOS](key)) {
|
} else if (keyMatchers[Command.SHOW_FULL_TODOS](key)) {
|
||||||
setShowFullTodos((prev) => !prev);
|
setShowFullTodos((prev) => !prev);
|
||||||
@@ -1543,7 +1552,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
if (lastOutputTimeRef.current === capturedTime) {
|
if (lastOutputTimeRef.current === capturedTime) {
|
||||||
setEmbeddedShellFocused(false);
|
setEmbeddedShellFocused(false);
|
||||||
} else {
|
} else {
|
||||||
handleWarning('Use Shift+Tab to unfocus');
|
showTransientMessage({
|
||||||
|
text: 'Use Shift+Tab to unfocus',
|
||||||
|
type: TransientMessageType.Warning,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, 150);
|
}, 150);
|
||||||
return false;
|
return false;
|
||||||
@@ -1623,7 +1635,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
setIsBackgroundShellListOpen,
|
setIsBackgroundShellListOpen,
|
||||||
lastOutputTimeRef,
|
lastOutputTimeRef,
|
||||||
tabFocusTimeoutRef,
|
tabFocusTimeoutRef,
|
||||||
handleWarning,
|
showTransientMessage,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1906,7 +1918,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
showDebugProfiler,
|
showDebugProfiler,
|
||||||
customDialog,
|
customDialog,
|
||||||
copyModeEnabled,
|
copyModeEnabled,
|
||||||
warningMessage,
|
transientMessage,
|
||||||
bannerData,
|
bannerData,
|
||||||
bannerVisible,
|
bannerVisible,
|
||||||
terminalBackgroundColor: config.getTerminalBackground(),
|
terminalBackgroundColor: config.getTerminalBackground(),
|
||||||
@@ -2016,7 +2028,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
apiKeyDefaultValue,
|
apiKeyDefaultValue,
|
||||||
authState,
|
authState,
|
||||||
copyModeEnabled,
|
copyModeEnabled,
|
||||||
warningMessage,
|
transientMessage,
|
||||||
bannerData,
|
bannerData,
|
||||||
bannerVisible,
|
bannerVisible,
|
||||||
config,
|
config,
|
||||||
@@ -2073,7 +2085,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
handleApiKeyCancel,
|
handleApiKeyCancel,
|
||||||
setBannerVisible,
|
setBannerVisible,
|
||||||
setShortcutsHelpVisible,
|
setShortcutsHelpVisible,
|
||||||
handleWarning,
|
|
||||||
setEmbeddedShellFocused,
|
setEmbeddedShellFocused,
|
||||||
dismissBackgroundShell,
|
dismissBackgroundShell,
|
||||||
setActiveBackgroundShellPid,
|
setActiveBackgroundShellPid,
|
||||||
@@ -2150,7 +2161,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
handleApiKeyCancel,
|
handleApiKeyCancel,
|
||||||
setBannerVisible,
|
setBannerVisible,
|
||||||
setShortcutsHelpVisible,
|
setShortcutsHelpVisible,
|
||||||
handleWarning,
|
|
||||||
setEmbeddedShellFocused,
|
setEmbeddedShellFocused,
|
||||||
dismissBackgroundShell,
|
dismissBackgroundShell,
|
||||||
setActiveBackgroundShellPid,
|
setActiveBackgroundShellPid,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { render } from '../../test-utils/render.js';
|
import { render } from '../../test-utils/render.js';
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { BackgroundShellDisplay } from './BackgroundShellDisplay.js';
|
import { BackgroundShellDisplay } from './BackgroundShellDisplay.js';
|
||||||
import { type BackgroundShell } from '../hooks/shellCommandProcessor.js';
|
import { type BackgroundShell } from '../hooks/shellCommandProcessor.js';
|
||||||
import { ShellExecutionService } from '@google/gemini-cli-core';
|
import { ShellExecutionService } from '@google/gemini-cli-core';
|
||||||
@@ -20,16 +20,12 @@ const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|||||||
const mockDismissBackgroundShell = vi.fn();
|
const mockDismissBackgroundShell = vi.fn();
|
||||||
const mockSetActiveBackgroundShellPid = vi.fn();
|
const mockSetActiveBackgroundShellPid = vi.fn();
|
||||||
const mockSetIsBackgroundShellListOpen = vi.fn();
|
const mockSetIsBackgroundShellListOpen = vi.fn();
|
||||||
const mockHandleWarning = vi.fn();
|
|
||||||
const mockSetEmbeddedShellFocused = vi.fn();
|
|
||||||
|
|
||||||
vi.mock('../contexts/UIActionsContext.js', () => ({
|
vi.mock('../contexts/UIActionsContext.js', () => ({
|
||||||
useUIActions: () => ({
|
useUIActions: () => ({
|
||||||
dismissBackgroundShell: mockDismissBackgroundShell,
|
dismissBackgroundShell: mockDismissBackgroundShell,
|
||||||
setActiveBackgroundShellPid: mockSetActiveBackgroundShellPid,
|
setActiveBackgroundShellPid: mockSetActiveBackgroundShellPid,
|
||||||
setIsBackgroundShellListOpen: mockSetIsBackgroundShellListOpen,
|
setIsBackgroundShellListOpen: mockSetIsBackgroundShellListOpen,
|
||||||
handleWarning: mockHandleWarning,
|
|
||||||
setEmbeddedShellFocused: mockSetEmbeddedShellFocused,
|
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -103,6 +99,10 @@ vi.mock('./shared/ScrollableList.js', () => ({
|
|||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
const createMockKey = (overrides: Partial<Key>): Key => ({
|
const createMockKey = (overrides: Partial<Key>): Key => ({
|
||||||
name: '',
|
name: '',
|
||||||
ctrl: false,
|
ctrl: false,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { createMockSettings } from '../../test-utils/settings.js';
|
|||||||
import { waitFor } from '../../test-utils/async.js';
|
import { waitFor } from '../../test-utils/async.js';
|
||||||
import { act, useState } from 'react';
|
import { act, useState } from 'react';
|
||||||
import type { InputPromptProps } from './InputPrompt.js';
|
import type { InputPromptProps } from './InputPrompt.js';
|
||||||
import { InputPrompt } from './InputPrompt.js';
|
import { InputPrompt, tryTogglePasteExpansion } from './InputPrompt.js';
|
||||||
import type { TextBuffer } from './shared/text-buffer.js';
|
import type { TextBuffer } from './shared/text-buffer.js';
|
||||||
import {
|
import {
|
||||||
calculateTransformationsForLine,
|
calculateTransformationsForLine,
|
||||||
@@ -46,6 +46,11 @@ import { isLowColorDepth } from '../utils/terminalUtils.js';
|
|||||||
import { cpLen } from '../utils/textUtils.js';
|
import { cpLen } from '../utils/textUtils.js';
|
||||||
import { keyMatchers, Command } from '../keyMatchers.js';
|
import { keyMatchers, Command } from '../keyMatchers.js';
|
||||||
import type { Key } from '../hooks/useKeypress.js';
|
import type { Key } from '../hooks/useKeypress.js';
|
||||||
|
import {
|
||||||
|
appEvents,
|
||||||
|
AppEvent,
|
||||||
|
TransientMessageType,
|
||||||
|
} from '../../utils/events.js';
|
||||||
|
|
||||||
vi.mock('../hooks/useShellHistory.js');
|
vi.mock('../hooks/useShellHistory.js');
|
||||||
vi.mock('../hooks/useCommandCompletion.js');
|
vi.mock('../hooks/useCommandCompletion.js');
|
||||||
@@ -69,6 +74,10 @@ vi.mock('ink', async (importOriginal) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
const mockSlashCommands: SlashCommand[] = [
|
const mockSlashCommands: SlashCommand[] = [
|
||||||
{
|
{
|
||||||
name: 'clear',
|
name: 'clear',
|
||||||
@@ -3826,6 +3835,260 @@ describe('InputPrompt', () => {
|
|||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Ctrl+O paste expansion', () => {
|
||||||
|
const CTRL_O = '\x0f'; // Ctrl+O key sequence
|
||||||
|
|
||||||
|
it('Ctrl+O triggers paste expansion via keybinding', async () => {
|
||||||
|
const id = '[Pasted Text: 10 lines]';
|
||||||
|
const toggleFn = vi.fn();
|
||||||
|
const buffer = {
|
||||||
|
...props.buffer,
|
||||||
|
text: id,
|
||||||
|
cursor: [0, 0] as number[],
|
||||||
|
pastedContent: {
|
||||||
|
[id]: 'line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10',
|
||||||
|
},
|
||||||
|
transformationsByLine: [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
logStart: 0,
|
||||||
|
logEnd: id.length,
|
||||||
|
logicalText: id,
|
||||||
|
collapsedText: id,
|
||||||
|
type: 'paste',
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
expandedPaste: null,
|
||||||
|
getExpandedPasteAtLine: vi.fn().mockReturnValue(null),
|
||||||
|
togglePasteExpansion: toggleFn,
|
||||||
|
} as unknown as TextBuffer;
|
||||||
|
|
||||||
|
const { stdin, unmount } = renderWithProviders(
|
||||||
|
<InputPrompt {...props} buffer={buffer} />,
|
||||||
|
{ uiActions },
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
stdin.write(CTRL_O);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(toggleFn).toHaveBeenCalledWith(id, 0, 0);
|
||||||
|
});
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
name: 'hint appears on large paste via Ctrl+V',
|
||||||
|
text: 'line1\nline2\nline3\nline4\nline5\nline6',
|
||||||
|
method: 'ctrl-v',
|
||||||
|
expectHint: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'hint does not appear for small pastes via Ctrl+V',
|
||||||
|
text: 'hello',
|
||||||
|
method: 'ctrl-v',
|
||||||
|
expectHint: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'hint appears on large terminal paste event',
|
||||||
|
text: 'line1\nline2\nline3\nline4\nline5\nline6',
|
||||||
|
method: 'terminal-paste',
|
||||||
|
expectHint: true,
|
||||||
|
},
|
||||||
|
])('$name', async ({ text, method, expectHint }) => {
|
||||||
|
vi.mocked(clipboardy.read).mockResolvedValue(text);
|
||||||
|
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
|
||||||
|
|
||||||
|
const emitSpy = vi.spyOn(appEvents, 'emit');
|
||||||
|
const buffer = {
|
||||||
|
...props.buffer,
|
||||||
|
handleInput: vi.fn().mockReturnValue(true),
|
||||||
|
} as unknown as TextBuffer;
|
||||||
|
|
||||||
|
// Need kitty protocol enabled for terminal paste events
|
||||||
|
if (method === 'terminal-paste') {
|
||||||
|
mockedUseKittyKeyboardProtocol.mockReturnValue({
|
||||||
|
enabled: true,
|
||||||
|
checking: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { stdin, unmount } = renderWithProviders(
|
||||||
|
<InputPrompt
|
||||||
|
{...props}
|
||||||
|
buffer={method === 'terminal-paste' ? buffer : props.buffer}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
if (method === 'ctrl-v') {
|
||||||
|
stdin.write('\x16'); // Ctrl+V
|
||||||
|
} else {
|
||||||
|
stdin.write(`\x1b[200~${text}\x1b[201~`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
if (expectHint) {
|
||||||
|
expect(emitSpy).toHaveBeenCalledWith(AppEvent.TransientMessage, {
|
||||||
|
message: 'Press Ctrl+O to expand pasted text',
|
||||||
|
type: TransientMessageType.Hint,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// If no hint expected, verify buffer was still updated
|
||||||
|
if (method === 'ctrl-v') {
|
||||||
|
expect(mockBuffer.insert).toHaveBeenCalledWith(text, {
|
||||||
|
paste: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
expect(buffer.handleInput).toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!expectHint) {
|
||||||
|
expect(emitSpy).not.toHaveBeenCalledWith(
|
||||||
|
AppEvent.TransientMessage,
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
emitSpy.mockRestore();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tryTogglePasteExpansion', () => {
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
name: 'returns false when no pasted content exists',
|
||||||
|
cursor: [0, 0],
|
||||||
|
pastedContent: {},
|
||||||
|
getExpandedPasteAtLine: null,
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'expands placeholder under cursor',
|
||||||
|
cursor: [0, 2],
|
||||||
|
pastedContent: { '[Pasted Text: 6 lines]': 'content' },
|
||||||
|
transformations: [
|
||||||
|
{
|
||||||
|
logStart: 0,
|
||||||
|
logEnd: '[Pasted Text: 6 lines]'.length,
|
||||||
|
id: '[Pasted Text: 6 lines]',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
expected: true,
|
||||||
|
expectedToggle: ['[Pasted Text: 6 lines]', 0, 2],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'collapses expanded paste when cursor is inside',
|
||||||
|
cursor: [1, 0],
|
||||||
|
pastedContent: { '[Pasted Text: 6 lines]': 'a\nb\nc' },
|
||||||
|
getExpandedPasteAtLine: '[Pasted Text: 6 lines]',
|
||||||
|
expected: true,
|
||||||
|
expectedToggle: ['[Pasted Text: 6 lines]', 1, 0],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'expands placeholder when cursor is immediately after it',
|
||||||
|
cursor: [0, '[Pasted Text: 6 lines]'.length],
|
||||||
|
pastedContent: { '[Pasted Text: 6 lines]': 'content' },
|
||||||
|
transformations: [
|
||||||
|
{
|
||||||
|
logStart: 0,
|
||||||
|
logEnd: '[Pasted Text: 6 lines]'.length,
|
||||||
|
id: '[Pasted Text: 6 lines]',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
expected: true,
|
||||||
|
expectedToggle: [
|
||||||
|
'[Pasted Text: 6 lines]',
|
||||||
|
0,
|
||||||
|
'[Pasted Text: 6 lines]'.length,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'shows hint when cursor is not on placeholder but placeholders exist',
|
||||||
|
cursor: [0, 0],
|
||||||
|
pastedContent: { '[Pasted Text: 6 lines]': 'content' },
|
||||||
|
transformationsByLine: [
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
logStart: 0,
|
||||||
|
logEnd: '[Pasted Text: 6 lines]'.length,
|
||||||
|
type: 'paste',
|
||||||
|
id: '[Pasted Text: 6 lines]',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
expected: true,
|
||||||
|
expectedHint: 'Move cursor within placeholder to expand',
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
'$name',
|
||||||
|
({
|
||||||
|
cursor,
|
||||||
|
pastedContent,
|
||||||
|
transformations,
|
||||||
|
transformationsByLine,
|
||||||
|
getExpandedPasteAtLine,
|
||||||
|
expected,
|
||||||
|
expectedToggle,
|
||||||
|
expectedHint,
|
||||||
|
}) => {
|
||||||
|
const id = '[Pasted Text: 6 lines]';
|
||||||
|
const buffer = {
|
||||||
|
cursor,
|
||||||
|
pastedContent,
|
||||||
|
transformationsByLine: transformationsByLine || [
|
||||||
|
transformations
|
||||||
|
? transformations.map((t) => ({
|
||||||
|
...t,
|
||||||
|
logicalText: id,
|
||||||
|
collapsedText: id,
|
||||||
|
type: 'paste',
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
],
|
||||||
|
getExpandedPasteAtLine: vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue(getExpandedPasteAtLine),
|
||||||
|
togglePasteExpansion: vi.fn(),
|
||||||
|
} as unknown as TextBuffer;
|
||||||
|
|
||||||
|
const emitSpy = vi.spyOn(appEvents, 'emit');
|
||||||
|
expect(tryTogglePasteExpansion(buffer)).toBe(expected);
|
||||||
|
|
||||||
|
if (expectedToggle) {
|
||||||
|
expect(buffer.togglePasteExpansion).toHaveBeenCalledWith(
|
||||||
|
...expectedToggle,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
expect(buffer.togglePasteExpansion).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expectedHint) {
|
||||||
|
expect(emitSpy).toHaveBeenCalledWith(AppEvent.TransientMessage, {
|
||||||
|
message: expectedHint,
|
||||||
|
type: TransientMessageType.Hint,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
expect(emitSpy).not.toHaveBeenCalledWith(
|
||||||
|
AppEvent.TransientMessage,
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
emitSpy.mockRestore();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
describe('History Navigation and Completion Suppression', () => {
|
describe('History Navigation and Completion Suppression', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
props.userMessages = ['first message', 'second message'];
|
props.userMessages = ['first message', 'second message'];
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
logicalPosToOffset,
|
logicalPosToOffset,
|
||||||
PASTED_TEXT_PLACEHOLDER_REGEX,
|
PASTED_TEXT_PLACEHOLDER_REGEX,
|
||||||
getTransformUnderCursor,
|
getTransformUnderCursor,
|
||||||
|
LARGE_PASTE_LINE_THRESHOLD,
|
||||||
|
LARGE_PASTE_CHAR_THRESHOLD,
|
||||||
} from './shared/text-buffer.js';
|
} from './shared/text-buffer.js';
|
||||||
import {
|
import {
|
||||||
cpSlice,
|
cpSlice,
|
||||||
@@ -59,6 +61,11 @@ import { getSafeLowColorBackground } from '../themes/color-utils.js';
|
|||||||
import { isLowColorDepth } from '../utils/terminalUtils.js';
|
import { isLowColorDepth } from '../utils/terminalUtils.js';
|
||||||
import { useShellFocusState } from '../contexts/ShellFocusContext.js';
|
import { useShellFocusState } from '../contexts/ShellFocusContext.js';
|
||||||
import { useUIState } from '../contexts/UIStateContext.js';
|
import { useUIState } from '../contexts/UIStateContext.js';
|
||||||
|
import {
|
||||||
|
appEvents,
|
||||||
|
AppEvent,
|
||||||
|
TransientMessageType,
|
||||||
|
} from '../../utils/events.js';
|
||||||
import { useSettings } from '../contexts/SettingsContext.js';
|
import { useSettings } from '../contexts/SettingsContext.js';
|
||||||
import { StreamingState } from '../types.js';
|
import { StreamingState } from '../types.js';
|
||||||
import { useMouseClick } from '../hooks/useMouseClick.js';
|
import { useMouseClick } from '../hooks/useMouseClick.js';
|
||||||
@@ -122,6 +129,55 @@ export const calculatePromptWidths = (mainContentWidth: number) => {
|
|||||||
} as const;
|
} as const;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the given text exceeds the thresholds for being considered a "large paste".
|
||||||
|
*/
|
||||||
|
export function isLargePaste(text: string): boolean {
|
||||||
|
const pasteLineCount = text.split('\n').length;
|
||||||
|
return (
|
||||||
|
pasteLineCount > LARGE_PASTE_LINE_THRESHOLD ||
|
||||||
|
text.length > LARGE_PASTE_CHAR_THRESHOLD
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to toggle expansion of a paste placeholder in the buffer.
|
||||||
|
* Returns true if a toggle action was performed or hint was shown, false otherwise.
|
||||||
|
*/
|
||||||
|
export function tryTogglePasteExpansion(buffer: TextBuffer): boolean {
|
||||||
|
if (!buffer.pastedContent || Object.keys(buffer.pastedContent).length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [row, col] = buffer.cursor;
|
||||||
|
|
||||||
|
// 1. Check if cursor is on or immediately after a collapsed placeholder
|
||||||
|
const transform = getTransformUnderCursor(
|
||||||
|
row,
|
||||||
|
col,
|
||||||
|
buffer.transformationsByLine,
|
||||||
|
{ includeEdge: true },
|
||||||
|
);
|
||||||
|
if (transform?.type === 'paste' && transform.id) {
|
||||||
|
buffer.togglePasteExpansion(transform.id, row, col);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check if cursor is inside an expanded paste region — collapse it
|
||||||
|
const expandedId = buffer.getExpandedPasteAtLine(row);
|
||||||
|
if (expandedId) {
|
||||||
|
buffer.togglePasteExpansion(expandedId, row, col);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Placeholders exist but cursor isn't on one — show hint
|
||||||
|
appEvents.emit(AppEvent.TransientMessage, {
|
||||||
|
message: 'Move cursor within placeholder to expand',
|
||||||
|
type: TransientMessageType.Hint,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export const InputPrompt: React.FC<InputPromptProps> = ({
|
export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
buffer,
|
buffer,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
@@ -402,6 +458,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
} else {
|
} else {
|
||||||
const textToInsert = await clipboardy.read();
|
const textToInsert = await clipboardy.read();
|
||||||
buffer.insert(textToInsert, { paste: true });
|
buffer.insert(textToInsert, { paste: true });
|
||||||
|
if (isLargePaste(textToInsert)) {
|
||||||
|
appEvents.emit(AppEvent.TransientMessage, {
|
||||||
|
message: 'Press Ctrl+O to expand pasted text',
|
||||||
|
type: TransientMessageType.Hint,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debugLogger.error('Error handling paste:', error);
|
debugLogger.error('Error handling paste:', error);
|
||||||
@@ -455,6 +517,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
logicalPos.row,
|
logicalPos.row,
|
||||||
logicalPos.col,
|
logicalPos.col,
|
||||||
buffer.transformationsByLine,
|
buffer.transformationsByLine,
|
||||||
|
{ includeEdge: true },
|
||||||
);
|
);
|
||||||
if (transform?.type === 'paste' && transform.id) {
|
if (transform?.type === 'paste' && transform.id) {
|
||||||
buffer.togglePasteExpansion(
|
buffer.togglePasteExpansion(
|
||||||
@@ -591,6 +654,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
}
|
}
|
||||||
// Ensure we never accidentally interpret paste as regular input.
|
// Ensure we never accidentally interpret paste as regular input.
|
||||||
buffer.handleInput(key);
|
buffer.handleInput(key);
|
||||||
|
if (key.sequence && isLargePaste(key.sequence)) {
|
||||||
|
appEvents.emit(AppEvent.TransientMessage, {
|
||||||
|
message: 'Press Ctrl+O to expand pasted text',
|
||||||
|
type: TransientMessageType.Hint,
|
||||||
|
});
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -632,6 +701,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ctrl+O to expand/collapse paste placeholders
|
||||||
|
if (keyMatchers[Command.EXPAND_PASTE](key)) {
|
||||||
|
const handled = tryTogglePasteExpansion(buffer);
|
||||||
|
if (handled) return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
key.sequence === '!' &&
|
key.sequence === '!' &&
|
||||||
buffer.text === '' &&
|
buffer.text === '' &&
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { render } from '../../test-utils/render.js';
|
|||||||
import { Text } from 'ink';
|
import { Text } from 'ink';
|
||||||
import { StatusDisplay } from './StatusDisplay.js';
|
import { StatusDisplay } from './StatusDisplay.js';
|
||||||
import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
|
import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
|
||||||
|
import { TransientMessageType } from '../../utils/events.js';
|
||||||
import { ConfigContext } from '../contexts/ConfigContext.js';
|
import { ConfigContext } from '../contexts/ConfigContext.js';
|
||||||
import { SettingsContext } from '../contexts/SettingsContext.js';
|
import { SettingsContext } from '../contexts/SettingsContext.js';
|
||||||
import { createMockSettings } from '../../test-utils/settings.js';
|
import { createMockSettings } from '../../test-utils/settings.js';
|
||||||
@@ -40,7 +41,7 @@ type UIStateOverrides = Partial<Omit<UIState, 'buffer'>> & {
|
|||||||
const createMockUIState = (overrides: UIStateOverrides = {}): UIState =>
|
const createMockUIState = (overrides: UIStateOverrides = {}): UIState =>
|
||||||
({
|
({
|
||||||
ctrlCPressedOnce: false,
|
ctrlCPressedOnce: false,
|
||||||
warningMessage: null,
|
transientMessage: null,
|
||||||
ctrlDPressedOnce: false,
|
ctrlDPressedOnce: false,
|
||||||
showEscapePrompt: false,
|
showEscapePrompt: false,
|
||||||
shortcutsHelpVisible: false,
|
shortcutsHelpVisible: false,
|
||||||
@@ -112,7 +113,10 @@ describe('StatusDisplay', () => {
|
|||||||
it('prioritizes Ctrl+C prompt over everything else (except system md)', () => {
|
it('prioritizes Ctrl+C prompt over everything else (except system md)', () => {
|
||||||
const uiState = createMockUIState({
|
const uiState = createMockUIState({
|
||||||
ctrlCPressedOnce: true,
|
ctrlCPressedOnce: true,
|
||||||
warningMessage: 'Warning',
|
transientMessage: {
|
||||||
|
text: 'Warning',
|
||||||
|
type: TransientMessageType.Warning,
|
||||||
|
},
|
||||||
activeHooks: [{ name: 'hook', eventName: 'event' }],
|
activeHooks: [{ name: 'hook', eventName: 'event' }],
|
||||||
});
|
});
|
||||||
const { lastFrame } = renderStatusDisplay(
|
const { lastFrame } = renderStatusDisplay(
|
||||||
@@ -124,7 +128,24 @@ describe('StatusDisplay', () => {
|
|||||||
|
|
||||||
it('renders warning message', () => {
|
it('renders warning message', () => {
|
||||||
const uiState = createMockUIState({
|
const uiState = createMockUIState({
|
||||||
warningMessage: 'This is a warning',
|
transientMessage: {
|
||||||
|
text: 'This is a warning',
|
||||||
|
type: TransientMessageType.Warning,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { lastFrame } = renderStatusDisplay(
|
||||||
|
{ hideContextSummary: false },
|
||||||
|
uiState,
|
||||||
|
);
|
||||||
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders hint message', () => {
|
||||||
|
const uiState = createMockUIState({
|
||||||
|
transientMessage: {
|
||||||
|
text: 'This is a hint',
|
||||||
|
type: TransientMessageType.Hint,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const { lastFrame } = renderStatusDisplay(
|
const { lastFrame } = renderStatusDisplay(
|
||||||
{ hideContextSummary: false },
|
{ hideContextSummary: false },
|
||||||
@@ -135,7 +156,10 @@ describe('StatusDisplay', () => {
|
|||||||
|
|
||||||
it('prioritizes warning over Ctrl+D', () => {
|
it('prioritizes warning over Ctrl+D', () => {
|
||||||
const uiState = createMockUIState({
|
const uiState = createMockUIState({
|
||||||
warningMessage: 'Warning',
|
transientMessage: {
|
||||||
|
text: 'Warning',
|
||||||
|
type: TransientMessageType.Warning,
|
||||||
|
},
|
||||||
ctrlDPressedOnce: true,
|
ctrlDPressedOnce: true,
|
||||||
});
|
});
|
||||||
const { lastFrame } = renderStatusDisplay(
|
const { lastFrame } = renderStatusDisplay(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type React from 'react';
|
|||||||
import { Text } from 'ink';
|
import { Text } from 'ink';
|
||||||
import { theme } from '../semantic-colors.js';
|
import { theme } from '../semantic-colors.js';
|
||||||
import { useUIState } from '../contexts/UIStateContext.js';
|
import { useUIState } from '../contexts/UIStateContext.js';
|
||||||
|
import { TransientMessageType } from '../../utils/events.js';
|
||||||
import { useSettings } from '../contexts/SettingsContext.js';
|
import { useSettings } from '../contexts/SettingsContext.js';
|
||||||
import { useConfig } from '../contexts/ConfigContext.js';
|
import { useConfig } from '../contexts/ConfigContext.js';
|
||||||
import { ContextSummaryDisplay } from './ContextSummaryDisplay.js';
|
import { ContextSummaryDisplay } from './ContextSummaryDisplay.js';
|
||||||
@@ -34,8 +35,13 @@ export const StatusDisplay: React.FC<StatusDisplayProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uiState.warningMessage) {
|
if (
|
||||||
return <Text color={theme.status.warning}>{uiState.warningMessage}</Text>;
|
uiState.transientMessage?.type === TransientMessageType.Warning &&
|
||||||
|
uiState.transientMessage.text
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<Text color={theme.status.warning}>{uiState.transientMessage.text}</Text>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uiState.ctrlDPressedOnce) {
|
if (uiState.ctrlDPressedOnce) {
|
||||||
@@ -59,6 +65,15 @@ export const StatusDisplay: React.FC<StatusDisplayProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
uiState.transientMessage?.type === TransientMessageType.Hint &&
|
||||||
|
uiState.transientMessage.text
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<Text color={theme.text.secondary}>{uiState.transientMessage.text}</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (uiState.queueErrorMessage) {
|
if (uiState.queueErrorMessage) {
|
||||||
return <Text color={theme.status.error}>{uiState.queueErrorMessage}</Text>;
|
return <Text color={theme.status.error}>{uiState.queueErrorMessage}</Text>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ exports[`StatusDisplay > renders HookStatusDisplay when hooks are active 1`] = `
|
|||||||
|
|
||||||
exports[`StatusDisplay > renders Queue Error Message 1`] = `"Queue Error"`;
|
exports[`StatusDisplay > renders Queue Error Message 1`] = `"Queue Error"`;
|
||||||
|
|
||||||
|
exports[`StatusDisplay > renders hint message 1`] = `"This is a hint"`;
|
||||||
|
|
||||||
exports[`StatusDisplay > renders system md indicator if env var is set 1`] = `"|⌐■_■|"`;
|
exports[`StatusDisplay > renders system md indicator if env var is set 1`] = `"|⌐■_■|"`;
|
||||||
|
|
||||||
exports[`StatusDisplay > renders warning message 1`] = `"This is a warning"`;
|
exports[`StatusDisplay > renders warning message 1`] = `"This is a warning"`;
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ import type { VimAction } from './vim-buffer-actions.js';
|
|||||||
import { handleVimAction } from './vim-buffer-actions.js';
|
import { handleVimAction } from './vim-buffer-actions.js';
|
||||||
import { LRU_BUFFER_PERF_CACHE_LIMIT } from '../../constants.js';
|
import { LRU_BUFFER_PERF_CACHE_LIMIT } from '../../constants.js';
|
||||||
|
|
||||||
const LARGE_PASTE_LINE_THRESHOLD = 5;
|
export const LARGE_PASTE_LINE_THRESHOLD = 5;
|
||||||
const LARGE_PASTE_CHAR_THRESHOLD = 500;
|
export const LARGE_PASTE_CHAR_THRESHOLD = 500;
|
||||||
|
|
||||||
// Regex to match paste placeholders like [Pasted Text: 6 lines] or [Pasted Text: 501 chars #2]
|
// Regex to match paste placeholders like [Pasted Text: 6 lines] or [Pasted Text: 501 chars #2]
|
||||||
export const PASTED_TEXT_PLACEHOLDER_REGEX =
|
export const PASTED_TEXT_PLACEHOLDER_REGEX =
|
||||||
@@ -986,11 +986,15 @@ export function getTransformUnderCursor(
|
|||||||
row: number,
|
row: number,
|
||||||
col: number,
|
col: number,
|
||||||
spansByLine: Transformation[][],
|
spansByLine: Transformation[][],
|
||||||
|
options: { includeEdge?: boolean } = {},
|
||||||
): Transformation | null {
|
): Transformation | null {
|
||||||
const spans = spansByLine[row];
|
const spans = spansByLine[row];
|
||||||
if (!spans || spans.length === 0) return null;
|
if (!spans || spans.length === 0) return null;
|
||||||
for (const span of spans) {
|
for (const span of spans) {
|
||||||
if (col >= span.logStart && col < span.logEnd) {
|
if (
|
||||||
|
col >= span.logStart &&
|
||||||
|
(options.includeEdge ? col <= span.logEnd : col < span.logEnd)
|
||||||
|
) {
|
||||||
return span;
|
return span;
|
||||||
}
|
}
|
||||||
if (col < span.logStart) break;
|
if (col < span.logStart) break;
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ export interface UIActions {
|
|||||||
handleApiKeyCancel: () => void;
|
handleApiKeyCancel: () => void;
|
||||||
setBannerVisible: (visible: boolean) => void;
|
setBannerVisible: (visible: boolean) => void;
|
||||||
setShortcutsHelpVisible: (visible: boolean) => void;
|
setShortcutsHelpVisible: (visible: boolean) => void;
|
||||||
handleWarning: (message: string) => void;
|
|
||||||
setEmbeddedShellFocused: (value: boolean) => void;
|
setEmbeddedShellFocused: (value: boolean) => void;
|
||||||
dismissBackgroundShell: (pid: number) => void;
|
dismissBackgroundShell: (pid: number) => void;
|
||||||
setActiveBackgroundShellPid: (pid: number) => void;
|
setActiveBackgroundShellPid: (pid: number) => void;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import type {
|
|||||||
ValidationIntent,
|
ValidationIntent,
|
||||||
AgentDefinition,
|
AgentDefinition,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
|
import { type TransientMessageType } from '../../utils/events.js';
|
||||||
import type { DOMElement } from 'ink';
|
import type { DOMElement } from 'ink';
|
||||||
import type { SessionStatsState } from '../contexts/SessionContext.js';
|
import type { SessionStatsState } from '../contexts/SessionContext.js';
|
||||||
import type { ExtensionUpdateState } from '../state/extensions.js';
|
import type { ExtensionUpdateState } from '../state/extensions.js';
|
||||||
@@ -152,7 +153,6 @@ export interface UIState {
|
|||||||
showDebugProfiler: boolean;
|
showDebugProfiler: boolean;
|
||||||
showFullTodos: boolean;
|
showFullTodos: boolean;
|
||||||
copyModeEnabled: boolean;
|
copyModeEnabled: boolean;
|
||||||
warningMessage: string | null;
|
|
||||||
bannerData: {
|
bannerData: {
|
||||||
defaultText: string;
|
defaultText: string;
|
||||||
warningText: string;
|
warningText: string;
|
||||||
@@ -167,6 +167,10 @@ export interface UIState {
|
|||||||
isBackgroundShellListOpen: boolean;
|
isBackgroundShellListOpen: boolean;
|
||||||
adminSettingsChanged: boolean;
|
adminSettingsChanged: boolean;
|
||||||
newAgents: AgentDefinition[] | null;
|
newAgents: AgentDefinition[] | null;
|
||||||
|
transientMessage: {
|
||||||
|
text: string;
|
||||||
|
type: TransientMessageType;
|
||||||
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UIStateContext = createContext<UIState | null>(null);
|
export const UIStateContext = createContext<UIState | null>(null);
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A hook to manage a state value that automatically resets to null after a duration.
|
||||||
|
* Useful for transient UI messages, hints, or warnings.
|
||||||
|
*/
|
||||||
|
export function useTimedMessage<T>(durationMs: number) {
|
||||||
|
const [message, setMessage] = useState<T | null>(null);
|
||||||
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
const showMessage = useCallback(
|
||||||
|
(msg: T) => {
|
||||||
|
setMessage(msg);
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
setMessage(null);
|
||||||
|
}, durationMs);
|
||||||
|
},
|
||||||
|
[durationMs],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return [message, showMessage] as const;
|
||||||
|
}
|
||||||
@@ -6,12 +6,23 @@
|
|||||||
|
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
|
|
||||||
|
export enum TransientMessageType {
|
||||||
|
Warning = 'warning',
|
||||||
|
Hint = 'hint',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransientMessagePayload {
|
||||||
|
message: string;
|
||||||
|
type: TransientMessageType;
|
||||||
|
}
|
||||||
|
|
||||||
export enum AppEvent {
|
export enum AppEvent {
|
||||||
OpenDebugConsole = 'open-debug-console',
|
OpenDebugConsole = 'open-debug-console',
|
||||||
Flicker = 'flicker',
|
Flicker = 'flicker',
|
||||||
SelectionWarning = 'selection-warning',
|
SelectionWarning = 'selection-warning',
|
||||||
PasteTimeout = 'paste-timeout',
|
PasteTimeout = 'paste-timeout',
|
||||||
TerminalBackground = 'terminal-background',
|
TerminalBackground = 'terminal-background',
|
||||||
|
TransientMessage = 'transient-message',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppEvents {
|
export interface AppEvents {
|
||||||
@@ -20,6 +31,7 @@ export interface AppEvents {
|
|||||||
[AppEvent.SelectionWarning]: never[];
|
[AppEvent.SelectionWarning]: never[];
|
||||||
[AppEvent.PasteTimeout]: never[];
|
[AppEvent.PasteTimeout]: never[];
|
||||||
[AppEvent.TerminalBackground]: [string];
|
[AppEvent.TerminalBackground]: [string];
|
||||||
|
[AppEvent.TransientMessage]: [TransientMessagePayload];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const appEvents = new EventEmitter<AppEvents>();
|
export const appEvents = new EventEmitter<AppEvents>();
|
||||||
|
|||||||
Reference in New Issue
Block a user