summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui/components/InputPrompt.tsx
diff options
context:
space:
mode:
author官余棚 <[email protected]>2025-08-21 16:04:04 +0800
committerGitHub <[email protected]>2025-08-21 08:04:04 +0000
commit589f5e6823eca456d9c93cadea664e5c19eebb90 (patch)
tree031edeb6f5bdf9cac2da206dde9584e04ad186b5 /packages/cli/src/ui/components/InputPrompt.tsx
parentba5309c4050efde8b0be0d9dd726e5c5f1a4c4c6 (diff)
feat(cli): prompt completion (#4691)
Co-authored-by: Jacob Richman <[email protected]>
Diffstat (limited to 'packages/cli/src/ui/components/InputPrompt.tsx')
-rw-r--r--packages/cli/src/ui/components/InputPrompt.tsx249
1 files changed, 216 insertions, 33 deletions
diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx
index 02c25bd8..01666c66 100644
--- a/packages/cli/src/ui/components/InputPrompt.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.tsx
@@ -10,7 +10,7 @@ import { theme } from '../semantic-colors.js';
import { SuggestionsDisplay } from './SuggestionsDisplay.js';
import { useInputHistory } from '../hooks/useInputHistory.js';
import { TextBuffer, logicalPosToOffset } from './shared/text-buffer.js';
-import { cpSlice, cpLen } from '../utils/textUtils.js';
+import { cpSlice, cpLen, toCodePoints } from '../utils/textUtils.js';
import chalk from 'chalk';
import stringWidth from 'string-width';
import { useShellHistory } from '../hooks/useShellHistory.js';
@@ -403,6 +403,16 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
}
+ // Handle Tab key for ghost text acceptance
+ if (
+ key.name === 'tab' &&
+ !completion.showSuggestions &&
+ completion.promptCompletion.text
+ ) {
+ completion.promptCompletion.accept();
+ return;
+ }
+
if (!shellModeActive) {
if (keyMatchers[Command.HISTORY_UP](key)) {
inputHistory.navigateUp();
@@ -507,6 +517,17 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
// Fall back to the text buffer's default input handling for all other keys
buffer.handleInput(key);
+
+ // Clear ghost text when user types regular characters (not navigation/control keys)
+ if (
+ completion.promptCompletion.text &&
+ key.sequence &&
+ key.sequence.length === 1 &&
+ !key.ctrl &&
+ !key.meta
+ ) {
+ completion.promptCompletion.clear();
+ }
},
[
focus,
@@ -540,6 +561,119 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
buffer.visualCursor;
const scrollVisualRow = buffer.visualScrollRow;
+ const getGhostTextLines = useCallback(() => {
+ if (
+ !completion.promptCompletion.text ||
+ !buffer.text ||
+ !completion.promptCompletion.text.startsWith(buffer.text)
+ ) {
+ return { inlineGhost: '', additionalLines: [] };
+ }
+
+ const ghostSuffix = completion.promptCompletion.text.slice(
+ buffer.text.length,
+ );
+ if (!ghostSuffix) {
+ return { inlineGhost: '', additionalLines: [] };
+ }
+
+ const currentLogicalLine = buffer.lines[buffer.cursor[0]] || '';
+ const cursorCol = buffer.cursor[1];
+
+ const textBeforeCursor = cpSlice(currentLogicalLine, 0, cursorCol);
+ const usedWidth = stringWidth(textBeforeCursor);
+ const remainingWidth = Math.max(0, inputWidth - usedWidth);
+
+ const ghostTextLinesRaw = ghostSuffix.split('\n');
+ const firstLineRaw = ghostTextLinesRaw.shift() || '';
+
+ let inlineGhost = '';
+ let remainingFirstLine = '';
+
+ if (stringWidth(firstLineRaw) <= remainingWidth) {
+ inlineGhost = firstLineRaw;
+ } else {
+ const words = firstLineRaw.split(' ');
+ let currentLine = '';
+ let wordIdx = 0;
+ for (const word of words) {
+ const prospectiveLine = currentLine ? `${currentLine} ${word}` : word;
+ if (stringWidth(prospectiveLine) > remainingWidth) {
+ break;
+ }
+ currentLine = prospectiveLine;
+ wordIdx++;
+ }
+ inlineGhost = currentLine;
+ if (words.length > wordIdx) {
+ remainingFirstLine = words.slice(wordIdx).join(' ');
+ }
+ }
+
+ const linesToWrap = [];
+ if (remainingFirstLine) {
+ linesToWrap.push(remainingFirstLine);
+ }
+ linesToWrap.push(...ghostTextLinesRaw);
+ const remainingGhostText = linesToWrap.join('\n');
+
+ const additionalLines: string[] = [];
+ if (remainingGhostText) {
+ const textLines = remainingGhostText.split('\n');
+ for (const textLine of textLines) {
+ const words = textLine.split(' ');
+ let currentLine = '';
+
+ for (const word of words) {
+ const prospectiveLine = currentLine ? `${currentLine} ${word}` : word;
+ const prospectiveWidth = stringWidth(prospectiveLine);
+
+ if (prospectiveWidth > inputWidth) {
+ if (currentLine) {
+ additionalLines.push(currentLine);
+ }
+
+ let wordToProcess = word;
+ while (stringWidth(wordToProcess) > inputWidth) {
+ let part = '';
+ const wordCP = toCodePoints(wordToProcess);
+ let partWidth = 0;
+ let splitIndex = 0;
+ for (let i = 0; i < wordCP.length; i++) {
+ const char = wordCP[i];
+ const charWidth = stringWidth(char);
+ if (partWidth + charWidth > inputWidth) {
+ break;
+ }
+ part += char;
+ partWidth += charWidth;
+ splitIndex = i + 1;
+ }
+ additionalLines.push(part);
+ wordToProcess = cpSlice(wordToProcess, splitIndex);
+ }
+ currentLine = wordToProcess;
+ } else {
+ currentLine = prospectiveLine;
+ }
+ }
+ if (currentLine) {
+ additionalLines.push(currentLine);
+ }
+ }
+ }
+
+ return { inlineGhost, additionalLines };
+ }, [
+ completion.promptCompletion.text,
+ buffer.text,
+ buffer.lines,
+ buffer.cursor,
+ inputWidth,
+ ]);
+
+ const { inlineGhost, additionalLines } = getGhostTextLines();
+
return (
<>
<Box
@@ -573,42 +707,91 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
<Text color={theme.text.secondary}>{placeholder}</Text>
)
) : (
- linesToRender.map((lineText, visualIdxInRenderedSet) => {
- const cursorVisualRow = cursorVisualRowAbsolute - scrollVisualRow;
- let display = cpSlice(lineText, 0, inputWidth);
- const currentVisualWidth = stringWidth(display);
- if (currentVisualWidth < inputWidth) {
- display = display + ' '.repeat(inputWidth - currentVisualWidth);
- }
+ linesToRender
+ .map((lineText, visualIdxInRenderedSet) => {
+ const cursorVisualRow =
+ cursorVisualRowAbsolute - scrollVisualRow;
+ let display = cpSlice(lineText, 0, inputWidth);
- if (focus && visualIdxInRenderedSet === cursorVisualRow) {
- const relativeVisualColForHighlight = cursorVisualColAbsolute;
+ const isOnCursorLine =
+ focus && visualIdxInRenderedSet === cursorVisualRow;
+ const currentLineGhost = isOnCursorLine ? inlineGhost : '';
- if (relativeVisualColForHighlight >= 0) {
- if (relativeVisualColForHighlight < cpLen(display)) {
- const charToHighlight =
- cpSlice(
- display,
- relativeVisualColForHighlight,
- relativeVisualColForHighlight + 1,
- ) || ' ';
- const highlighted = chalk.inverse(charToHighlight);
- display =
- cpSlice(display, 0, relativeVisualColForHighlight) +
- highlighted +
- cpSlice(display, relativeVisualColForHighlight + 1);
- } else if (
- relativeVisualColForHighlight === cpLen(display) &&
- cpLen(display) === inputWidth
- ) {
- display = display + chalk.inverse(' ');
+ const ghostWidth = stringWidth(currentLineGhost);
+
+ if (focus && visualIdxInRenderedSet === cursorVisualRow) {
+ const relativeVisualColForHighlight = cursorVisualColAbsolute;
+
+ if (relativeVisualColForHighlight >= 0) {
+ if (relativeVisualColForHighlight < cpLen(display)) {
+ const charToHighlight =
+ cpSlice(
+ display,
+ relativeVisualColForHighlight,
+ relativeVisualColForHighlight + 1,
+ ) || ' ';
+ const highlighted = chalk.inverse(charToHighlight);
+ display =
+ cpSlice(display, 0, relativeVisualColForHighlight) +
+ highlighted +
+ cpSlice(display, relativeVisualColForHighlight + 1);
+ } else if (
+ relativeVisualColForHighlight === cpLen(display)
+ ) {
+ if (!currentLineGhost) {
+ display = display + chalk.inverse(' ');
+ }
+ }
}
}
- }
- return (
- <Text key={`line-${visualIdxInRenderedSet}`}>{display}</Text>
- );
- })
+
+ const showCursorBeforeGhost =
+ focus &&
+ visualIdxInRenderedSet === cursorVisualRow &&
+ cursorVisualColAbsolute ===
+ // eslint-disable-next-line no-control-regex
+ cpLen(display.replace(/\x1b\[[0-9;]*m/g, '')) &&
+ currentLineGhost;
+
+ const actualDisplayWidth = stringWidth(display);
+ const cursorWidth = showCursorBeforeGhost ? 1 : 0;
+ const totalContentWidth =
+ actualDisplayWidth + cursorWidth + ghostWidth;
+ const trailingPadding = Math.max(
+ 0,
+ inputWidth - totalContentWidth,
+ );
+
+ return (
+ <Text key={`line-${visualIdxInRenderedSet}`}>
+ {display}
+ {showCursorBeforeGhost && chalk.inverse(' ')}
+ {currentLineGhost && (
+ <Text color={theme.text.secondary}>
+ {currentLineGhost}
+ </Text>
+ )}
+ {trailingPadding > 0 && ' '.repeat(trailingPadding)}
+ </Text>
+ );
+ })
+ .concat(
+ additionalLines.map((ghostLine, index) => {
+ const padding = Math.max(
+ 0,
+ inputWidth - stringWidth(ghostLine),
+ );
+ return (
+ <Text
+ key={`ghost-line-${index}`}
+ color={theme.text.secondary}
+ >
+ {ghostLine}
+ {' '.repeat(padding)}
+ </Text>
+ );
+ }),
+ )
)}
</Box>
</Box>