More progress

This commit is contained in:
cte 2026-01-07 12:50:57 -08:00
parent 530b67d2ba
commit 7826828c0a
13 changed files with 1082 additions and 35 deletions

36
apps/cli/CHANGELOG.md Normal file
View file

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

View file

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

View file

@ -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": {

View file

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

View file

@ -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> = {}): 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)
}
})
})
})

View file

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

View file

@ -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", () => {

View file

@ -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 ? (
<ToastDisplay toast={currentToast} />
) : showExitHint ? (
<Text color="yellow">Press Ctrl+C again to exit</Text>
) : isLoading && !pendingAsk ? (
<Box>
@ -1103,7 +1144,7 @@ function AppInner({
) : isScrollAreaActive ? (
<ScrollIndicator scrollTop={scrollState.scrollTop} maxScroll={scrollState.maxScroll} isScrollFocused={true} />
) : isInputAreaActive ? (
<Text color={theme.dimText}>? for shortcuts</Text>
<Text color={theme.dimText}>? for shortcuts Ctrl+M mode</Text>
) : null
// Get render function for picker items based on active trigger

View file

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

View file

@ -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 (
<Box>
<Text color={color}>
{icon} {toast.message}
</Text>
</Box>
)
}
export default memo(ToastDisplay)

View file

@ -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(<ToastDisplay toast={null} />)
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(<ToastDisplay toast={toast} />)
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(<ToastDisplay toast={toast} />)
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(<ToastDisplay toast={toast} />)
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(<ToastDisplay toast={toast} />)
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(<ToastDisplay toast={toast} />)
expect(lastFrame()).toContain("Switched to Code mode")
})
})

View file

@ -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<ToastState>((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<Map<string, NodeJS.Timeout>>(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,
}
}

View file

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