mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 19:52:02 +00:00
feat(cli): add agent composer UI and refactor text input handling
- Extract shared BaseTextInput component with readline keyboard handling - Add AgentComposer and AgentFooter components for agent interaction - Add useAgentStreamingState hook for managing agent streaming state - Refactor InputPrompt to use BaseTextInput with agent tab bar focus support - Move calculatePromptWidths to shared layoutUtils - Disable auto-accept indicator on agent tabs (agents handle their own) This enables a dedicated input experience for agent tabs with proper focus management and keyboard navigation between main input and agent tabs. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
parent
eaef9efe90
commit
89f8751233
19 changed files with 1273 additions and 337 deletions
|
|
@ -18,7 +18,6 @@ import { useShellHistory } from '../hooks/useShellHistory.js';
|
|||
import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js';
|
||||
import { useCommandCompletion } from '../hooks/useCommandCompletion.js';
|
||||
import type { Key } from '../hooks/useKeypress.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { keyMatchers, Command } from '../keyMatchers.js';
|
||||
import type { CommandContext, SlashCommand } from '../commands/types.js';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
|
|
@ -43,7 +42,13 @@ import { useShellFocusState } from '../contexts/ShellFocusContext.js';
|
|||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||
import { useKeypressContext } from '../contexts/KeypressContext.js';
|
||||
import {
|
||||
useAgentViewState,
|
||||
useAgentViewActions,
|
||||
} from '../contexts/AgentViewContext.js';
|
||||
import { FEEDBACK_DIALOG_KEYS } from '../FeedbackDialog.js';
|
||||
import { BaseTextInput } from './BaseTextInput.js';
|
||||
import type { RenderLineOptions } from './BaseTextInput.js';
|
||||
|
||||
/**
|
||||
* Represents an attachment (e.g., pasted image) displayed above the input prompt
|
||||
|
|
@ -78,30 +83,8 @@ export interface InputPromptProps {
|
|||
isEmbeddedShellFocused?: boolean;
|
||||
}
|
||||
|
||||
// The input content, input container, and input suggestions list may have different widths
|
||||
export const calculatePromptWidths = (terminalWidth: number) => {
|
||||
const widthFraction = 0.9;
|
||||
const FRAME_PADDING_AND_BORDER = 4; // Border (2) + padding (2)
|
||||
const PROMPT_PREFIX_WIDTH = 2; // '> ' or '! '
|
||||
const MIN_CONTENT_WIDTH = 2;
|
||||
|
||||
const innerContentWidth =
|
||||
Math.floor(terminalWidth * widthFraction) -
|
||||
FRAME_PADDING_AND_BORDER -
|
||||
PROMPT_PREFIX_WIDTH;
|
||||
|
||||
const inputWidth = Math.max(MIN_CONTENT_WIDTH, innerContentWidth);
|
||||
const FRAME_OVERHEAD = FRAME_PADDING_AND_BORDER + PROMPT_PREFIX_WIDTH;
|
||||
const containerWidth = inputWidth + FRAME_OVERHEAD;
|
||||
const suggestionsWidth = Math.max(20, Math.floor(terminalWidth * 1.0));
|
||||
|
||||
return {
|
||||
inputWidth,
|
||||
containerWidth,
|
||||
suggestionsWidth,
|
||||
frameOverhead: FRAME_OVERHEAD,
|
||||
} as const;
|
||||
};
|
||||
// Re-export from shared utils for backwards compatibility
|
||||
export { calculatePromptWidths } from '../utils/layoutUtils.js';
|
||||
|
||||
// Large paste placeholder thresholds
|
||||
const LARGE_PASTE_CHAR_THRESHOLD = 1000;
|
||||
|
|
@ -132,6 +115,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
const uiState = useUIState();
|
||||
const uiActions = useUIActions();
|
||||
const { pasteWorkaround } = useKeypressContext();
|
||||
const { agents, agentTabBarFocused } = useAgentViewState();
|
||||
const { setAgentTabBarFocused } = useAgentViewActions();
|
||||
const hasAgents = agents.size > 0;
|
||||
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
|
||||
const [escPressCount, setEscPressCount] = useState(0);
|
||||
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
|
||||
|
|
@ -225,7 +211,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
const resetCommandSearchCompletionState =
|
||||
commandSearchCompletion.resetCompletionState;
|
||||
|
||||
const showCursor = focus && isShellFocused && !isEmbeddedShellFocused;
|
||||
const showCursor =
|
||||
focus && isShellFocused && !isEmbeddedShellFocused && !agentTabBarFocused;
|
||||
|
||||
const resetEscapeState = useCallback(() => {
|
||||
if (escapeTimerRef.current) {
|
||||
|
|
@ -411,13 +398,30 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
}, []);
|
||||
|
||||
const handleInput = useCallback(
|
||||
(key: Key) => {
|
||||
(key: Key): boolean => {
|
||||
// When the tab bar has focus, block all non-printable keys so arrow
|
||||
// keys and shortcuts don't interfere. Printable characters fall
|
||||
// through to BaseTextInput's default handler so the first keystroke
|
||||
// appears in the input immediately (the tab bar handler releases
|
||||
// focus on the same event).
|
||||
if (agentTabBarFocused) {
|
||||
if (
|
||||
key.sequence &&
|
||||
key.sequence.length === 1 &&
|
||||
!key.ctrl &&
|
||||
!key.meta
|
||||
) {
|
||||
return false; // let BaseTextInput type the character
|
||||
}
|
||||
return true; // consume non-printable keys
|
||||
}
|
||||
|
||||
// TODO(jacobr): this special case is likely not needed anymore.
|
||||
// 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.paste) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (key.paste) {
|
||||
|
|
@ -459,18 +463,18 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
// Normal paste handling for small content
|
||||
buffer.handleInput(key);
|
||||
}
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (vimHandleInput && vimHandleInput(key)) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle feedback dialog keyboard interactions when dialog is open
|
||||
if (uiState.isFeedbackDialogOpen) {
|
||||
// If it's one of the feedback option keys (1-4), let FeedbackDialog handle it
|
||||
if ((FEEDBACK_DIALOG_KEYS as readonly string[]).includes(key.name)) {
|
||||
return;
|
||||
return true;
|
||||
} else {
|
||||
// For any other key, close feedback dialog temporarily and continue with normal processing
|
||||
uiActions.temporaryCloseFeedbackDialog();
|
||||
|
|
@ -496,7 +500,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
}
|
||||
setShellModeActive(!shellModeActive);
|
||||
buffer.setText(''); // Clear the '!' from input
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Toggle keyboard shortcuts display with "?" when buffer is empty
|
||||
|
|
@ -507,7 +511,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
onToggleShortcuts
|
||||
) {
|
||||
onToggleShortcuts();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Hide shortcuts on any other key press
|
||||
|
|
@ -537,33 +541,33 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
setReverseSearchActive,
|
||||
reverseSearchCompletion.resetCompletionState,
|
||||
);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (commandSearchActive) {
|
||||
cancelSearch(
|
||||
setCommandSearchActive,
|
||||
commandSearchCompletion.resetCompletionState,
|
||||
);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (shellModeActive) {
|
||||
setShellModeActive(false);
|
||||
resetEscapeState();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (completion.showSuggestions) {
|
||||
completion.resetCompletionState();
|
||||
setExpandedSuggestionIndex(-1);
|
||||
resetEscapeState();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle double ESC for clearing input
|
||||
if (escPressCount === 0) {
|
||||
if (buffer.text === '') {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
setEscPressCount(1);
|
||||
setShowEscapePrompt(true);
|
||||
|
|
@ -579,7 +583,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
resetCompletionState();
|
||||
resetEscapeState();
|
||||
}
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Ctrl+Y: Retry the last failed request.
|
||||
|
|
@ -589,19 +593,19 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
// If no failed request exists, a message will be shown to the user.
|
||||
if (keyMatchers[Command.RETRY_LAST](key)) {
|
||||
uiActions.handleRetryLastPrompt();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (shellModeActive && keyMatchers[Command.REVERSE_SEARCH](key)) {
|
||||
setReverseSearchActive(true);
|
||||
setTextBeforeReverseSearch(buffer.text);
|
||||
setCursorPosition(buffer.cursor);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.CLEAR_SCREEN](key)) {
|
||||
onClearScreen();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (reverseSearchActive || commandSearchActive) {
|
||||
|
|
@ -626,29 +630,29 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
if (showSuggestions) {
|
||||
if (keyMatchers[Command.NAVIGATION_UP](key)) {
|
||||
navigateUp();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
|
||||
navigateDown();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.COLLAPSE_SUGGESTION](key)) {
|
||||
if (suggestions[activeSuggestionIndex].value.length >= MAX_WIDTH) {
|
||||
setExpandedSuggestionIndex(-1);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (keyMatchers[Command.EXPAND_SUGGESTION](key)) {
|
||||
if (suggestions[activeSuggestionIndex].value.length >= MAX_WIDTH) {
|
||||
setExpandedSuggestionIndex(activeSuggestionIndex);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (keyMatchers[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH](key)) {
|
||||
sc.handleAutocomplete(activeSuggestionIndex);
|
||||
resetState();
|
||||
setActive(false);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -660,7 +664,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
handleSubmitAndClear(textToSubmit);
|
||||
resetState();
|
||||
setActive(false);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Prevent up/down from falling through to regular history navigation
|
||||
|
|
@ -668,14 +672,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
keyMatchers[Command.NAVIGATION_UP](key) ||
|
||||
keyMatchers[Command.NAVIGATION_DOWN](key)
|
||||
) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// If the command is a perfect match, pressing enter should execute it.
|
||||
if (completion.isPerfectMatch && keyMatchers[Command.RETURN](key)) {
|
||||
handleSubmitAndClear(buffer.text);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (completion.showSuggestions) {
|
||||
|
|
@ -683,12 +687,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
if (keyMatchers[Command.COMPLETION_UP](key)) {
|
||||
completion.navigateUp();
|
||||
setExpandedSuggestionIndex(-1); // Reset expansion when navigating
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.COMPLETION_DOWN](key)) {
|
||||
completion.navigateDown();
|
||||
setExpandedSuggestionIndex(-1); // Reset expansion when navigating
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -703,7 +707,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
setExpandedSuggestionIndex(-1); // Reset expansion after selection
|
||||
}
|
||||
}
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -711,28 +715,28 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
if (isAttachmentMode && attachments.length > 0) {
|
||||
if (key.name === 'left') {
|
||||
setSelectedAttachmentIndex((i) => Math.max(0, i - 1));
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (key.name === 'right') {
|
||||
setSelectedAttachmentIndex((i) =>
|
||||
Math.min(attachments.length - 1, i + 1),
|
||||
);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
|
||||
// Exit attachment mode and return to input
|
||||
setIsAttachmentMode(false);
|
||||
setSelectedAttachmentIndex(-1);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (key.name === 'backspace' || key.name === 'delete') {
|
||||
handleAttachmentDelete(selectedAttachmentIndex);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (key.name === 'return' || key.name === 'escape') {
|
||||
setIsAttachmentMode(false);
|
||||
setSelectedAttachmentIndex(-1);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
// For other keys, exit attachment mode and let input handle them
|
||||
setIsAttachmentMode(false);
|
||||
|
|
@ -753,7 +757,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
) {
|
||||
setIsAttachmentMode(true);
|
||||
setSelectedAttachmentIndex(attachments.length - 1);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!shellModeActive) {
|
||||
|
|
@ -761,16 +765,16 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
setCommandSearchActive(true);
|
||||
setTextBeforeReverseSearch(buffer.text);
|
||||
setCursorPosition(buffer.cursor);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.HISTORY_UP](key)) {
|
||||
inputHistory.navigateUp();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.HISTORY_DOWN](key)) {
|
||||
inputHistory.navigateDown();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
// Handle arrow-up/down for history on single-line or at edges
|
||||
if (
|
||||
|
|
@ -779,27 +783,33 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
(buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0))
|
||||
) {
|
||||
inputHistory.navigateUp();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
keyMatchers[Command.NAVIGATION_DOWN](key) &&
|
||||
(buffer.allVisualLines.length === 1 ||
|
||||
buffer.visualCursor[0] === buffer.allVisualLines.length - 1)
|
||||
) {
|
||||
inputHistory.navigateDown();
|
||||
return;
|
||||
if (inputHistory.navigateDown()) {
|
||||
return true;
|
||||
}
|
||||
if (hasAgents) {
|
||||
setAgentTabBarFocused(true);
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
// Shell History Navigation
|
||||
if (keyMatchers[Command.NAVIGATION_UP](key)) {
|
||||
const prevCommand = shellHistory.getPreviousCommand();
|
||||
if (prevCommand !== null) buffer.setText(prevCommand);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
|
||||
const nextCommand = shellHistory.getNextCommand();
|
||||
if (nextCommand !== null) buffer.setText(nextCommand);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -810,7 +820,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
// paste markers may not work reliably and Enter key events can leak from pasted text.
|
||||
if (pasteWorkaround && recentPasteTime !== null) {
|
||||
// Paste occurred recently, ignore this submit to prevent auto-execution
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
const [row, col] = buffer.cursor;
|
||||
|
|
@ -823,65 +833,21 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
handleSubmitAndClear(buffer.text);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Newline insertion
|
||||
if (keyMatchers[Command.NEWLINE](key)) {
|
||||
buffer.newline();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+A (Home) / Ctrl+E (End)
|
||||
if (keyMatchers[Command.HOME](key)) {
|
||||
buffer.move('home');
|
||||
return;
|
||||
}
|
||||
if (keyMatchers[Command.END](key)) {
|
||||
buffer.move('end');
|
||||
return;
|
||||
}
|
||||
// Ctrl+C (Clear input)
|
||||
if (keyMatchers[Command.CLEAR_INPUT](key)) {
|
||||
if (buffer.text.length > 0) {
|
||||
buffer.setText('');
|
||||
resetCompletionState();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Kill line commands
|
||||
if (keyMatchers[Command.KILL_LINE_RIGHT](key)) {
|
||||
buffer.killLineRight();
|
||||
return;
|
||||
}
|
||||
if (keyMatchers[Command.KILL_LINE_LEFT](key)) {
|
||||
buffer.killLineLeft();
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.DELETE_WORD_BACKWARD](key)) {
|
||||
buffer.deleteWordLeft();
|
||||
return;
|
||||
}
|
||||
|
||||
// External editor
|
||||
if (keyMatchers[Command.OPEN_EXTERNAL_EDITOR](key)) {
|
||||
buffer.openInExternalEditor();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Ctrl+V for clipboard image paste
|
||||
if (keyMatchers[Command.PASTE_CLIPBOARD_IMAGE](key)) {
|
||||
handleClipboardImage();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle backspace with placeholder-aware deletion
|
||||
if (
|
||||
key.name === 'backspace' ||
|
||||
key.sequence === '\x7f' ||
|
||||
(key.ctrl && key.name === 'h')
|
||||
pendingPastes.size > 0 &&
|
||||
(key.name === 'backspace' ||
|
||||
key.sequence === '\x7f' ||
|
||||
(key.ctrl && key.name === 'h'))
|
||||
) {
|
||||
const text = buffer.text;
|
||||
const [row, col] = buffer.cursor;
|
||||
|
|
@ -894,7 +860,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
offset += col;
|
||||
|
||||
// Check if we're at the end of any placeholder
|
||||
let placeholderDeleted = false;
|
||||
for (const placeholder of pendingPastes.keys()) {
|
||||
const placeholderStart = offset - placeholder.length;
|
||||
if (
|
||||
|
|
@ -913,20 +878,22 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
if (parsed) {
|
||||
freePlaceholderId(parsed.charCount, parsed.id);
|
||||
}
|
||||
placeholderDeleted = true;
|
||||
break;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!placeholderDeleted) {
|
||||
// Normal backspace behavior
|
||||
buffer.backspace();
|
||||
}
|
||||
return;
|
||||
// No placeholder matched — fall through to BaseTextInput's default backspace
|
||||
}
|
||||
|
||||
// Fall back to the text buffer's default input handling for all other keys
|
||||
buffer.handleInput(key);
|
||||
// Ctrl+C with completion active — also reset completion state
|
||||
if (keyMatchers[Command.CLEAR_INPUT](key)) {
|
||||
if (buffer.text.length > 0) {
|
||||
resetCompletionState();
|
||||
}
|
||||
// Fall through to BaseTextInput's default CLEAR_INPUT handler
|
||||
}
|
||||
|
||||
// All remaining keys (readline shortcuts, text input) handled by BaseTextInput
|
||||
return false;
|
||||
},
|
||||
[
|
||||
focus,
|
||||
|
|
@ -964,17 +931,89 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
pendingPastes,
|
||||
parsePlaceholder,
|
||||
freePlaceholderId,
|
||||
agentTabBarFocused,
|
||||
hasAgents,
|
||||
setAgentTabBarFocused,
|
||||
],
|
||||
);
|
||||
|
||||
useKeypress(handleInput, {
|
||||
isActive: !isEmbeddedShellFocused,
|
||||
});
|
||||
const renderLineWithHighlighting = useCallback(
|
||||
(opts: RenderLineOptions): React.ReactNode => {
|
||||
const {
|
||||
lineText,
|
||||
isOnCursorLine,
|
||||
cursorCol: cursorVisualColAbsolute,
|
||||
showCursor: showCursorOpt,
|
||||
absoluteVisualIndex,
|
||||
buffer: buf,
|
||||
} = opts;
|
||||
const mapEntry = buf.visualToLogicalMap[absoluteVisualIndex];
|
||||
const [logicalLineIdx, logicalStartCol] = mapEntry;
|
||||
const logicalLine = buf.lines[logicalLineIdx] || '';
|
||||
const tokens = parseInputForHighlighting(logicalLine, logicalLineIdx);
|
||||
|
||||
const linesToRender = buffer.viewportVisualLines;
|
||||
const [cursorVisualRowAbsolute, cursorVisualColAbsolute] =
|
||||
buffer.visualCursor;
|
||||
const scrollVisualRow = buffer.visualScrollRow;
|
||||
const visualStart = logicalStartCol;
|
||||
const visualEnd = logicalStartCol + cpLen(lineText);
|
||||
const segments = buildSegmentsForVisualSlice(
|
||||
tokens,
|
||||
visualStart,
|
||||
visualEnd,
|
||||
);
|
||||
|
||||
const renderedLine: React.ReactNode[] = [];
|
||||
let charCount = 0;
|
||||
segments.forEach((seg, segIdx) => {
|
||||
const segLen = cpLen(seg.text);
|
||||
let display = seg.text;
|
||||
|
||||
if (isOnCursorLine) {
|
||||
const segStart = charCount;
|
||||
const segEnd = segStart + segLen;
|
||||
if (
|
||||
cursorVisualColAbsolute >= segStart &&
|
||||
cursorVisualColAbsolute < segEnd
|
||||
) {
|
||||
const charToHighlight = cpSlice(
|
||||
seg.text,
|
||||
cursorVisualColAbsolute - segStart,
|
||||
cursorVisualColAbsolute - segStart + 1,
|
||||
);
|
||||
const highlighted = showCursorOpt
|
||||
? chalk.inverse(charToHighlight)
|
||||
: charToHighlight;
|
||||
display =
|
||||
cpSlice(seg.text, 0, cursorVisualColAbsolute - segStart) +
|
||||
highlighted +
|
||||
cpSlice(seg.text, cursorVisualColAbsolute - segStart + 1);
|
||||
}
|
||||
charCount = segEnd;
|
||||
}
|
||||
|
||||
const color =
|
||||
seg.type === 'command' || seg.type === 'file'
|
||||
? theme.text.accent
|
||||
: theme.text.primary;
|
||||
|
||||
renderedLine.push(
|
||||
<Text key={`token-${segIdx}`} color={color}>
|
||||
{display}
|
||||
</Text>,
|
||||
);
|
||||
});
|
||||
|
||||
if (isOnCursorLine && cursorVisualColAbsolute === cpLen(lineText)) {
|
||||
// Add zero-width space after cursor to prevent Ink from trimming trailing whitespace
|
||||
renderedLine.push(
|
||||
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
|
||||
{showCursorOpt ? chalk.inverse(' ') + '\u200B' : ' \u200B'}
|
||||
</Text>,
|
||||
);
|
||||
}
|
||||
|
||||
return <Text>{renderedLine}</Text>;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const getActiveCompletion = () => {
|
||||
if (commandSearchActive) return commandSearchCompletion;
|
||||
|
|
@ -1011,10 +1050,33 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
}
|
||||
|
||||
const borderColor =
|
||||
isShellFocused && !isEmbeddedShellFocused
|
||||
isShellFocused && !isEmbeddedShellFocused && !agentTabBarFocused
|
||||
? (statusColor ?? theme.border.focused)
|
||||
: theme.border.default;
|
||||
|
||||
const prefixNode = (
|
||||
<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 ? (
|
||||
'*'
|
||||
) : (
|
||||
'>'
|
||||
)}{' '}
|
||||
</Text>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{attachments.length > 0 && (
|
||||
|
|
@ -1034,142 +1096,17 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
))}
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderTop={true}
|
||||
borderBottom={true}
|
||||
borderLeft={false}
|
||||
borderRight={false}
|
||||
<BaseTextInput
|
||||
buffer={buffer}
|
||||
onSubmit={handleSubmitAndClear}
|
||||
onKeypress={handleInput}
|
||||
showCursor={showCursor}
|
||||
placeholder={placeholder}
|
||||
prefix={prefixNode}
|
||||
borderColor={borderColor}
|
||||
>
|
||||
<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 ? (
|
||||
'*'
|
||||
) : (
|
||||
'>'
|
||||
)}{' '}
|
||||
</Text>
|
||||
<Box flexGrow={1} flexDirection="column">
|
||||
{buffer.text.length === 0 && placeholder ? (
|
||||
showCursor ? (
|
||||
<Text>
|
||||
{chalk.inverse(placeholder.slice(0, 1))}
|
||||
<Text color={theme.text.secondary}>{placeholder.slice(1)}</Text>
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={theme.text.secondary}>{placeholder}</Text>
|
||||
)
|
||||
) : (
|
||||
linesToRender.map((lineText, visualIdxInRenderedSet) => {
|
||||
const absoluteVisualIdx =
|
||||
scrollVisualRow + visualIdxInRenderedSet;
|
||||
const mapEntry = buffer.visualToLogicalMap[absoluteVisualIdx];
|
||||
const cursorVisualRow = cursorVisualRowAbsolute - scrollVisualRow;
|
||||
const isOnCursorLine =
|
||||
focus && visualIdxInRenderedSet === cursorVisualRow;
|
||||
|
||||
const renderedLine: React.ReactNode[] = [];
|
||||
|
||||
const [logicalLineIdx, logicalStartCol] = mapEntry;
|
||||
const logicalLine = buffer.lines[logicalLineIdx] || '';
|
||||
const tokens = parseInputForHighlighting(
|
||||
logicalLine,
|
||||
logicalLineIdx,
|
||||
);
|
||||
|
||||
const visualStart = logicalStartCol;
|
||||
const visualEnd = logicalStartCol + cpLen(lineText);
|
||||
const segments = buildSegmentsForVisualSlice(
|
||||
tokens,
|
||||
visualStart,
|
||||
visualEnd,
|
||||
);
|
||||
|
||||
let charCount = 0;
|
||||
segments.forEach((seg, segIdx) => {
|
||||
const segLen = cpLen(seg.text);
|
||||
let display = seg.text;
|
||||
|
||||
if (isOnCursorLine) {
|
||||
const relativeVisualColForHighlight = cursorVisualColAbsolute;
|
||||
const segStart = charCount;
|
||||
const segEnd = segStart + segLen;
|
||||
if (
|
||||
relativeVisualColForHighlight >= segStart &&
|
||||
relativeVisualColForHighlight < segEnd
|
||||
) {
|
||||
const charToHighlight = cpSlice(
|
||||
seg.text,
|
||||
relativeVisualColForHighlight - segStart,
|
||||
relativeVisualColForHighlight - segStart + 1,
|
||||
);
|
||||
const highlighted = showCursor
|
||||
? chalk.inverse(charToHighlight)
|
||||
: charToHighlight;
|
||||
display =
|
||||
cpSlice(
|
||||
seg.text,
|
||||
0,
|
||||
relativeVisualColForHighlight - segStart,
|
||||
) +
|
||||
highlighted +
|
||||
cpSlice(
|
||||
seg.text,
|
||||
relativeVisualColForHighlight - segStart + 1,
|
||||
);
|
||||
}
|
||||
charCount = segEnd;
|
||||
}
|
||||
|
||||
const color =
|
||||
seg.type === 'command' || seg.type === 'file'
|
||||
? theme.text.accent
|
||||
: theme.text.primary;
|
||||
|
||||
renderedLine.push(
|
||||
<Text key={`token-${segIdx}`} color={color}>
|
||||
{display}
|
||||
</Text>,
|
||||
);
|
||||
});
|
||||
|
||||
if (
|
||||
isOnCursorLine &&
|
||||
cursorVisualColAbsolute === cpLen(lineText)
|
||||
) {
|
||||
// Add zero-width space after cursor to prevent Ink from trimming trailing whitespace
|
||||
renderedLine.push(
|
||||
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
|
||||
{showCursor ? chalk.inverse(' ') + '\u200B' : ' \u200B'}
|
||||
</Text>,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box key={`line-${visualIdxInRenderedSet}`} height={1}>
|
||||
<Text>{renderedLine}</Text>
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
isActive={!isEmbeddedShellFocused}
|
||||
renderLine={renderLineWithHighlighting}
|
||||
/>
|
||||
{shouldShowSuggestions && (
|
||||
<Box marginLeft={2} marginRight={2}>
|
||||
<SuggestionsDisplay
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue