mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-30 04:30:48 +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
287
packages/cli/src/ui/components/BaseTextInput.tsx
Normal file
287
packages/cli/src/ui/components/BaseTextInput.tsx
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview BaseTextInput — shared text input component with rendering
|
||||
* and common readline keyboard handling.
|
||||
*
|
||||
* Provides:
|
||||
* - Viewport line rendering from a TextBuffer with cursor display
|
||||
* - Placeholder support when buffer is empty
|
||||
* - Configurable border/prefix styling
|
||||
* - Standard readline shortcuts (Ctrl+A/E/K/U/W, Escape, etc.)
|
||||
* - An `onKeypress` interceptor so consumers can layer custom behavior
|
||||
*
|
||||
* Used by both InputPrompt (with syntax highlighting + complex key handling)
|
||||
* and AgentComposer (with minimal customization).
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import chalk from 'chalk';
|
||||
import type { TextBuffer } from './shared/text-buffer.js';
|
||||
import type { Key } from '../hooks/useKeypress.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { keyMatchers, Command } from '../keyMatchers.js';
|
||||
import { cpSlice, cpLen } from '../utils/textUtils.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────
|
||||
|
||||
export interface RenderLineOptions {
|
||||
/** The text content of this visual line. */
|
||||
lineText: string;
|
||||
/** Whether the cursor is on this visual line. */
|
||||
isOnCursorLine: boolean;
|
||||
/** The cursor column within this visual line (visual col, not logical). */
|
||||
cursorCol: number;
|
||||
/** Whether the cursor should be rendered. */
|
||||
showCursor: boolean;
|
||||
/** Index of this line within the rendered viewport (0-based). */
|
||||
visualLineIndex: number;
|
||||
/** Absolute visual line index (scrollVisualRow + visualLineIndex). */
|
||||
absoluteVisualIndex: number;
|
||||
/** The underlying text buffer. */
|
||||
buffer: TextBuffer;
|
||||
/** The first visible visual row (scroll offset). */
|
||||
scrollVisualRow: number;
|
||||
}
|
||||
|
||||
export interface BaseTextInputProps {
|
||||
/** The text buffer driving this input. */
|
||||
buffer: TextBuffer;
|
||||
/** Called when the user submits (Enter). Buffer is cleared automatically. */
|
||||
onSubmit: (text: string) => void;
|
||||
/**
|
||||
* Optional key interceptor. Called before default readline handling.
|
||||
* Return `true` if the key was handled (skips default processing).
|
||||
*/
|
||||
onKeypress?: (key: Key) => boolean;
|
||||
/** Whether to show the blinking block cursor. Defaults to true. */
|
||||
showCursor?: boolean;
|
||||
/** Placeholder text shown when the buffer is empty. */
|
||||
placeholder?: string;
|
||||
/** Custom prefix node (defaults to `> `). */
|
||||
prefix?: React.ReactNode;
|
||||
/** Border color for the input box. */
|
||||
borderColor?: string;
|
||||
/** Whether keyboard handling is active. Defaults to true. */
|
||||
isActive?: boolean;
|
||||
/**
|
||||
* Custom line renderer for advanced rendering (e.g. syntax highlighting).
|
||||
* When not provided, lines are rendered as plain text with cursor overlay.
|
||||
*/
|
||||
renderLine?: (opts: RenderLineOptions) => React.ReactNode;
|
||||
}
|
||||
|
||||
// ─── Default line renderer ──────────────────────────────────
|
||||
|
||||
/**
|
||||
* Renders a single visual line with an inverse-video block cursor.
|
||||
* Uses codepoint-aware string operations for Unicode/emoji safety.
|
||||
*/
|
||||
export function defaultRenderLine({
|
||||
lineText,
|
||||
isOnCursorLine,
|
||||
cursorCol,
|
||||
showCursor,
|
||||
}: RenderLineOptions): React.ReactNode {
|
||||
if (!isOnCursorLine || !showCursor) {
|
||||
return <Text>{lineText || ' '}</Text>;
|
||||
}
|
||||
|
||||
const len = cpLen(lineText);
|
||||
|
||||
// Cursor past end of line — append inverse space
|
||||
if (cursorCol >= len) {
|
||||
return (
|
||||
<Text>
|
||||
{lineText}
|
||||
{chalk.inverse(' ') + '\u200B'}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const before = cpSlice(lineText, 0, cursorCol);
|
||||
const cursorChar = cpSlice(lineText, cursorCol, cursorCol + 1);
|
||||
const after = cpSlice(lineText, cursorCol + 1);
|
||||
|
||||
return (
|
||||
<Text>
|
||||
{before}
|
||||
{chalk.inverse(cursorChar)}
|
||||
{after}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Component ──────────────────────────────────────────────
|
||||
|
||||
export const BaseTextInput: React.FC<BaseTextInputProps> = ({
|
||||
buffer,
|
||||
onSubmit,
|
||||
onKeypress,
|
||||
showCursor = true,
|
||||
placeholder,
|
||||
prefix,
|
||||
borderColor,
|
||||
isActive = true,
|
||||
renderLine = defaultRenderLine,
|
||||
}) => {
|
||||
// ── Keyboard handling ──
|
||||
|
||||
const handleKey = useCallback(
|
||||
(key: Key) => {
|
||||
// Let the consumer intercept first
|
||||
if (onKeypress?.(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Standard readline shortcuts ──
|
||||
|
||||
// Submit (Enter, no modifiers)
|
||||
if (keyMatchers[Command.SUBMIT](key)) {
|
||||
if (buffer.text.trim()) {
|
||||
const text = buffer.text;
|
||||
buffer.setText('');
|
||||
onSubmit(text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Newline (Shift+Enter, Ctrl+Enter, Ctrl+J)
|
||||
if (keyMatchers[Command.NEWLINE](key)) {
|
||||
buffer.newline();
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape → clear input
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
if (buffer.text.length > 0) {
|
||||
buffer.setText('');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+C → clear input
|
||||
if (keyMatchers[Command.CLEAR_INPUT](key)) {
|
||||
if (buffer.text.length > 0) {
|
||||
buffer.setText('');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+A → home
|
||||
if (keyMatchers[Command.HOME](key)) {
|
||||
buffer.move('home');
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+E → end
|
||||
if (keyMatchers[Command.END](key)) {
|
||||
buffer.move('end');
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+K → kill to end of line
|
||||
if (keyMatchers[Command.KILL_LINE_RIGHT](key)) {
|
||||
buffer.killLineRight();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+U → kill to start of line
|
||||
if (keyMatchers[Command.KILL_LINE_LEFT](key)) {
|
||||
buffer.killLineLeft();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+W / Alt+Backspace → delete word backward
|
||||
if (keyMatchers[Command.DELETE_WORD_BACKWARD](key)) {
|
||||
buffer.deleteWordLeft();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+X Ctrl+E → open in external editor
|
||||
if (keyMatchers[Command.OPEN_EXTERNAL_EDITOR](key)) {
|
||||
buffer.openInExternalEditor();
|
||||
return;
|
||||
}
|
||||
|
||||
// Backspace
|
||||
if (
|
||||
key.name === 'backspace' ||
|
||||
key.sequence === '\x7f' ||
|
||||
(key.ctrl && key.name === 'h')
|
||||
) {
|
||||
buffer.backspace();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallthrough — delegate to buffer's built-in input handler
|
||||
buffer.handleInput(key);
|
||||
},
|
||||
[buffer, onSubmit, onKeypress],
|
||||
);
|
||||
|
||||
useKeypress(handleKey, { isActive });
|
||||
|
||||
// ── Rendering ──
|
||||
|
||||
const linesToRender = buffer.viewportVisualLines;
|
||||
const [cursorVisualRow, cursorVisualCol] = buffer.visualCursor;
|
||||
const scrollVisualRow = buffer.visualScrollRow;
|
||||
|
||||
const resolvedBorderColor = borderColor ?? theme.border.focused;
|
||||
const resolvedPrefix = prefix ?? (
|
||||
<Text color={theme.text.accent}>{'> '}</Text>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderTop={true}
|
||||
borderBottom={true}
|
||||
borderLeft={false}
|
||||
borderRight={false}
|
||||
borderColor={resolvedBorderColor}
|
||||
>
|
||||
{resolvedPrefix}
|
||||
<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, idx) => {
|
||||
const absoluteVisualIndex = scrollVisualRow + idx;
|
||||
const isOnCursorLine = absoluteVisualIndex === cursorVisualRow;
|
||||
|
||||
return (
|
||||
<Box key={idx} height={1}>
|
||||
{renderLine({
|
||||
lineText,
|
||||
isOnCursorLine,
|
||||
cursorCol: cursorVisualCol,
|
||||
showCursor,
|
||||
visualLineIndex: idx,
|
||||
absoluteVisualIndex,
|
||||
buffer,
|
||||
scrollVisualRow,
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
|||
: null;
|
||||
|
||||
return (
|
||||
<Box paddingLeft={0} flexDirection="column">
|
||||
<Box paddingLeft={2} flexDirection="column">
|
||||
{/* Main loading line */}
|
||||
<Box
|
||||
width="100%"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<LoadingIndicator /> > should truncate long primary text instead of wrapping 1`] = `
|
||||
"MockResponding This is an extremely long loading phrase that should be truncated in t (esc to
|
||||
Spinner cancel, 5s)"
|
||||
" MockResponding This is an extremely long loading phrase that should be truncated in (esc to
|
||||
Spinner cancel, 5s)"
|
||||
`;
|
||||
|
|
|
|||
284
packages/cli/src/ui/components/agent-view/AgentComposer.tsx
Normal file
284
packages/cli/src/ui/components/agent-view/AgentComposer.tsx
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview AgentComposer — footer area for in-process agent tabs.
|
||||
*
|
||||
* Replaces the main Composer when an agent tab is active so that:
|
||||
* - The loading indicator reflects the agent's status (not the main agent)
|
||||
* - The input prompt sends messages to the agent (via enqueueMessage)
|
||||
* - Keyboard events are scoped — no conflict with the main InputPrompt
|
||||
*
|
||||
* Wraps its content in a local StreamingContext.Provider so reusable
|
||||
* components like LoadingIndicator and GeminiRespondingSpinner read the
|
||||
* agent's derived streaming state instead of the main agent's.
|
||||
*/
|
||||
|
||||
import { Box, Text, useStdin } from 'ink';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
AgentStatus,
|
||||
ApprovalMode,
|
||||
APPROVAL_MODES,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
useAgentViewState,
|
||||
useAgentViewActions,
|
||||
} from '../../contexts/AgentViewContext.js';
|
||||
import { useConfig } from '../../contexts/ConfigContext.js';
|
||||
import { StreamingContext } from '../../contexts/StreamingContext.js';
|
||||
import { StreamingState } from '../../types.js';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
import { useAgentStreamingState } from '../../hooks/useAgentStreamingState.js';
|
||||
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
|
||||
import { useTextBuffer } from '../shared/text-buffer.js';
|
||||
import { calculatePromptWidths } from '../../utils/layoutUtils.js';
|
||||
import { BaseTextInput } from '../BaseTextInput.js';
|
||||
import { LoadingIndicator } from '../LoadingIndicator.js';
|
||||
import { AgentFooter } from './AgentFooter.js';
|
||||
import { keyMatchers, Command } from '../../keyMatchers.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────
|
||||
|
||||
interface AgentComposerProps {
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
// ─── Component ──────────────────────────────────────────────
|
||||
|
||||
export const AgentComposer: React.FC<AgentComposerProps> = ({ agentId }) => {
|
||||
const { agents, agentTabBarFocused, agentShellFocused, agentApprovalModes } =
|
||||
useAgentViewState();
|
||||
const {
|
||||
setAgentInputBufferText,
|
||||
setAgentTabBarFocused,
|
||||
setAgentApprovalMode,
|
||||
} = useAgentViewActions();
|
||||
const agent = agents.get(agentId);
|
||||
const interactiveAgent = agent?.interactiveAgent;
|
||||
|
||||
const config = useConfig();
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
const { inputWidth } = calculatePromptWidths(terminalWidth);
|
||||
const { stdin, setRawMode } = useStdin();
|
||||
|
||||
const {
|
||||
status,
|
||||
streamingState,
|
||||
isInputActive,
|
||||
elapsedTime,
|
||||
lastPromptTokenCount,
|
||||
} = useAgentStreamingState(interactiveAgent);
|
||||
|
||||
// ── Escape to cancel the active agent round ──
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (
|
||||
key.name === 'escape' &&
|
||||
streamingState === StreamingState.Responding
|
||||
) {
|
||||
interactiveAgent?.cancelCurrentRound();
|
||||
}
|
||||
},
|
||||
{
|
||||
isActive:
|
||||
streamingState === StreamingState.Responding && !agentShellFocused,
|
||||
},
|
||||
);
|
||||
|
||||
// ── Shift+Tab to cycle this agent's approval mode ──
|
||||
|
||||
const agentApprovalMode =
|
||||
agentApprovalModes.get(agentId) ?? ApprovalMode.DEFAULT;
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
const isShiftTab = key.shift && key.name === 'tab';
|
||||
const isWindowsTab =
|
||||
process.platform === 'win32' &&
|
||||
key.name === 'tab' &&
|
||||
!key.ctrl &&
|
||||
!key.meta;
|
||||
if (isShiftTab || isWindowsTab) {
|
||||
const currentIndex = APPROVAL_MODES.indexOf(agentApprovalMode);
|
||||
const nextIndex =
|
||||
currentIndex === -1 ? 0 : (currentIndex + 1) % APPROVAL_MODES.length;
|
||||
setAgentApprovalMode(agentId, APPROVAL_MODES[nextIndex]!);
|
||||
}
|
||||
},
|
||||
{ isActive: !agentShellFocused },
|
||||
);
|
||||
|
||||
// ── Input buffer (independent from main agent) ──
|
||||
|
||||
const isValidPath = useCallback((): boolean => false, []);
|
||||
|
||||
const buffer = useTextBuffer({
|
||||
initialText: '',
|
||||
viewport: { height: 3, width: inputWidth },
|
||||
stdin,
|
||||
setRawMode,
|
||||
isValidPath,
|
||||
});
|
||||
|
||||
// Sync agent buffer text to context so AgentTabBar can guard tab switching
|
||||
useEffect(() => {
|
||||
setAgentInputBufferText(buffer.text);
|
||||
return () => setAgentInputBufferText('');
|
||||
}, [buffer.text, setAgentInputBufferText]);
|
||||
|
||||
// When agent input is not active (agent running, completed, etc.),
|
||||
// auto-focus the tab bar so arrow keys switch tabs directly.
|
||||
// We also depend on streamingState so that transitions like
|
||||
// WaitingForConfirmation → Responding re-trigger the effect — the
|
||||
// approval keypress releases tab-bar focus (printable char handler),
|
||||
// but isInputActive stays false throughout, so without this extra
|
||||
// dependency the focus would never be restored.
|
||||
useEffect(() => {
|
||||
if (!isInputActive) {
|
||||
setAgentTabBarFocused(true);
|
||||
}
|
||||
}, [isInputActive, streamingState, setAgentTabBarFocused]);
|
||||
|
||||
// ── Focus management between input and tab bar ──
|
||||
|
||||
const handleKeypress = useCallback(
|
||||
(key: Key): boolean => {
|
||||
// When tab bar has focus, block all non-printable keys so they don't
|
||||
// act on the hidden buffer. Printable characters fall through to
|
||||
// BaseTextInput naturally; the tab bar handler releases focus on the
|
||||
// same event so the keystroke appears in the input immediately.
|
||||
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
|
||||
}
|
||||
|
||||
// Down arrow at the bottom edge (or empty buffer) → focus the tab bar
|
||||
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
|
||||
if (
|
||||
buffer.text === '' ||
|
||||
buffer.allVisualLines.length === 1 ||
|
||||
buffer.visualCursor[0] === buffer.allVisualLines.length - 1
|
||||
) {
|
||||
setAgentTabBarFocused(true);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[buffer, agentTabBarFocused, setAgentTabBarFocused],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(text: string) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || !interactiveAgent) return;
|
||||
interactiveAgent.enqueueMessage(trimmed);
|
||||
},
|
||||
[interactiveAgent],
|
||||
);
|
||||
|
||||
// ── Render ──
|
||||
|
||||
const statusLabel = useMemo(() => {
|
||||
switch (status) {
|
||||
case AgentStatus.COMPLETED:
|
||||
return { text: t('Completed'), color: theme.status.success };
|
||||
case AgentStatus.FAILED:
|
||||
return {
|
||||
text: t('Failed: {{error}}', {
|
||||
error:
|
||||
interactiveAgent?.getError() ??
|
||||
interactiveAgent?.getLastRoundError() ??
|
||||
'unknown',
|
||||
}),
|
||||
color: theme.status.error,
|
||||
};
|
||||
case AgentStatus.CANCELLED:
|
||||
return { text: t('Cancelled'), color: theme.text.secondary };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [status, interactiveAgent]);
|
||||
|
||||
// ── Approval-mode styling (mirrors main InputPrompt) ──
|
||||
|
||||
const isYolo = agentApprovalMode === ApprovalMode.YOLO;
|
||||
const isAutoAccept = agentApprovalMode !== ApprovalMode.DEFAULT;
|
||||
|
||||
const statusColor = isYolo
|
||||
? theme.status.errorDim
|
||||
: isAutoAccept
|
||||
? theme.status.warningDim
|
||||
: undefined;
|
||||
|
||||
const inputBorderColor =
|
||||
!isInputActive || agentTabBarFocused
|
||||
? theme.border.default
|
||||
: (statusColor ?? theme.border.focused);
|
||||
|
||||
const prefixNode = (
|
||||
<Text color={statusColor ?? theme.text.accent}>{isYolo ? '*' : '>'} </Text>
|
||||
);
|
||||
|
||||
return (
|
||||
<StreamingContext.Provider value={streamingState}>
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{/* Loading indicator — mirrors main Composer but reads agent's
|
||||
streaming state via the overridden StreamingContext. */}
|
||||
<LoadingIndicator
|
||||
currentLoadingPhrase={
|
||||
streamingState === StreamingState.Responding
|
||||
? t('Agent is working…')
|
||||
: undefined
|
||||
}
|
||||
elapsedTime={elapsedTime}
|
||||
/>
|
||||
|
||||
{/* Terminal status for completed/failed agents */}
|
||||
{statusLabel && (
|
||||
<Box marginLeft={2}>
|
||||
<Text color={statusLabel.color}>{statusLabel.text}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Input prompt — always visible, like the main Composer */}
|
||||
<BaseTextInput
|
||||
buffer={buffer}
|
||||
onSubmit={handleSubmit}
|
||||
onKeypress={handleKeypress}
|
||||
showCursor={isInputActive && !agentTabBarFocused}
|
||||
placeholder={' ' + t('Send a message to this agent')}
|
||||
prefix={prefixNode}
|
||||
borderColor={inputBorderColor}
|
||||
isActive={isInputActive && !agentShellFocused}
|
||||
/>
|
||||
|
||||
{/* Footer: approval mode + context usage */}
|
||||
{isInputActive && (
|
||||
<AgentFooter
|
||||
approvalMode={agentApprovalMode}
|
||||
promptTokenCount={lastPromptTokenCount}
|
||||
contextWindowSize={
|
||||
config.getContentGeneratorConfig()?.contextWindowSize
|
||||
}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</StreamingContext.Provider>
|
||||
);
|
||||
};
|
||||
66
packages/cli/src/ui/components/agent-view/AgentFooter.tsx
Normal file
66
packages/cli/src/ui/components/agent-view/AgentFooter.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Lightweight footer for agent tabs showing approval mode
|
||||
* and context usage. Mirrors the main Footer layout but without
|
||||
* main-agent-specific concerns (vim mode, shell mode, exit prompts, etc.).
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { ApprovalMode } from '@qwen-code/qwen-code-core';
|
||||
import { AutoAcceptIndicator } from '../AutoAcceptIndicator.js';
|
||||
import { ContextUsageDisplay } from '../ContextUsageDisplay.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
|
||||
interface AgentFooterProps {
|
||||
approvalMode: ApprovalMode | undefined;
|
||||
promptTokenCount: number;
|
||||
contextWindowSize: number | undefined;
|
||||
terminalWidth: number;
|
||||
}
|
||||
|
||||
export const AgentFooter: React.FC<AgentFooterProps> = ({
|
||||
approvalMode,
|
||||
promptTokenCount,
|
||||
contextWindowSize,
|
||||
terminalWidth,
|
||||
}) => {
|
||||
const showApproval =
|
||||
approvalMode !== undefined && approvalMode !== ApprovalMode.DEFAULT;
|
||||
const showContext = promptTokenCount > 0 && contextWindowSize !== undefined;
|
||||
|
||||
if (!showApproval && !showContext) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
justifyContent="space-between"
|
||||
width="100%"
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
>
|
||||
<Box marginLeft={2}>
|
||||
{showApproval ? (
|
||||
<AutoAcceptIndicator approvalMode={approvalMode} />
|
||||
) : null}
|
||||
</Box>
|
||||
<Box marginRight={2}>
|
||||
{showContext && (
|
||||
<Text color={theme.text.accent}>
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={promptTokenCount}
|
||||
terminalWidth={terminalWidth}
|
||||
contextWindowSize={contextWindowSize!}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
@ -8,7 +8,12 @@
|
|||
* @fileoverview AgentTabBar — horizontal tab strip for in-process agent views.
|
||||
*
|
||||
* Rendered at the top of the terminal whenever in-process agents are registered.
|
||||
* Left/Right arrow keys cycle through tabs when the input buffer is empty.
|
||||
*
|
||||
* On the main tab, Left/Right switch tabs when the input buffer is empty.
|
||||
* On agent tabs, the tab bar uses an exclusive-focus model:
|
||||
* - Down arrow at the input's bottom edge focuses the tab bar
|
||||
* - Left/Right switch tabs only when the tab bar is focused
|
||||
* - Up arrow or typing returns focus to the input
|
||||
*
|
||||
* Tab indicators: running, idle/completed, failed, cancelled
|
||||
*/
|
||||
|
|
@ -36,6 +41,8 @@ function statusIndicator(agent: RegisteredAgent): {
|
|||
case AgentStatus.RUNNING:
|
||||
case AgentStatus.INITIALIZING:
|
||||
return { symbol: '\u25CF', color: theme.status.warning }; // ● running
|
||||
case AgentStatus.IDLE:
|
||||
return { symbol: '\u25CF', color: theme.status.success }; // ● idle (ready)
|
||||
case AgentStatus.COMPLETED:
|
||||
return { symbol: '\u2713', color: theme.status.success }; // ✓ completed
|
||||
case AgentStatus.FAILED:
|
||||
|
|
@ -50,20 +57,32 @@ function statusIndicator(agent: RegisteredAgent): {
|
|||
// ─── Component ──────────────────────────────────────────────
|
||||
|
||||
export const AgentTabBar: React.FC = () => {
|
||||
const { activeView, agents, agentShellFocused } = useAgentViewState();
|
||||
const { switchToNext, switchToPrevious } = useAgentViewActions();
|
||||
const { buffer, embeddedShellFocused } = useUIState();
|
||||
const { activeView, agents, agentShellFocused, agentTabBarFocused } =
|
||||
useAgentViewState();
|
||||
const { switchToNext, switchToPrevious, setAgentTabBarFocused } =
|
||||
useAgentViewActions();
|
||||
const { embeddedShellFocused } = useUIState();
|
||||
|
||||
// Left/Right arrow keys switch tabs when the input buffer is empty
|
||||
// and no embedded shell (main or agent tab) has input focus.
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (buffer.text !== '' || embeddedShellFocused || agentShellFocused)
|
||||
return;
|
||||
if (embeddedShellFocused || agentShellFocused) return;
|
||||
if (!agentTabBarFocused) return;
|
||||
|
||||
if (key.name === 'left') {
|
||||
switchToPrevious();
|
||||
} else if (key.name === 'right') {
|
||||
switchToNext();
|
||||
} else if (key.name === 'up') {
|
||||
setAgentTabBarFocused(false);
|
||||
} else if (
|
||||
key.sequence &&
|
||||
key.sequence.length === 1 &&
|
||||
!key.ctrl &&
|
||||
!key.meta
|
||||
) {
|
||||
// Printable character → return focus to input (key falls through
|
||||
// to BaseTextInput's useKeypress and gets typed normally)
|
||||
setAgentTabBarFocused(false);
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
|
|
@ -89,12 +108,18 @@ export const AgentTabBar: React.FC = () => {
|
|||
return () => cleanups.forEach((fn) => fn());
|
||||
}, [agents, forceRender]);
|
||||
|
||||
const isFocused = agentTabBarFocused;
|
||||
|
||||
// Navigation hint varies by context
|
||||
const hint = isFocused ? '\u2190/\u2192 switch \u2191 input' : '\u2193 tabs';
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" paddingX={1}>
|
||||
{/* Main tab */}
|
||||
<Box marginRight={1}>
|
||||
<Text
|
||||
bold={activeView === 'main'}
|
||||
dimColor={!isFocused}
|
||||
backgroundColor={
|
||||
activeView === 'main' ? theme.border.default : undefined
|
||||
}
|
||||
|
|
@ -107,7 +132,9 @@ export const AgentTabBar: React.FC = () => {
|
|||
</Box>
|
||||
|
||||
{/* Separator */}
|
||||
<Text color={theme.border.default}>{'\u2502'}</Text>
|
||||
<Text dimColor={!isFocused} color={theme.border.default}>
|
||||
{'\u2502'}
|
||||
</Text>
|
||||
|
||||
{/* Agent tabs */}
|
||||
{[...agents.entries()].map(([agentId, agent]) => {
|
||||
|
|
@ -118,19 +145,22 @@ export const AgentTabBar: React.FC = () => {
|
|||
<Box key={agentId} marginLeft={1}>
|
||||
<Text
|
||||
bold={isActive}
|
||||
dimColor={!isFocused}
|
||||
backgroundColor={isActive ? theme.border.default : undefined}
|
||||
color={isActive ? undefined : agent.color || theme.text.secondary}
|
||||
>
|
||||
{` ${agent.displayName} `}
|
||||
</Text>
|
||||
<Text color={indicatorColor}>{` ${symbol}`}</Text>
|
||||
<Text dimColor={!isFocused} color={indicatorColor}>
|
||||
{` ${symbol}`}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Navigation hint */}
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.text.secondary}>←/→</Text>
|
||||
<Text color={theme.text.secondary}>{hint}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,4 +6,6 @@
|
|||
|
||||
export { AgentTabBar } from './AgentTabBar.js';
|
||||
export { AgentChatView } from './AgentChatView.js';
|
||||
export { AgentComposer } from './AgentComposer.js';
|
||||
export { AgentFooter } from './AgentFooter.js';
|
||||
export { agentMessagesToHistoryItems } from './agentHistoryAdapter.js';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue