mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
More progress
This commit is contained in:
parent
530b67d2ba
commit
7826828c0a
13 changed files with 1082 additions and 35 deletions
36
apps/cli/CHANGELOG.md
Normal file
36
apps/cli/CHANGELOG.md
Normal 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!
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
135
apps/cli/src/__tests__/globalInputSequences.test.ts
Normal file
135
apps/cli/src/__tests__/globalInputSequences.test.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
190
apps/cli/src/__tests__/useToast.test.ts
Normal file
190
apps/cli/src/__tests__/useToast.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
69
apps/cli/src/ui/components/ToastDisplay.tsx
Normal file
69
apps/cli/src/ui/components/ToastDisplay.tsx
Normal 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)
|
||||
87
apps/cli/src/ui/components/__tests__/ToastDisplay.test.tsx
Normal file
87
apps/cli/src/ui/components/__tests__/ToastDisplay.test.tsx
Normal 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")
|
||||
})
|
||||
})
|
||||
196
apps/cli/src/ui/hooks/useToast.ts
Normal file
196
apps/cli/src/ui/hooks/useToast.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
109
apps/cli/src/ui/utils/globalInputSequences.ts
Normal file
109
apps/cli/src/ui/utils/globalInputSequences.ts
Normal 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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue