mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 16:57:46 +00:00
feature(commands) - Refactor Slash Command + Vision For the Future (#3175)
This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../colors.js';
|
||||
import { SlashCommand } from '../hooks/slashCommandProcessor.js';
|
||||
import { SlashCommand } from '../commands/types.js';
|
||||
|
||||
interface Help {
|
||||
commands: SlashCommand[];
|
||||
@@ -67,13 +67,25 @@ export const Help: React.FC<Help> = ({ commands }) => (
|
||||
{commands
|
||||
.filter((command) => command.description)
|
||||
.map((command: SlashCommand) => (
|
||||
<Text key={command.name} color={Colors.Foreground}>
|
||||
<Text bold color={Colors.AccentPurple}>
|
||||
{' '}
|
||||
/{command.name}
|
||||
<Box key={command.name} flexDirection="column">
|
||||
<Text color={Colors.Foreground}>
|
||||
<Text bold color={Colors.AccentPurple}>
|
||||
{' '}
|
||||
/{command.name}
|
||||
</Text>
|
||||
{command.description && ' - ' + command.description}
|
||||
</Text>
|
||||
{command.description && ' - ' + command.description}
|
||||
</Text>
|
||||
{command.subCommands &&
|
||||
command.subCommands.map((subCommand) => (
|
||||
<Text key={subCommand.name} color={Colors.Foreground}>
|
||||
<Text> </Text>
|
||||
<Text bold color={Colors.AccentPurple}>
|
||||
{subCommand.name}
|
||||
</Text>
|
||||
{subCommand.description && ' - ' + subCommand.description}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
<Text color={Colors.Foreground}>
|
||||
<Text bold color={Colors.AccentPurple}>
|
||||
|
||||
@@ -8,10 +8,12 @@ import { render } from 'ink-testing-library';
|
||||
import { InputPrompt, InputPromptProps } from './InputPrompt.js';
|
||||
import type { TextBuffer } from './shared/text-buffer.js';
|
||||
import { Config } from '@google/gemini-cli-core';
|
||||
import { CommandContext, SlashCommand } from '../commands/types.js';
|
||||
import { vi } from 'vitest';
|
||||
import { useShellHistory } from '../hooks/useShellHistory.js';
|
||||
import { useCompletion } from '../hooks/useCompletion.js';
|
||||
import { useInputHistory } from '../hooks/useInputHistory.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
|
||||
vi.mock('../hooks/useShellHistory.js');
|
||||
vi.mock('../hooks/useCompletion.js');
|
||||
@@ -21,12 +23,38 @@ type MockedUseShellHistory = ReturnType<typeof useShellHistory>;
|
||||
type MockedUseCompletion = ReturnType<typeof useCompletion>;
|
||||
type MockedUseInputHistory = ReturnType<typeof useInputHistory>;
|
||||
|
||||
const mockSlashCommands: SlashCommand[] = [
|
||||
{ name: 'clear', description: 'Clear screen', action: vi.fn() },
|
||||
{
|
||||
name: 'memory',
|
||||
description: 'Manage memory',
|
||||
subCommands: [
|
||||
{ name: 'show', description: 'Show memory', action: vi.fn() },
|
||||
{ name: 'add', description: 'Add to memory', action: vi.fn() },
|
||||
{ name: 'refresh', description: 'Refresh memory', action: vi.fn() },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'chat',
|
||||
description: 'Manage chats',
|
||||
subCommands: [
|
||||
{
|
||||
name: 'resume',
|
||||
description: 'Resume a chat',
|
||||
action: vi.fn(),
|
||||
completion: async () => ['fix-foo', 'fix-bar'],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
describe('InputPrompt', () => {
|
||||
let props: InputPromptProps;
|
||||
let mockShellHistory: MockedUseShellHistory;
|
||||
let mockCompletion: MockedUseCompletion;
|
||||
let mockInputHistory: MockedUseInputHistory;
|
||||
let mockBuffer: TextBuffer;
|
||||
let mockCommandContext: CommandContext;
|
||||
|
||||
const mockedUseShellHistory = vi.mocked(useShellHistory);
|
||||
const mockedUseCompletion = vi.mocked(useCompletion);
|
||||
@@ -35,6 +63,8 @@ describe('InputPrompt', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
mockCommandContext = createMockCommandContext();
|
||||
|
||||
mockBuffer = {
|
||||
text: '',
|
||||
cursor: [0, 0],
|
||||
@@ -99,12 +129,15 @@ describe('InputPrompt', () => {
|
||||
getTargetDir: () => '/test/project/src',
|
||||
} as unknown as Config,
|
||||
slashCommands: [],
|
||||
commandContext: mockCommandContext,
|
||||
shellModeActive: false,
|
||||
setShellModeActive: vi.fn(),
|
||||
inputWidth: 80,
|
||||
suggestionsWidth: 80,
|
||||
focus: true,
|
||||
};
|
||||
|
||||
props.slashCommands = mockSlashCommands;
|
||||
});
|
||||
|
||||
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
@@ -184,4 +217,194 @@ describe('InputPrompt', () => {
|
||||
expect(props.onSubmit).toHaveBeenCalledWith('some text');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should complete a partial parent command and add a space', async () => {
|
||||
// SCENARIO: /mem -> Tab
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [{ label: 'memory', value: 'memory', description: '...' }],
|
||||
activeSuggestionIndex: 0,
|
||||
});
|
||||
props.buffer.setText('/mem');
|
||||
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('\t'); // Press Tab
|
||||
await wait();
|
||||
|
||||
expect(props.buffer.setText).toHaveBeenCalledWith('/memory ');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should append a sub-command when the parent command is already complete with a space', async () => {
|
||||
// SCENARIO: /memory -> Tab (to accept 'add')
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [
|
||||
{ label: 'show', value: 'show' },
|
||||
{ label: 'add', value: 'add' },
|
||||
],
|
||||
activeSuggestionIndex: 1, // 'add' is highlighted
|
||||
});
|
||||
props.buffer.setText('/memory ');
|
||||
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('\t'); // Press Tab
|
||||
await wait();
|
||||
|
||||
expect(props.buffer.setText).toHaveBeenCalledWith('/memory add ');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should handle the "backspace" edge case correctly', async () => {
|
||||
// SCENARIO: /memory -> Backspace -> /memory -> Tab (to accept 'show')
|
||||
// This is the critical bug we fixed.
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [
|
||||
{ label: 'show', value: 'show' },
|
||||
{ label: 'add', value: 'add' },
|
||||
],
|
||||
activeSuggestionIndex: 0, // 'show' is highlighted
|
||||
});
|
||||
// The user has backspaced, so the query is now just '/memory'
|
||||
props.buffer.setText('/memory');
|
||||
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('\t'); // Press Tab
|
||||
await wait();
|
||||
|
||||
// It should NOT become '/show '. It should correctly become '/memory show '.
|
||||
expect(props.buffer.setText).toHaveBeenCalledWith('/memory show ');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should complete a partial argument for a command', async () => {
|
||||
// SCENARIO: /chat resume fi- -> Tab
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [{ label: 'fix-foo', value: 'fix-foo' }],
|
||||
activeSuggestionIndex: 0,
|
||||
});
|
||||
props.buffer.setText('/chat resume fi-');
|
||||
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('\t'); // Press Tab
|
||||
await wait();
|
||||
|
||||
expect(props.buffer.setText).toHaveBeenCalledWith('/chat resume fix-foo ');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should autocomplete on Enter when suggestions are active, without submitting', async () => {
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [{ label: 'memory', value: 'memory' }],
|
||||
activeSuggestionIndex: 0,
|
||||
});
|
||||
props.buffer.setText('/mem');
|
||||
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('\r');
|
||||
await wait();
|
||||
|
||||
// The app should autocomplete the text, NOT submit.
|
||||
expect(props.buffer.setText).toHaveBeenCalledWith('/memory ');
|
||||
|
||||
expect(props.onSubmit).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should complete a command based on its altName', async () => {
|
||||
// Add a command with an altName to our mock for this test
|
||||
props.slashCommands.push({
|
||||
name: 'help',
|
||||
altName: '?',
|
||||
description: '...',
|
||||
});
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [{ label: 'help', value: 'help' }],
|
||||
activeSuggestionIndex: 0,
|
||||
});
|
||||
props.buffer.setText('/?');
|
||||
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('\t'); // Press Tab
|
||||
await wait();
|
||||
|
||||
expect(props.buffer.setText).toHaveBeenCalledWith('/help ');
|
||||
unmount();
|
||||
});
|
||||
|
||||
// ADD this test for defensive coverage
|
||||
|
||||
it('should not submit on Enter when the buffer is empty or only contains whitespace', async () => {
|
||||
props.buffer.setText(' '); // Set buffer to whitespace
|
||||
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('\r'); // Press Enter
|
||||
await wait();
|
||||
|
||||
expect(props.onSubmit).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should submit directly on Enter when a complete leaf command is typed', async () => {
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: false,
|
||||
});
|
||||
props.buffer.setText('/clear');
|
||||
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('\r');
|
||||
await wait();
|
||||
|
||||
expect(props.onSubmit).toHaveBeenCalledWith('/clear');
|
||||
expect(props.buffer.setText).not.toHaveBeenCalledWith('/clear ');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should autocomplete an @-path on Enter without submitting', async () => {
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [{ label: 'index.ts', value: 'index.ts' }],
|
||||
activeSuggestionIndex: 0,
|
||||
});
|
||||
props.buffer.setText('@src/components/');
|
||||
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('\r');
|
||||
await wait();
|
||||
|
||||
expect(props.buffer.replaceRangeByOffset).toHaveBeenCalled();
|
||||
expect(props.onSubmit).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,12 +13,11 @@ import { TextBuffer } from './shared/text-buffer.js';
|
||||
import { cpSlice, cpLen } from '../utils/textUtils.js';
|
||||
import chalk from 'chalk';
|
||||
import stringWidth from 'string-width';
|
||||
import process from 'node:process';
|
||||
import { useShellHistory } from '../hooks/useShellHistory.js';
|
||||
import { useCompletion } from '../hooks/useCompletion.js';
|
||||
import { useKeypress, Key } from '../hooks/useKeypress.js';
|
||||
import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js';
|
||||
import { SlashCommand } from '../hooks/slashCommandProcessor.js';
|
||||
import { CommandContext, SlashCommand } from '../commands/types.js';
|
||||
import { Config } from '@google/gemini-cli-core';
|
||||
|
||||
export interface InputPromptProps {
|
||||
@@ -26,8 +25,9 @@ export interface InputPromptProps {
|
||||
onSubmit: (value: string) => void;
|
||||
userMessages: readonly string[];
|
||||
onClearScreen: () => void;
|
||||
config: Config; // Added config for useCompletion
|
||||
slashCommands: SlashCommand[]; // Added slashCommands for useCompletion
|
||||
config: Config;
|
||||
slashCommands: SlashCommand[];
|
||||
commandContext: CommandContext;
|
||||
placeholder?: string;
|
||||
focus?: boolean;
|
||||
inputWidth: number;
|
||||
@@ -43,6 +43,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
onClearScreen,
|
||||
config,
|
||||
slashCommands,
|
||||
commandContext,
|
||||
placeholder = ' Type your message or @path/to/file',
|
||||
focus = true,
|
||||
inputWidth,
|
||||
@@ -57,6 +58,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
config.getTargetDir(),
|
||||
isAtCommand(buffer.text) || isSlashCommand(buffer.text),
|
||||
slashCommands,
|
||||
commandContext,
|
||||
config,
|
||||
);
|
||||
|
||||
@@ -116,28 +118,46 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
const suggestion = completionSuggestions[indexToUse].value;
|
||||
|
||||
if (query.trimStart().startsWith('/')) {
|
||||
const parts = query.trimStart().substring(1).split(' ');
|
||||
const commandName = parts[0];
|
||||
const slashIndex = query.indexOf('/');
|
||||
const base = query.substring(0, slashIndex + 1);
|
||||
const hasTrailingSpace = query.endsWith(' ');
|
||||
const parts = query
|
||||
.trimStart()
|
||||
.substring(1)
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
|
||||
const command = slashCommands.find((cmd) => cmd.name === commandName);
|
||||
// Make sure completion isn't the original command when command.completion hasn't happened yet.
|
||||
if (command && command.completion && suggestion !== commandName) {
|
||||
const newValue = `${base}${commandName} ${suggestion}`;
|
||||
if (newValue === query) {
|
||||
handleSubmitAndClear(newValue);
|
||||
} else {
|
||||
buffer.setText(newValue);
|
||||
}
|
||||
} else {
|
||||
const newValue = base + suggestion;
|
||||
if (newValue === query) {
|
||||
handleSubmitAndClear(newValue);
|
||||
} else {
|
||||
buffer.setText(newValue);
|
||||
let isParentPath = false;
|
||||
// If there's no trailing space, we need to check if the current query
|
||||
// is already a complete path to a parent command.
|
||||
if (!hasTrailingSpace) {
|
||||
let currentLevel: SlashCommand[] | undefined = slashCommands;
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
const found: SlashCommand | undefined = currentLevel?.find(
|
||||
(cmd) => cmd.name === part || cmd.altName === part,
|
||||
);
|
||||
|
||||
if (found) {
|
||||
if (i === parts.length - 1 && found.subCommands) {
|
||||
isParentPath = true;
|
||||
}
|
||||
currentLevel = found.subCommands;
|
||||
} else {
|
||||
// Path is invalid, so it can't be a parent path.
|
||||
currentLevel = undefined;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine the base path of the command.
|
||||
// - If there's a trailing space, the whole command is the base.
|
||||
// - If it's a known parent path, the whole command is the base.
|
||||
// - Otherwise, the base is everything EXCEPT the last partial part.
|
||||
const basePath =
|
||||
hasTrailingSpace || isParentPath ? parts : parts.slice(0, -1);
|
||||
const newValue = `/${[...basePath, suggestion].join(' ')} `;
|
||||
|
||||
buffer.setText(newValue);
|
||||
} else {
|
||||
const atIndex = query.lastIndexOf('@');
|
||||
if (atIndex === -1) return;
|
||||
@@ -155,13 +175,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
}
|
||||
resetCompletionState();
|
||||
},
|
||||
[
|
||||
resetCompletionState,
|
||||
handleSubmitAndClear,
|
||||
buffer,
|
||||
completionSuggestions,
|
||||
slashCommands,
|
||||
],
|
||||
[resetCompletionState, buffer, completionSuggestions, slashCommands],
|
||||
);
|
||||
|
||||
const handleInput = useCallback(
|
||||
@@ -169,12 +183,32 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
if (!focus) {
|
||||
return;
|
||||
}
|
||||
const query = buffer.text;
|
||||
|
||||
if (key.sequence === '!' && query === '' && !completion.showSuggestions) {
|
||||
if (
|
||||
key.sequence === '!' &&
|
||||
buffer.text === '' &&
|
||||
!completion.showSuggestions
|
||||
) {
|
||||
setShellModeActive(!shellModeActive);
|
||||
buffer.setText(''); // Clear the '!' from input
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === 'escape') {
|
||||
if (shellModeActive) {
|
||||
setShellModeActive(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (completion.showSuggestions) {
|
||||
completion.resetCompletionState();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (key.ctrl && key.name === 'l') {
|
||||
onClearScreen();
|
||||
return;
|
||||
}
|
||||
|
||||
if (completion.showSuggestions) {
|
||||
@@ -186,11 +220,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
completion.navigateDown();
|
||||
return;
|
||||
}
|
||||
if (key.name === 'tab') {
|
||||
|
||||
if (key.name === 'tab' || (key.name === 'return' && !key.ctrl)) {
|
||||
if (completion.suggestions.length > 0) {
|
||||
const targetIndex =
|
||||
completion.activeSuggestionIndex === -1
|
||||
? 0
|
||||
? 0 // Default to the first if none is active
|
||||
: completion.activeSuggestionIndex;
|
||||
if (targetIndex < completion.suggestions.length) {
|
||||
handleAutocomplete(targetIndex);
|
||||
@@ -198,67 +233,72 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key.name === 'return') {
|
||||
if (completion.activeSuggestionIndex >= 0) {
|
||||
handleAutocomplete(completion.activeSuggestionIndex);
|
||||
} else if (query.trim()) {
|
||||
handleSubmitAndClear(query);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Keybindings when suggestions are not shown
|
||||
if (key.ctrl && key.name === 'l') {
|
||||
onClearScreen();
|
||||
return;
|
||||
}
|
||||
if (key.ctrl && key.name === 'p') {
|
||||
inputHistory.navigateUp();
|
||||
return;
|
||||
}
|
||||
if (key.ctrl && key.name === 'n') {
|
||||
inputHistory.navigateDown();
|
||||
return;
|
||||
}
|
||||
if (key.name === 'escape') {
|
||||
if (shellModeActive) {
|
||||
setShellModeActive(false);
|
||||
if (!shellModeActive) {
|
||||
if (key.ctrl && key.name === 'p') {
|
||||
inputHistory.navigateUp();
|
||||
return;
|
||||
}
|
||||
completion.resetCompletionState();
|
||||
if (key.ctrl && key.name === 'n') {
|
||||
inputHistory.navigateDown();
|
||||
return;
|
||||
}
|
||||
// Handle arrow-up/down for history on single-line or at edges
|
||||
if (
|
||||
key.name === 'up' &&
|
||||
(buffer.allVisualLines.length === 1 ||
|
||||
(buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0))
|
||||
) {
|
||||
inputHistory.navigateUp();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
key.name === 'down' &&
|
||||
(buffer.allVisualLines.length === 1 ||
|
||||
buffer.visualCursor[0] === buffer.allVisualLines.length - 1)
|
||||
) {
|
||||
inputHistory.navigateDown();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Shell History Navigation
|
||||
if (key.name === 'up') {
|
||||
const prevCommand = shellHistory.getPreviousCommand();
|
||||
if (prevCommand !== null) buffer.setText(prevCommand);
|
||||
return;
|
||||
}
|
||||
if (key.name === 'down') {
|
||||
const nextCommand = shellHistory.getNextCommand();
|
||||
if (nextCommand !== null) buffer.setText(nextCommand);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (key.name === 'return' && !key.ctrl && !key.meta && !key.paste) {
|
||||
if (buffer.text.trim()) {
|
||||
handleSubmitAndClear(buffer.text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+A (Home)
|
||||
if (key.ctrl && key.name === 'a') {
|
||||
buffer.move('home');
|
||||
buffer.moveToOffset(0);
|
||||
return;
|
||||
}
|
||||
// Ctrl+E (End)
|
||||
if (key.ctrl && key.name === 'e') {
|
||||
buffer.move('end');
|
||||
buffer.moveToOffset(cpLen(buffer.text));
|
||||
return;
|
||||
}
|
||||
// Ctrl+L (Clear Screen)
|
||||
if (key.ctrl && key.name === 'l') {
|
||||
onClearScreen();
|
||||
return;
|
||||
}
|
||||
// Ctrl+P (History Up)
|
||||
if (key.ctrl && key.name === 'p' && !completion.showSuggestions) {
|
||||
inputHistory.navigateUp();
|
||||
return;
|
||||
}
|
||||
// Ctrl+N (History Down)
|
||||
if (key.ctrl && key.name === 'n' && !completion.showSuggestions) {
|
||||
inputHistory.navigateDown();
|
||||
// Newline insertion
|
||||
if (key.name === 'return' && (key.ctrl || key.meta || key.paste)) {
|
||||
buffer.newline();
|
||||
return;
|
||||
}
|
||||
|
||||
// Core text editing from MultilineTextEditor's useInput
|
||||
// Ctrl+A (Home) / Ctrl+E (End)
|
||||
if (key.ctrl && key.name === 'a') {
|
||||
buffer.move('home');
|
||||
return;
|
||||
}
|
||||
if (key.ctrl && key.name === 'e') {
|
||||
buffer.move('end');
|
||||
return;
|
||||
}
|
||||
|
||||
// Kill line commands
|
||||
if (key.ctrl && key.name === 'k') {
|
||||
buffer.killLineRight();
|
||||
return;
|
||||
@@ -267,97 +307,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
buffer.killLineLeft();
|
||||
return;
|
||||
}
|
||||
const isCtrlX =
|
||||
(key.ctrl && (key.name === 'x' || key.sequence === '\x18')) ||
|
||||
key.sequence === '\x18';
|
||||
const isCtrlEFromEditor =
|
||||
(key.ctrl && (key.name === 'e' || key.sequence === '\x05')) ||
|
||||
key.sequence === '\x05' ||
|
||||
(!key.ctrl &&
|
||||
key.name === 'e' &&
|
||||
key.sequence.length === 1 &&
|
||||
key.sequence.charCodeAt(0) === 5);
|
||||
|
||||
if (isCtrlX || isCtrlEFromEditor) {
|
||||
if (isCtrlEFromEditor && !(key.ctrl && key.name === 'e')) {
|
||||
// Avoid double handling Ctrl+E
|
||||
buffer.openInExternalEditor();
|
||||
return;
|
||||
}
|
||||
if (isCtrlX) {
|
||||
buffer.openInExternalEditor();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
process.env['TEXTBUFFER_DEBUG'] === '1' ||
|
||||
process.env['TEXTBUFFER_DEBUG'] === 'true'
|
||||
) {
|
||||
console.log('[InputPromptCombined] event', { key });
|
||||
}
|
||||
|
||||
// Ctrl+Enter for newline, Enter for submit
|
||||
if (key.name === 'return') {
|
||||
const [row, col] = buffer.cursor;
|
||||
const line = buffer.lines[row];
|
||||
const charBefore = col > 0 ? cpSlice(line, col - 1, col) : '';
|
||||
if (key.ctrl || key.meta || charBefore === '\\' || key.paste) {
|
||||
// Ctrl+Enter or escaped newline
|
||||
if (charBefore === '\\') {
|
||||
buffer.backspace();
|
||||
}
|
||||
buffer.newline();
|
||||
} else {
|
||||
// Enter for submit
|
||||
if (query.trim()) {
|
||||
handleSubmitAndClear(query);
|
||||
}
|
||||
}
|
||||
// External editor
|
||||
const isCtrlX = key.ctrl && (key.name === 'x' || key.sequence === '\x18');
|
||||
if (isCtrlX) {
|
||||
buffer.openInExternalEditor();
|
||||
return;
|
||||
}
|
||||
|
||||
// Standard arrow navigation within the buffer
|
||||
if (key.name === 'up' && !completion.showSuggestions) {
|
||||
if (shellModeActive) {
|
||||
const prevCommand = shellHistory.getPreviousCommand();
|
||||
if (prevCommand !== null) {
|
||||
buffer.setText(prevCommand);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
(buffer.allVisualLines.length === 1 || // Always navigate for single line
|
||||
(buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0)) &&
|
||||
inputHistory.navigateUp
|
||||
) {
|
||||
inputHistory.navigateUp();
|
||||
} else {
|
||||
buffer.move('up');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key.name === 'down' && !completion.showSuggestions) {
|
||||
if (shellModeActive) {
|
||||
const nextCommand = shellHistory.getNextCommand();
|
||||
if (nextCommand !== null) {
|
||||
buffer.setText(nextCommand);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
(buffer.allVisualLines.length === 1 || // Always navigate for single line
|
||||
buffer.visualCursor[0] === buffer.allVisualLines.length - 1) &&
|
||||
inputHistory.navigateDown
|
||||
) {
|
||||
inputHistory.navigateDown();
|
||||
} else {
|
||||
buffer.move('down');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback to buffer's default input handling
|
||||
// Fallback to the text buffer's default input handling for all other keys
|
||||
buffer.handleInput(key);
|
||||
},
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user