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:
tanzhenxin 2026-03-10 16:53:10 +08:00
parent eaef9efe90
commit 89f8751233
19 changed files with 1273 additions and 337 deletions

View 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>
);
};

View file

@ -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

View file

@ -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%"

View file

@ -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)"
`;

View 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>
);
};

View 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>
);
};

View file

@ -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>
);

View file

@ -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';