diff --git a/apps/cli/CHANGELOG.md b/apps/cli/CHANGELOG.md new file mode 100644 index 0000000000..e945354bf2 --- /dev/null +++ b/apps/cli/CHANGELOG.md @@ -0,0 +1,36 @@ +# Changelog + +All notable changes to the `@roo-code/cli` package will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.0.43] - Unreleased + +### Added + +- **Toast Notification System**: New toast notifications for user feedback with support for info, success, warning, and error types. Toasts auto-dismiss after a configurable duration and are managed via Zustand store. + + - New [`ToastDisplay`](src/ui/components/ToastDisplay.tsx) component for rendering toast messages + - New [`useToast`](src/ui/hooks/useToast.ts) hook for managing toast state and displaying notifications + +- **Global Input Sequences Registry**: Centralized system for handling keyboard shortcuts at the application level, preventing conflicts with input components. + + - New [`globalInputSequences.ts`](src/ui/utils/globalInputSequences.ts) utility module + - Support for Kitty keyboard protocol (CSI u encoding) for better terminal compatibility + - Built-in sequences for `Ctrl+C` (exit) and `Ctrl+M` (mode cycling) + +- **Local Tarball Installation**: The install script now supports installing from a local tarball via the `ROO_LOCAL_TARBALL` environment variable, useful for offline installation or testing pre-release builds. + +### Changed + +- **MultilineTextInput**: Updated to respect global input sequences, preventing the component from consuming shortcuts meant for application-level handling. + +### Tests + +- Added comprehensive tests for the toast notification system +- Added tests for global input sequence matching + +## [0.0.42] - 2025-01-07 + +The cli is alive! diff --git a/apps/cli/install.sh b/apps/cli/install.sh index 9980d41d42..1b01e51aa5 100755 --- a/apps/cli/install.sh +++ b/apps/cli/install.sh @@ -3,9 +3,10 @@ # Usage: curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh # # Environment variables: -# ROO_INSTALL_DIR - Installation directory (default: ~/.roo/cli) -# ROO_BIN_DIR - Binary symlink directory (default: ~/.local/bin) -# ROO_VERSION - Specific version to install (default: latest) +# ROO_INSTALL_DIR - Installation directory (default: ~/.roo/cli) +# ROO_BIN_DIR - Binary symlink directory (default: ~/.local/bin) +# ROO_VERSION - Specific version to install (default: latest) +# ROO_LOCAL_TARBALL - Path to local tarball to install (skips download) set -e @@ -83,6 +84,13 @@ detect_platform() { # Get latest release version or use specified version get_version() { + # Skip version fetch if using local tarball + if [ -n "$ROO_LOCAL_TARBALL" ]; then + VERSION="${ROO_VERSION:-local}" + info "Using local tarball (version: $VERSION)" + return + fi + if [ -n "$ROO_VERSION" ]; then VERSION="$ROO_VERSION" info "Using specified version: $VERSION" @@ -97,10 +105,10 @@ get_version() { } # Extract the latest cli-v* tag - VERSION=$(echo "$RELEASES_JSON" | - grep -o '"tag_name": "cli-v[^"]*"' | - head -1 | - sed 's/"tag_name": "cli-v//' | + VERSION=$(echo "$RELEASES_JSON" | + grep -o '"tag_name": "cli-v[^"]*"' | + head -1 | + sed 's/"tag_name": "cli-v//' | sed 's/"//') if [ -z "$VERSION" ]; then @@ -113,27 +121,37 @@ get_version() { # Download and extract download_and_install() { TARBALL="roo-cli-${PLATFORM}.tar.gz" - URL="https://github.com/$REPO/releases/download/cli-v${VERSION}/${TARBALL}" - - info "Downloading from $URL..." # Create temp directory TMP_DIR=$(mktemp -d) trap "rm -rf $TMP_DIR" EXIT - # Download with progress indicator - HTTP_CODE=$(curl -fsSL -w "%{http_code}" "$URL" -o "$TMP_DIR/$TARBALL" 2>/dev/null) || { - if [ "$HTTP_CODE" = "404" ]; then - error "Release not found for platform $PLATFORM version $VERSION. + # Use local tarball if provided, otherwise download + if [ -n "$ROO_LOCAL_TARBALL" ]; then + if [ ! -f "$ROO_LOCAL_TARBALL" ]; then + error "Local tarball not found: $ROO_LOCAL_TARBALL" + fi + info "Using local tarball: $ROO_LOCAL_TARBALL" + cp "$ROO_LOCAL_TARBALL" "$TMP_DIR/$TARBALL" + else + URL="https://github.com/$REPO/releases/download/cli-v${VERSION}/${TARBALL}" + + info "Downloading from $URL..." + + # Download with progress indicator + HTTP_CODE=$(curl -fsSL -w "%{http_code}" "$URL" -o "$TMP_DIR/$TARBALL" 2>/dev/null) || { + if [ "$HTTP_CODE" = "404" ]; then + error "Release not found for platform $PLATFORM version $VERSION. Available at: https://github.com/$REPO/releases" - fi - error "Download failed. HTTP code: $HTTP_CODE" - } + fi + error "Download failed. HTTP code: $HTTP_CODE" + } - # Verify we got something - if [ ! -s "$TMP_DIR/$TARBALL" ]; then - error "Downloaded file is empty. Please try again." + # Verify we got something + if [ ! -s "$TMP_DIR/$TARBALL" ]; then + error "Downloaded file is empty. Please try again." + fi fi # Remove old installation if exists diff --git a/apps/cli/package.json b/apps/cli/package.json index 3338e6337e..5bfbd1cd3e 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@roo-code/cli", - "version": "0.2.2", + "version": "0.0.42", "description": "Roo Code CLI - Run the Roo Code agent from the command line", "private": true, "type": "module", @@ -16,6 +16,7 @@ "build": "tsup", "dev": "tsup --watch", "start": "node dist/index.js", + "release": "scripts/release.sh", "clean": "rimraf dist .turbo" }, "dependencies": { diff --git a/apps/cli/scripts/release.sh b/apps/cli/scripts/release.sh index 3ade690252..2c52f77709 100755 --- a/apps/cli/scripts/release.sh +++ b/apps/cli/scripts/release.sh @@ -60,7 +60,7 @@ detect_platform() { # Check prerequisites check_prerequisites() { - step "1/7" "Checking prerequisites..." + step "1/8" "Checking prerequisites..." if ! command -v gh &> /dev/null; then error "GitHub CLI (gh) is not installed. Install it with: brew install gh" @@ -98,13 +98,71 @@ get_version() { info "Version: $VERSION (tag: $TAG)" } +# Extract changelog content for a specific version +# Returns the content between the version header and the next version header (or EOF) +get_changelog_content() { + CHANGELOG_FILE="$CLI_DIR/CHANGELOG.md" + + if [ ! -f "$CHANGELOG_FILE" ]; then + warn "No CHANGELOG.md found at $CHANGELOG_FILE" + CHANGELOG_CONTENT="" + return + fi + + # Try to find the version section (handles both "[0.0.43]" and "[0.0.43] - date" formats) + # Also handles "Unreleased" marker + VERSION_PATTERN="^\#\# \[${VERSION}\]" + + # Check if the version exists in the changelog + if ! grep -qE "$VERSION_PATTERN" "$CHANGELOG_FILE"; then + warn "No changelog entry found for version $VERSION" + warn "Please add an entry to $CHANGELOG_FILE before releasing" + echo "" + echo "Expected format:" + echo " ## [$VERSION] - $(date +%Y-%m-%d)" + echo " " + echo " ### Added" + echo " - Your changes here" + echo "" + read -p "Continue without changelog content? [y/N] " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + error "Aborted. Please add a changelog entry and try again." + fi + CHANGELOG_CONTENT="" + return + fi + + # Extract content between this version and the next version header (or EOF) + # Uses awk to capture everything between ## [VERSION] and the next ## [ + # Using index() with "[VERSION]" ensures exact matching (1.0.1 won't match 1.0.10) + CHANGELOG_CONTENT=$(awk -v version="$VERSION" ' + BEGIN { found = 0; content = ""; target = "[" version "]" } + /^## \[/ { + if (found) { exit } + if (index($0, target) > 0) { found = 1; next } + } + found { content = content $0 "\n" } + END { print content } + ' "$CHANGELOG_FILE") + + # Trim leading/trailing whitespace + CHANGELOG_CONTENT=$(echo "$CHANGELOG_CONTENT" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') + + if [ -n "$CHANGELOG_CONTENT" ]; then + info "Found changelog content for version $VERSION" + else + warn "Changelog entry for $VERSION appears to be empty" + fi +} + # Build everything build() { - step "2/7" "Building extension bundle..." + step "2/8" "Building extension bundle..." cd "$REPO_ROOT" pnpm bundle - step "3/7" "Building CLI..." + step "3/8" "Building CLI..." pnpm --filter @roo-code/cli build info "Build complete" @@ -112,7 +170,7 @@ build() { # Create release tarball create_tarball() { - step "4/7" "Creating release tarball for $PLATFORM..." + step "4/8" "Creating release tarball for $PLATFORM..." RELEASE_DIR="$REPO_ROOT/roo-cli-${PLATFORM}" TARBALL="roo-cli-${PLATFORM}.tar.gz" @@ -141,6 +199,7 @@ create_tarball() { dependencies: { '@inkjs/ui': pkg.dependencies['@inkjs/ui'], 'commander': pkg.dependencies.commander, + 'fuzzysort': pkg.dependencies.fuzzysort, 'ink': pkg.dependencies.ink, 'react': pkg.dependencies.react, 'zustand': pkg.dependencies.zustand @@ -218,9 +277,91 @@ WRAPPER_EOF info "Created: $TARBALL ($TARBALL_SIZE)" } +# Verify local installation +verify_local_install() { + step "5/8" "Verifying local installation..." + + VERIFY_DIR="$REPO_ROOT/.verify-release" + VERIFY_INSTALL_DIR="$VERIFY_DIR/cli" + VERIFY_BIN_DIR="$VERIFY_DIR/bin" + + # Clean up any previous verification directory + rm -rf "$VERIFY_DIR" + mkdir -p "$VERIFY_DIR" + + # Run the actual install script with the local tarball + info "Running install script with local tarball..." + TARBALL_PATH="$REPO_ROOT/$TARBALL" + + ROO_LOCAL_TARBALL="$TARBALL_PATH" \ + ROO_INSTALL_DIR="$VERIFY_INSTALL_DIR" \ + ROO_BIN_DIR="$VERIFY_BIN_DIR" \ + ROO_VERSION="$VERSION" \ + "$CLI_DIR/install.sh" || { + echo "" + warn "Install script failed. Showing tarball contents:" + tar -tzf "$TARBALL_PATH" 2>&1 || true + echo "" + rm -rf "$VERIFY_DIR" + error "Installation verification failed! The install script could not complete successfully." + } + + # Verify the CLI runs correctly with basic commands + info "Testing installed CLI..." + + # Test --help + if ! "$VERIFY_BIN_DIR/roo" --help > /dev/null 2>&1; then + echo "" + warn "CLI --help output:" + "$VERIFY_BIN_DIR/roo" --help 2>&1 || true + echo "" + rm -rf "$VERIFY_DIR" + error "CLI --help check failed! The release tarball may have missing dependencies." + fi + info "CLI --help check passed" + + # Test --version + if ! "$VERIFY_BIN_DIR/roo" --version > /dev/null 2>&1; then + echo "" + warn "CLI --version output:" + "$VERIFY_BIN_DIR/roo" --version 2>&1 || true + echo "" + rm -rf "$VERIFY_DIR" + error "CLI --version check failed! The release tarball may have missing dependencies." + fi + info "CLI --version check passed" + + # Run a simple end-to-end test to verify the CLI actually works + info "Running end-to-end verification test..." + + # Create a temporary workspace for the test + VERIFY_WORKSPACE="$VERIFY_DIR/workspace" + mkdir -p "$VERIFY_WORKSPACE" + + # Run the CLI with a simple prompt + # Use timeout to prevent hanging if something goes wrong + if timeout 60 "$VERIFY_BIN_DIR/roo" --yes --exit-on-complete --prompt "1+1=?" "$VERIFY_WORKSPACE" > "$VERIFY_DIR/test-output.log" 2>&1; then + info "End-to-end test passed" + else + EXIT_CODE=$? + echo "" + warn "End-to-end test failed (exit code: $EXIT_CODE). Output:" + cat "$VERIFY_DIR/test-output.log" 2>&1 || true + echo "" + rm -rf "$VERIFY_DIR" + error "CLI end-to-end test failed! The CLI may be broken." + fi + + # Clean up verification directory + cd "$REPO_ROOT" + rm -rf "$VERIFY_DIR" + + info "Local verification passed!" +} + # Create checksum create_checksum() { - step "5/7" "Creating checksum..." + step "6/8" "Creating checksum..." cd "$REPO_ROOT" if command -v sha256sum &> /dev/null; then @@ -237,7 +378,7 @@ create_checksum() { # Check if release already exists check_existing_release() { - step "6/7" "Checking for existing release..." + step "7/8" "Checking for existing release..." if gh release view "$TAG" &> /dev/null; then warn "Release $TAG already exists" @@ -257,11 +398,21 @@ check_existing_release() { # Create GitHub release create_release() { - step "7/7" "Creating GitHub release..." + step "8/8" "Creating GitHub release..." cd "$REPO_ROOT" + # Build the What's New section from changelog content + WHATS_NEW_SECTION="" + if [ -n "$CHANGELOG_CONTENT" ]; then + WHATS_NEW_SECTION="## What's New + +$CHANGELOG_CONTENT + +" + fi + RELEASE_NOTES=$(cat << EOF -## Installation +${WHATS_NEW_SECTION}## Installation \`\`\`bash curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh @@ -284,7 +435,7 @@ ROO_VERSION=$VERSION curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo export OPENROUTER_API_KEY=sk-or-v1-... # Run a task -roo "What is this project?" --workspace ~/my-project +roo "What is this project?" ~/my-project # See all options roo --help @@ -358,8 +509,10 @@ main() { detect_platform check_prerequisites get_version "$1" + get_changelog_content build create_tarball + verify_local_install create_checksum check_existing_release create_release diff --git a/apps/cli/src/__tests__/globalInputSequences.test.ts b/apps/cli/src/__tests__/globalInputSequences.test.ts new file mode 100644 index 0000000000..7ec8afa367 --- /dev/null +++ b/apps/cli/src/__tests__/globalInputSequences.test.ts @@ -0,0 +1,135 @@ +import type { Key } from "ink" + +import { + GLOBAL_INPUT_SEQUENCES, + isGlobalInputSequence, + matchesGlobalSequence, +} from "../ui/utils/globalInputSequences.js" + +/** + * Helper to create a minimal Key object for testing + */ +function createKey(overrides: Partial = {}): Key { + return { + upArrow: false, + downArrow: false, + leftArrow: false, + rightArrow: false, + pageDown: false, + pageUp: false, + home: false, + end: false, + return: false, + escape: false, + ctrl: false, + shift: false, + tab: false, + backspace: false, + delete: false, + meta: false, + ...overrides, + } +} + +describe("globalInputSequences", () => { + describe("GLOBAL_INPUT_SEQUENCES registry", () => { + it("should have ctrl-c registered", () => { + const seq = GLOBAL_INPUT_SEQUENCES.find((s) => s.id === "ctrl-c") + expect(seq).toBeDefined() + expect(seq?.description).toContain("Exit") + }) + + it("should have ctrl-m registered", () => { + const seq = GLOBAL_INPUT_SEQUENCES.find((s) => s.id === "ctrl-m") + expect(seq).toBeDefined() + expect(seq?.description).toContain("mode") + }) + }) + + describe("isGlobalInputSequence", () => { + describe("Ctrl+C detection", () => { + it("should match standard Ctrl+C", () => { + const result = isGlobalInputSequence("c", createKey({ ctrl: true })) + expect(result).toBeDefined() + expect(result?.id).toBe("ctrl-c") + }) + + it("should not match plain 'c' key", () => { + const result = isGlobalInputSequence("c", createKey()) + expect(result).toBeUndefined() + }) + }) + + describe("Ctrl+M detection", () => { + it("should match standard Ctrl+M", () => { + const result = isGlobalInputSequence("m", createKey({ ctrl: true })) + expect(result).toBeDefined() + expect(result?.id).toBe("ctrl-m") + }) + + it("should match CSI u encoding for Ctrl+M", () => { + const result = isGlobalInputSequence("\x1b[109;5u", createKey()) + expect(result).toBeDefined() + expect(result?.id).toBe("ctrl-m") + }) + + it("should match input ending with CSI u sequence", () => { + const result = isGlobalInputSequence("[109;5u", createKey()) + expect(result).toBeDefined() + expect(result?.id).toBe("ctrl-m") + }) + + it("should not match plain 'm' key", () => { + const result = isGlobalInputSequence("m", createKey()) + expect(result).toBeUndefined() + }) + }) + + it("should return undefined for non-global sequences", () => { + const result = isGlobalInputSequence("a", createKey()) + expect(result).toBeUndefined() + }) + + it("should return undefined for regular text input", () => { + const result = isGlobalInputSequence("hello", createKey()) + expect(result).toBeUndefined() + }) + }) + + describe("matchesGlobalSequence", () => { + it("should return true for matching sequence ID", () => { + const result = matchesGlobalSequence("c", createKey({ ctrl: true }), "ctrl-c") + expect(result).toBe(true) + }) + + it("should return false for non-matching sequence ID", () => { + const result = matchesGlobalSequence("c", createKey({ ctrl: true }), "ctrl-m") + expect(result).toBe(false) + }) + + it("should return false for non-existent sequence ID", () => { + const result = matchesGlobalSequence("c", createKey({ ctrl: true }), "non-existent") + expect(result).toBe(false) + }) + + it("should match ctrl-m with CSI u encoding", () => { + const result = matchesGlobalSequence("\x1b[109;5u", createKey(), "ctrl-m") + expect(result).toBe(true) + }) + }) + + describe("extensibility", () => { + it("should have unique IDs for all sequences", () => { + const ids = GLOBAL_INPUT_SEQUENCES.map((s) => s.id) + const uniqueIds = new Set(ids) + expect(uniqueIds.size).toBe(ids.length) + }) + + it("should have descriptions for all sequences", () => { + for (const seq of GLOBAL_INPUT_SEQUENCES) { + expect(seq.description).toBeTruthy() + expect(seq.description.length).toBeGreaterThan(0) + } + }) + }) +}) diff --git a/apps/cli/src/__tests__/useToast.test.ts b/apps/cli/src/__tests__/useToast.test.ts new file mode 100644 index 0000000000..4083ee8618 --- /dev/null +++ b/apps/cli/src/__tests__/useToast.test.ts @@ -0,0 +1,190 @@ +import { useToastStore } from "../ui/hooks/useToast.js" + +describe("useToastStore", () => { + beforeEach(() => { + // Reset the store before each test + useToastStore.setState({ toasts: [] }) + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + describe("initial state", () => { + it("should start with an empty toast queue", () => { + const state = useToastStore.getState() + expect(state.toasts).toEqual([]) + }) + }) + + describe("addToast", () => { + it("should add a toast to the queue", () => { + const { addToast } = useToastStore.getState() + + const id = addToast("Test message") + + const state = useToastStore.getState() + expect(state.toasts).toHaveLength(1) + expect(state.toasts[0]).toMatchObject({ + id, + message: "Test message", + type: "info", + duration: 3000, + }) + }) + + it("should add a toast with custom type", () => { + const { addToast } = useToastStore.getState() + + const id = addToast("Error message", "error") + + const state = useToastStore.getState() + expect(state.toasts[0]).toMatchObject({ + id, + message: "Error message", + type: "error", + }) + }) + + it("should add a toast with custom duration", () => { + const { addToast } = useToastStore.getState() + + const id = addToast("Custom duration", "info", 5000) + + const state = useToastStore.getState() + expect(state.toasts[0]).toMatchObject({ + id, + duration: 5000, + }) + }) + + it("should replace existing toast when adding a new one (immediate display)", () => { + const { addToast } = useToastStore.getState() + + addToast("First message") + addToast("Second message") + addToast("Third message") + + const state = useToastStore.getState() + // New toasts replace existing ones for immediate display + expect(state.toasts).toHaveLength(1) + expect(state.toasts[0]?.message).toBe("Third message") + }) + + it("should generate unique IDs for each toast", () => { + const { addToast } = useToastStore.getState() + + const id1 = addToast("First") + const id2 = addToast("Second") + const id3 = addToast("Third") + + expect(id1).not.toBe(id2) + expect(id2).not.toBe(id3) + expect(id1).not.toBe(id3) + }) + + it("should set createdAt timestamp", () => { + const { addToast } = useToastStore.getState() + const beforeTime = Date.now() + + addToast("Timestamped message") + + const state = useToastStore.getState() + expect(state.toasts[0]?.createdAt).toBeGreaterThanOrEqual(beforeTime) + expect(state.toasts[0]?.createdAt).toBeLessThanOrEqual(Date.now()) + }) + + it("should support success type", () => { + const { addToast } = useToastStore.getState() + + addToast("Success", "success") + + const state = useToastStore.getState() + expect(state.toasts[0]?.type).toBe("success") + }) + + it("should support warning type", () => { + const { addToast } = useToastStore.getState() + + addToast("Warning", "warning") + + const state = useToastStore.getState() + expect(state.toasts[0]?.type).toBe("warning") + }) + }) + + describe("removeToast", () => { + it("should remove a toast by ID", () => { + const { addToast, removeToast } = useToastStore.getState() + + const id = addToast("Only toast") + + removeToast(id) + + const state = useToastStore.getState() + expect(state.toasts).toHaveLength(0) + }) + + it("should handle removing non-existent toast gracefully", () => { + const { addToast, removeToast } = useToastStore.getState() + + addToast("Only toast") + + removeToast("non-existent-id") + + const state = useToastStore.getState() + expect(state.toasts).toHaveLength(1) + }) + }) + + describe("clearToasts", () => { + it("should clear all toasts", () => { + const { addToast, clearToasts } = useToastStore.getState() + + addToast("First") + addToast("Second") + addToast("Third") + + clearToasts() + + const state = useToastStore.getState() + expect(state.toasts).toHaveLength(0) + }) + + it("should handle clearing empty queue", () => { + const { clearToasts } = useToastStore.getState() + + clearToasts() + + const state = useToastStore.getState() + expect(state.toasts).toHaveLength(0) + }) + }) + + describe("immediate replacement behavior", () => { + it("should show latest toast immediately when multiple are added", () => { + const { addToast } = useToastStore.getState() + + addToast("First") + addToast("Second") + const id3 = addToast("Third") + + const state = useToastStore.getState() + // Only most recent toast is present + expect(state.toasts).toHaveLength(1) + expect(state.toasts[0]?.id).toBe(id3) + expect(state.toasts[0]?.message).toBe("Third") + }) + + it("should return empty when toast is removed", () => { + const { addToast, removeToast } = useToastStore.getState() + + const id = addToast("Only toast") + removeToast(id) + + const state = useToastStore.getState() + expect(state.toasts).toHaveLength(0) + }) + }) +}) diff --git a/apps/cli/src/__tests__/utils.test.ts b/apps/cli/src/__tests__/utils.test.ts index 34ce825463..e3c57d7b60 100644 --- a/apps/cli/src/__tests__/utils.test.ts +++ b/apps/cli/src/__tests__/utils.test.ts @@ -80,8 +80,17 @@ describe("getApiKeyFromEnv", () => { }) describe("getDefaultExtensionPath", () => { + const originalEnv = process.env + beforeEach(() => { vi.resetAllMocks() + // Reset process.env to avoid ROO_EXTENSION_PATH from installed CLI affecting tests + process.env = { ...originalEnv } + delete process.env.ROO_EXTENSION_PATH + }) + + afterEach(() => { + process.env = originalEnv }) it("should return monorepo path when extension.js exists there", () => { diff --git a/apps/cli/src/ui/App.tsx b/apps/cli/src/ui/App.tsx index b0f4c5ec73..b334ca0416 100644 --- a/apps/cli/src/ui/App.tsx +++ b/apps/cli/src/ui/App.tsx @@ -13,6 +13,8 @@ import { getContextWindow } from "../utils/getContextWindow.js" import Header from "./components/Header.js" import ChatHistoryItem from "./components/ChatHistoryItem.js" import LoadingText from "./components/LoadingText.js" +import ToastDisplay from "./components/ToastDisplay.js" +import { useToast } from "./hooks/useToast.js" import { AutocompleteInput, PickerSelect, @@ -34,6 +36,7 @@ import { ScrollArea, useScrollToBottom } from "./components/ScrollArea.js" import ScrollIndicator from "./components/ScrollIndicator.js" import { TerminalSizeProvider, useTerminalSize } from "./hooks/TerminalSizeContext.js" import * as theme from "./utils/theme.js" +import { matchesGlobalSequence } from "./utils/globalInputSequences.js" import { FOLLOWUP_TIMEOUT_SECONDS } from "../constants.js" import type { AppProps, @@ -259,6 +262,9 @@ function AppInner({ const [scrollState, setScrollState] = useState({ scrollTop: 0, maxScroll: 0, isAtBottom: true }) const { scrollToBottomTrigger, scrollToBottom } = useScrollToBottom() + // Toast notifications for ephemeral messages (e.g., mode changes) + const { currentToast, showInfo } = useToast() + // Determine current view const view = getView(messages, pendingAsk, isLoading) @@ -357,7 +363,7 @@ function AppInner({ return [fileTrigger, slashCommandTrigger, modeTrigger, helpTrigger] }, [handleFileSearch]) // Only depend on handleFileSearch - data accessed via refs - // Handle Ctrl+C, Tab for focus switching, and Escape to cancel task + // Handle Ctrl+C, Tab for focus switching, Escape to cancel task, and Ctrl+M for mode cycling useInput((input, key) => { // Tab to toggle focus between scroll area and input (only when input is available) if (key.tab && canToggleFocus && !pickerState.isOpen) { @@ -369,6 +375,35 @@ function AppInner({ return } + // Ctrl+M to cycle through modes (only when not loading and we have available modes) + // Uses centralized global input sequence detection + if (matchesGlobalSequence(input, key, "ctrl-m")) { + // Don't allow mode switching while a task is in progress (loading) + if (isLoading) { + showInfo("Cannot switch modes while task is in progress", 2000) + return + } + + // Need at least 2 modes to cycle + if (availableModes.length < 2) { + return + } + + // Find current mode index + const currentModeSlug = currentMode || mode + const currentIndex = availableModes.findIndex((m) => m.slug === currentModeSlug) + const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % availableModes.length + const nextMode = availableModes[nextIndex] + + if (nextMode && hostRef.current) { + // Send mode change to extension + hostRef.current.sendToExtension({ type: "switchMode", mode: nextMode.slug }) + // Show toast notification with the mode name + showInfo(`Switched to ${nextMode.name}`, 2000) + } + return + } + // Escape key to cancel/pause task when loading (streaming) if (key.escape && isLoading && hostRef.current) { // If picker is open, let the picker handle escape first @@ -911,6 +946,9 @@ function AppInner({ seenMessageIds.current.clear() firstTextMessageSkipped.current = false hostRef.current.sendToExtension({ type: "clearTask" }) + // Re-request commands and modes since reset() cleared them. + hostRef.current.sendToExtension({ type: "requestCommands" }) + hostRef.current.sendToExtension({ type: "requestModes" }) return } } @@ -1081,8 +1119,11 @@ function AppInner({ } // Status bar content + // Priority: Toast > Exit hint > Loading > Scroll indicator > Input hint // Don't show spinner when waiting for user input (pendingAsk is set) - const statusBarMessage = showExitHint ? ( + const statusBarMessage = currentToast ? ( + + ) : showExitHint ? ( Press Ctrl+C again to exit ) : isLoading && !pendingAsk ? ( @@ -1103,7 +1144,7 @@ function AppInner({ ) : isScrollAreaActive ? ( ) : isInputAreaActive ? ( - ? for shortcuts + ? for shortcuts • Ctrl+M mode ) : null // Get render function for picker items based on active trigger diff --git a/apps/cli/src/ui/components/MultilineTextInput.tsx b/apps/cli/src/ui/components/MultilineTextInput.tsx index 32bbea8dc0..5e4e020445 100644 --- a/apps/cli/src/ui/components/MultilineTextInput.tsx +++ b/apps/cli/src/ui/components/MultilineTextInput.tsx @@ -16,6 +16,8 @@ import { useState, useEffect, useMemo, useCallback, useRef } from "react" import { Box, Text, useInput, type Key } from "ink" +import { isGlobalInputSequence } from "../utils/globalInputSequences.js" + export interface MultilineTextInputProps { /** * Current value (can contain newlines) @@ -240,8 +242,9 @@ export function MultilineTextInput({ return } - // Ctrl+C: ignore (handled elsewhere) - if (key.ctrl && input === "c") { + // Ignore inputs that are handled at the App level (global shortcuts) + // This includes Ctrl+C (exit), Ctrl+M (mode toggle), etc. + if (isGlobalInputSequence(input, key)) { return } diff --git a/apps/cli/src/ui/components/ToastDisplay.tsx b/apps/cli/src/ui/components/ToastDisplay.tsx new file mode 100644 index 0000000000..46eae788aa --- /dev/null +++ b/apps/cli/src/ui/components/ToastDisplay.tsx @@ -0,0 +1,69 @@ +import { memo } from "react" +import { Text, Box } from "ink" + +import type { Toast, ToastType } from "../hooks/useToast.js" +import * as theme from "../utils/theme.js" + +interface ToastDisplayProps { + /** The current toast to display (null if no toast) */ + toast: Toast | null +} + +/** + * Get the color for a toast based on its type + */ +function getToastColor(type: ToastType): string { + switch (type) { + case "success": + return theme.successColor + case "warning": + return theme.warningColor + case "error": + return theme.errorColor + case "info": + default: + return theme.focusColor // cyan for info + } +} + +/** + * Get the icon/prefix for a toast based on its type + */ +function getToastIcon(type: ToastType): string { + switch (type) { + case "success": + return "✓" + case "warning": + return "⚠" + case "error": + return "✗" + case "info": + default: + return "ℹ" + } +} + +/** + * ToastDisplay component for showing ephemeral messages in the status bar. + * + * Displays the current toast with appropriate styling based on type. + * When no toast is present, renders nothing. + */ +function ToastDisplay({ toast }: ToastDisplayProps) { + if (!toast) { + return null + } + + const color = getToastColor(toast.type) + const icon = getToastIcon(toast.type) + + return ( + + + {icon} {toast.message} + + + ) +} + +export default memo(ToastDisplay) diff --git a/apps/cli/src/ui/components/__tests__/ToastDisplay.test.tsx b/apps/cli/src/ui/components/__tests__/ToastDisplay.test.tsx new file mode 100644 index 0000000000..e8092971f1 --- /dev/null +++ b/apps/cli/src/ui/components/__tests__/ToastDisplay.test.tsx @@ -0,0 +1,87 @@ +import { render } from "ink-testing-library" +import { describe, it, expect } from "vitest" + +import type { Toast } from "../../hooks/useToast.js" +import ToastDisplay from "../ToastDisplay.js" + +describe("ToastDisplay", () => { + it("should render nothing when toast is null", () => { + const { lastFrame } = render() + + expect(lastFrame()).toBe("") + }) + + it("should render info toast with cyan color and info icon", () => { + const toast: Toast = { + id: "test-1", + message: "Info message", + type: "info", + duration: 3000, + createdAt: Date.now(), + } + + const { lastFrame } = render() + + expect(lastFrame()).toContain("Info message") + expect(lastFrame()).toContain("ℹ") + }) + + it("should render success toast with success icon", () => { + const toast: Toast = { + id: "test-2", + message: "Success message", + type: "success", + duration: 3000, + createdAt: Date.now(), + } + + const { lastFrame } = render() + + expect(lastFrame()).toContain("Success message") + expect(lastFrame()).toContain("✓") + }) + + it("should render warning toast with warning icon", () => { + const toast: Toast = { + id: "test-3", + message: "Warning message", + type: "warning", + duration: 3000, + createdAt: Date.now(), + } + + const { lastFrame } = render() + + expect(lastFrame()).toContain("Warning message") + expect(lastFrame()).toContain("⚠") + }) + + it("should render error toast with error icon", () => { + const toast: Toast = { + id: "test-4", + message: "Error message", + type: "error", + duration: 3000, + createdAt: Date.now(), + } + + const { lastFrame } = render() + + expect(lastFrame()).toContain("Error message") + expect(lastFrame()).toContain("✗") + }) + + it("should display the full message", () => { + const toast: Toast = { + id: "test-5", + message: "Switched to Code mode", + type: "info", + duration: 2000, + createdAt: Date.now(), + } + + const { lastFrame } = render() + + expect(lastFrame()).toContain("Switched to Code mode") + }) +}) diff --git a/apps/cli/src/ui/hooks/useToast.ts b/apps/cli/src/ui/hooks/useToast.ts new file mode 100644 index 0000000000..18d5bcda07 --- /dev/null +++ b/apps/cli/src/ui/hooks/useToast.ts @@ -0,0 +1,196 @@ +import { create } from "zustand" +import { useEffect, useCallback, useRef } from "react" + +/** + * Toast message types for different visual styles + */ +export type ToastType = "info" | "success" | "warning" | "error" + +/** + * A single toast message in the queue + */ +export interface Toast { + id: string + message: string + type: ToastType + /** Duration in milliseconds before auto-dismiss (default: 3000) */ + duration: number + /** Timestamp when the toast was created */ + createdAt: number +} + +/** + * Toast queue store state + */ +interface ToastState { + /** Queue of active toasts (FIFO - first one is displayed) */ + toasts: Toast[] + /** Add a toast to the queue */ + addToast: (message: string, type?: ToastType, duration?: number) => string + /** Remove a specific toast by ID */ + removeToast: (id: string) => void + /** Clear all toasts */ + clearToasts: () => void +} + +/** + * Default toast duration in milliseconds + */ +const DEFAULT_DURATION = 3000 + +/** + * Generate a unique ID for toasts + */ +let toastIdCounter = 0 +function generateToastId(): string { + return `toast-${Date.now()}-${++toastIdCounter}` +} + +/** + * Zustand store for toast queue management + */ +export const useToastStore = create((set) => ({ + toasts: [], + + addToast: (message: string, type: ToastType = "info", duration: number = DEFAULT_DURATION) => { + const id = generateToastId() + const toast: Toast = { + id, + message, + type, + duration, + createdAt: Date.now(), + } + + // Replace any existing toasts - new toast shows immediately + // This provides better UX as users see the most recent message right away + set(() => ({ + toasts: [toast], + })) + + return id + }, + + removeToast: (id: string) => { + set((state) => ({ + toasts: state.toasts.filter((t) => t.id !== id), + })) + }, + + clearToasts: () => { + set({ toasts: [] }) + }, +})) + +/** + * Hook for displaying and managing toasts with auto-expiry. + * Returns the current toast (if any) and utility functions. + * + * The hook handles auto-dismissal of toasts after their duration expires. + */ +export function useToast() { + const { toasts, addToast, removeToast, clearToasts } = useToastStore() + + // Track active timers for cleanup + const timersRef = useRef>(new Map()) + + // Get the current toast to display (first in queue) + const currentToast = toasts.length > 0 ? toasts[0] : null + + // Set up auto-dismissal timer for current toast + useEffect(() => { + if (!currentToast) { + return + } + + // Check if timer already exists for this toast + if (timersRef.current.has(currentToast.id)) { + return + } + + // Calculate remaining time (accounts for time already elapsed) + const elapsed = Date.now() - currentToast.createdAt + const remainingTime = Math.max(0, currentToast.duration - elapsed) + + const timer = setTimeout(() => { + removeToast(currentToast.id) + timersRef.current.delete(currentToast.id) + }, remainingTime) + + timersRef.current.set(currentToast.id, timer) + + return () => { + // Clean up timer if toast is removed before expiry + const existingTimer = timersRef.current.get(currentToast.id) + if (existingTimer) { + clearTimeout(existingTimer) + timersRef.current.delete(currentToast.id) + } + } + }, [currentToast?.id, currentToast?.createdAt, currentToast?.duration, removeToast]) + + // Cleanup all timers on unmount + useEffect(() => { + return () => { + timersRef.current.forEach((timer) => clearTimeout(timer)) + timersRef.current.clear() + } + }, []) + + // Convenience methods for different toast types + const showToast = useCallback( + (message: string, type?: ToastType, duration?: number) => { + return addToast(message, type, duration) + }, + [addToast], + ) + + const showInfo = useCallback( + (message: string, duration?: number) => { + return addToast(message, "info", duration) + }, + [addToast], + ) + + const showSuccess = useCallback( + (message: string, duration?: number) => { + return addToast(message, "success", duration) + }, + [addToast], + ) + + const showWarning = useCallback( + (message: string, duration?: number) => { + return addToast(message, "warning", duration) + }, + [addToast], + ) + + const showError = useCallback( + (message: string, duration?: number) => { + return addToast(message, "error", duration) + }, + [addToast], + ) + + return { + /** Current toast being displayed (first in queue) */ + currentToast, + /** All toasts in the queue */ + toasts, + /** Generic toast display method */ + showToast, + /** Show an info toast */ + showInfo, + /** Show a success toast */ + showSuccess, + /** Show a warning toast */ + showWarning, + /** Show an error toast */ + showError, + /** Remove a specific toast by ID */ + removeToast, + /** Clear all toasts */ + clearToasts, + } +} diff --git a/apps/cli/src/ui/utils/globalInputSequences.ts b/apps/cli/src/ui/utils/globalInputSequences.ts new file mode 100644 index 0000000000..8989ceea9c --- /dev/null +++ b/apps/cli/src/ui/utils/globalInputSequences.ts @@ -0,0 +1,109 @@ +/** + * Global Input Sequences Registry + * + * This module centralizes the definition of input sequences that should be + * handled at the App level (or other top-level components) and ignored by + * child components like MultilineTextInput. + * + * When adding new global shortcuts: + * 1. Add the sequence definition to GLOBAL_INPUT_SEQUENCES + * 2. The App.tsx useInput handler should check for and handle the sequence + * 3. Child components automatically ignore these via isGlobalInputSequence() + */ + +import type { Key } from "ink" + +/** + * Definition of a global input sequence + */ +export interface GlobalInputSequence { + /** Unique identifier for the sequence */ + id: string + /** Human-readable description */ + description: string + /** + * Matcher function - returns true if the input matches this sequence. + * @param input - The raw input string from useInput + * @param key - The parsed key object from useInput + */ + matches: (input: string, key: Key) => boolean +} + +/** + * Registry of all global input sequences that should be handled at the App level + * and ignored by child components (like MultilineTextInput). + * + * Add new global shortcuts here to ensure they're properly handled throughout + * the application. + */ +export const GLOBAL_INPUT_SEQUENCES: GlobalInputSequence[] = [ + { + id: "ctrl-c", + description: "Exit application (with confirmation)", + matches: (input, key) => key.ctrl && input === "c", + }, + { + id: "ctrl-m", + description: "Cycle through modes", + matches: (input, key) => { + // Standard Ctrl+M detection + if (key.ctrl && input === "m") return true + // CSI u encoding: ESC [ 109 ; 5 u (kitty keyboard protocol) + // 109 = 'm' ASCII code, 5 = Ctrl modifier + if (input === "\x1b[109;5u") return true + if (input.endsWith("[109;5u")) return true + return false + }, + }, + // Add more global sequences here as needed: + // { + // id: "ctrl-n", + // description: "New task", + // matches: (input, key) => key.ctrl && input === "n", + // }, +] + +/** + * Check if an input matches any global input sequence. + * + * Use this in child components (like MultilineTextInput) to determine + * if input should be ignored because it will be handled by a parent component. + * + * @param input - The raw input string from useInput + * @param key - The parsed key object from useInput + * @returns The matching GlobalInputSequence, or undefined if no match + * + * @example + * ```tsx + * useInput((input, key) => { + * // Ignore inputs handled at App level + * if (isGlobalInputSequence(input, key)) { + * return + * } + * // Handle component-specific input... + * }) + * ``` + */ +export function isGlobalInputSequence(input: string, key: Key): GlobalInputSequence | undefined { + return GLOBAL_INPUT_SEQUENCES.find((seq) => seq.matches(input, key)) +} + +/** + * Check if an input matches a specific global input sequence by ID. + * + * @param input - The raw input string from useInput + * @param key - The parsed key object from useInput + * @param id - The sequence ID to check for + * @returns true if the input matches the specified sequence + * + * @example + * ```tsx + * if (matchesGlobalSequence(input, key, "ctrl-m")) { + * // Handle mode cycling + * } + * ``` + */ +export function matchesGlobalSequence(input: string, key: Key, id: string): boolean { + const seq = GLOBAL_INPUT_SEQUENCES.find((s) => s.id === id) + return seq ? seq.matches(input, key) : false +}