feat(agent): enhance interactive CLI with colors, spinner, and status bar (#43)

* feat(agent): improve interactive CLI with colors, spinner, and status bar

- Add colors.ts module with ANSI terminal color utilities
- Add spinner animation for tool execution feedback
- Add persistent status bar showing session/provider/model
- Apply colors to welcome banner, prompts, commands, and suggestions
- Support NO_COLOR env for accessibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(agent): correct cursor position with ANSI-colored prompts

Strip ANSI escape codes when calculating visual length of prompt
to ensure cursor is positioned correctly after colored text.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(agent): prevent duplicate input echo in interactive CLI

Lazy-initialize readline.Interface only when multiline mode is active.
This prevents readline from interfering with autocomplete's raw mode,
which was causing user input to be echoed twice.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor(agent): move CLI files to dedicated cli/ directory

Reorganize CLI-related files into src/agent/cli/ for better separation:
- interactive.ts (was interactive-cli.ts)
- non-interactive.ts (was cli.ts)
- profile.ts, skills.ts, tools.ts (was *-cli.ts)
- autocomplete.ts, colors.ts, output.ts (CLI utilities)

Update all imports, package.json scripts, and build configuration.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jiayuan Zhang 2026-01-31 21:07:07 +08:00 committed by GitHub
parent b7e1063b3a
commit b1d80f29ae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 437 additions and 87 deletions

View file

@ -0,0 +1,313 @@
/**
* Autocomplete Input
*
* Real-time dropdown autocomplete for terminal input
* No external dependencies - uses raw terminal control
*/
import * as readline from "readline";
import { colors } from "./colors.js";
export interface AutocompleteOption {
value: string;
label?: string;
}
export interface AutocompleteConfig {
/** Function to get suggestions based on current input */
getSuggestions: (input: string) => AutocompleteOption[];
/** Prompt string */
prompt?: string;
/** Max suggestions to show */
maxSuggestions?: number;
}
// ANSI escape codes
const ESC = "\x1b";
const CLEAR_LINE = `${ESC}[2K`;
const CURSOR_UP = (n: number) => (n > 0 ? `${ESC}[${n}A` : "");
const CURSOR_TO_COL = (n: number) => `${ESC}[${n}G`;
const RESET = `${ESC}[0m`;
const INVERSE = `${ESC}[7m`;
const SHOW_CURSOR = `${ESC}[?25h`;
const SAVE_CURSOR = `${ESC}[s`;
const RESTORE_CURSOR = `${ESC}[u`;
const CLEAR_TO_END = `${ESC}[J`;
const CURSOR_DOWN = (n: number) => (n > 0 ? `${ESC}[${n}B` : "");
// Strip ANSI escape codes to get visual length
const ANSI_REGEX = /\x1b\[[0-9;]*m/g;
function stripAnsi(str: string): string {
return str.replace(ANSI_REGEX, "");
}
/**
* Read a line with real-time autocomplete dropdown
*/
export function autocompleteInput(config: AutocompleteConfig): Promise<string> {
return new Promise((resolve) => {
const { getSuggestions, prompt = "> ", maxSuggestions = 5 } = config;
const stdin = process.stdin;
const stdout = process.stdout;
let input = "";
let cursorPos = 0;
let suggestions: AutocompleteOption[] = [];
let selectedIndex = -1;
let initialized = false;
// Enable raw mode
if (stdin.isTTY) {
stdin.setRawMode(true);
}
readline.emitKeypressEvents(stdin);
const cleanup = () => {
stdout.write(SHOW_CURSOR);
if (stdin.isTTY) {
stdin.setRawMode(false);
}
stdin.removeListener("keypress", onKeypress);
};
const render = () => {
if (!initialized) {
// First render - save cursor position as anchor
stdout.write(SAVE_CURSOR);
initialized = true;
} else {
// Restore to anchor and clear everything after it
stdout.write(RESTORE_CURSOR);
stdout.write(CLEAR_TO_END);
// Re-save in case terminal scrolled
stdout.write(SAVE_CURSOR);
}
// Write prompt and input
stdout.write(`${prompt}${input}`);
// Calculate cursor position accounting for line wrapping
const termWidth = stdout.columns || 80;
const promptVisualLen = stripAnsi(prompt).length;
const cursorOffset = promptVisualLen + cursorPos;
// Handle edge case: when cursor is exactly at line boundary,
// show it at end of current line, not start of next line
let cursorRow: number;
let cursorCol: number;
if (cursorOffset > 0 && cursorOffset % termWidth === 0) {
cursorRow = cursorOffset / termWidth - 1;
cursorCol = termWidth;
} else {
cursorRow = Math.floor(cursorOffset / termWidth);
cursorCol = (cursorOffset % termWidth) + 1;
}
// Calculate total lines for suggestions positioning
const totalLength = promptVisualLen + input.length;
const totalLines = Math.ceil(totalLength / termWidth) || 1;
// Get and display suggestions if input starts with /
if (input.startsWith("/") && input.length > 1) {
suggestions = getSuggestions(input).slice(0, maxSuggestions);
if (suggestions.length > 0) {
// Ensure selectedIndex is valid
if (selectedIndex >= suggestions.length) {
selectedIndex = suggestions.length - 1;
}
// Move to end of input text before showing suggestions
// Cursor is currently at end of text, just go to new line
stdout.write("\n");
for (let i = 0; i < suggestions.length; i++) {
const opt = suggestions[i]!;
const isSelected = i === selectedIndex;
const value = isSelected
? `${INVERSE} ${opt.value}${RESET}`
: ` ${colors.suggestionDim(opt.value)}`;
const label = opt.label ? ` ${colors.suggestionLabel(opt.label)}` : "";
const line = `${value}${label}`;
stdout.write(`${CLEAR_LINE}${line}`);
if (i < suggestions.length - 1) {
stdout.write("\n");
}
}
// Move cursor back up to input line (accounting for wrapped lines)
const linesFromEnd = totalLines - 1 - cursorRow;
stdout.write(CURSOR_UP(suggestions.length + linesFromEnd));
stdout.write(CURSOR_TO_COL(cursorCol));
}
} else {
suggestions = [];
selectedIndex = -1;
}
// Position cursor for wrapped text
// After writing, cursor is at end of text. Move to correct position.
// Go back to start of input block, then move to target row/col
const endRow = totalLines - 1;
if (endRow > cursorRow) {
stdout.write(CURSOR_UP(endRow - cursorRow));
}
stdout.write(CURSOR_TO_COL(cursorCol));
};
const submit = (value: string) => {
// Clear suggestions before submitting
stdout.write(RESTORE_CURSOR);
stdout.write(CLEAR_TO_END);
stdout.write(`${prompt}${value}\n`);
cleanup();
resolve(value);
};
const onKeypress = (_char: string, key: readline.Key) => {
if (!key) return;
// Handle Ctrl+C
if (key.ctrl && key.name === "c") {
cleanup();
process.exit(0);
}
// Handle Ctrl+D (EOF)
if (key.ctrl && key.name === "d") {
cleanup();
stdout.write("\n");
resolve("");
return;
}
// Handle Enter
if (key.name === "return" || key.name === "enter") {
if (selectedIndex >= 0 && selectedIndex < suggestions.length) {
// Use selected suggestion
const selected = suggestions[selectedIndex]!;
submit(selected.value);
} else {
submit(input);
}
return;
}
// Handle Tab - cycle through suggestions or complete selected one
if (key.name === "tab") {
if (suggestions.length > 0) {
if (selectedIndex >= 0) {
// Already have a selection - complete it to input
const selected = suggestions[selectedIndex]!;
input = selected.value + " ";
cursorPos = input.length;
selectedIndex = -1;
render();
} else {
// No selection yet - select first item
if (key.shift) {
selectedIndex = suggestions.length - 1;
} else {
selectedIndex = 0;
}
render();
}
}
return;
}
// Handle arrow keys
if (key.name === "up") {
if (suggestions.length > 0) {
selectedIndex = selectedIndex <= 0 ? suggestions.length - 1 : selectedIndex - 1;
render();
}
return;
}
if (key.name === "down") {
if (suggestions.length > 0) {
selectedIndex = selectedIndex >= suggestions.length - 1 ? 0 : selectedIndex + 1;
render();
}
return;
}
// Handle Escape - clear selection
if (key.name === "escape") {
selectedIndex = -1;
render();
return;
}
// Handle backspace
if (key.name === "backspace") {
if (cursorPos > 0) {
input = input.slice(0, cursorPos - 1) + input.slice(cursorPos);
cursorPos--;
selectedIndex = -1;
render();
}
return;
}
// Handle delete
if (key.name === "delete") {
if (cursorPos < input.length) {
input = input.slice(0, cursorPos) + input.slice(cursorPos + 1);
selectedIndex = -1;
render();
}
return;
}
// Handle left arrow
if (key.name === "left") {
if (cursorPos > 0) {
cursorPos--;
render();
}
return;
}
// Handle right arrow
if (key.name === "right") {
if (cursorPos < input.length) {
cursorPos++;
render();
}
return;
}
// Handle home
if (key.name === "home" || (key.ctrl && key.name === "a")) {
cursorPos = 0;
render();
return;
}
// Handle end
if (key.name === "end" || (key.ctrl && key.name === "e")) {
cursorPos = input.length;
render();
return;
}
// Handle printable characters
if (key.sequence && !key.ctrl && !key.meta) {
const char = key.sequence;
if (char.length === 1 && char.charCodeAt(0) >= 32) {
input = input.slice(0, cursorPos) + char + input.slice(cursorPos);
cursorPos++;
selectedIndex = -1;
render();
}
}
};
stdin.on("keypress", onKeypress);
render();
});
}