Compare commits

...

146 Commits

Author SHA1 Message Date
abeatrix 525e154684 feat(cli): add MCP server management commands
Introduce a new `mcp` command suite to the CLI for managing Model Context Protocol (MCP) servers. This addition allows users to configure MCP servers directly from the command line without manually editing configuration files.

Key changes:
- Added `mcp add` to register new servers (supports stdio, sse, and http transports).
- Added `mcp remove` (alias `rm`) to delete server configurations.
- Added `mcp enable` and `mcp disable` to toggle server availability.
- Added `mcp list` (alias `ls`) to view configured servers.
- Integrated utility functions from `./utils/mcp` for configuration handling.
2026-01-27 00:06:21 -08:00
abeatrix f5fb30e98a clean up 2026-01-26 23:36:52 -08:00
abeatrix 67ec7bb8b2 update tsconfig.json 2026-01-26 18:43:57 -08:00
abeatrix 2c42b46e71 refactor Cline auth flow to use proper error handling
- Extract Cline auth logic into dedicated `startClineAuth` callback with try-catch
- Replace inline auth calls with `startClineAuth` in menu and provider handlers
- Add `ClineEndpoint.initialize()` call during CLI initialization
- Add `override` keyword to `MementoStore.update()` method

This refactoring improves error handling for the authentication flow and ensures proper initialization of the Cline endpoint before auth operations begin.
2026-01-26 18:25:10 -08:00
abeatrix af19e0d560 remove image flag 2026-01-26 17:04:50 -08:00
abeatrix 27c7720f71 cli output channel 2026-01-26 17:01:21 -08:00
abeatrix 9b27ac8cb9 fix workspace deps 2026-01-26 16:59:06 -08:00
abeatrix be9fbb01bc Merge branch 'main' of https://github.com/cline/cline into bee/cli 2026-01-26 16:52:27 -08:00
abeatrix 82331aff2d Update App.test.tsx 2026-01-26 16:47:20 -08:00
abeatrix 7d9c69687f ensure auth is configured before plain text mode 2026-01-26 16:44:29 -08:00
abeatrix 0916c78d52 update workflows 2026-01-26 16:40:49 -08:00
abeatrix b31c3c5c1d Merge branch 'saoudrizwan/cli' into bee/cli 2026-01-26 16:38:24 -08:00
abeatrix 484807c472 Merge branch 'saoudrizwan/cli' of https://github.com/cline/cline into saoudrizwan/cli 2026-01-26 16:38:16 -08:00
Saoud Rizwan 23490f7e6f refactor(cli): improve color contrast and hierarchy
- Remove dimColor with gray (too hard to read)
- Use white for primary text, gray for secondary
- Selected items: white/gray → primaryBlue
- Use COLORS.primaryBlue constant instead of blueBright
- Update .clinerules/cli.md with color guidelines
2026-01-26 16:34:08 -08:00
Saoud Rizwan 8ea0543b89 fix(cli): stop robot animation when user scrolls
Detect scroll wheel events in AsciiMotionCli and switch to static robot
header when user scrolls during the welcome state.
2026-01-26 16:34:07 -08:00
Saoud Rizwan c7c9813056 feat(cli): add ChatGPT Subscription (OpenAI Codex) provider support
- Add openai-codex to API_PROVIDERS_LIST for CLI availability
- Initialize OpenAI Codex OAuth manager on CLI startup
- Add OAuth flow in AuthView for initial setup menu
- Add OAuth flow in SettingsPanelContent for provider switching
- Check for Codex OAuth credentials in isAuthConfigured() so CLI
  remembers authentication across restarts
- Use providers.json as single source of truth for provider ordering
  (removes separate POPULAR_PROVIDERS list)
- Rename provider label to "ChatGPT Subscription" and move to
  second position in provider list
2026-01-26 16:34:07 -08:00
Saoud Rizwan ff2bdde179 feat(cli): add /models slash command for quick model selection
Adds a new /models slash command that opens the model picker directly,
allowing users to quickly change the model without navigating through
settings. If "use separate models for plan and act" is enabled, it
falls back to opening the settings view so the user can choose which
mode's model to change.
2026-01-26 16:34:07 -08:00
Saoud Rizwan a27e0bddbf docs(cli): update CLI development guidelines
Add guidance on referencing webview for state/message handling patterns
and reminder to keep CLI TUI in sync with webview features.
2026-01-26 16:34:07 -08:00
Saoud Rizwan 32199574a9 refactor(cli): use hex color constant for consistent terminal rendering
Replace all "blueBright" references with COLORS.primaryBlue (#B1B9F9)
from a new colors.ts constants file. Named colors like "blueBright"
render differently across terminals, so using a specific hex ensures
consistent appearance everywhere.
2026-01-26 16:34:07 -08:00
abeatrix fccb633d08 Merge branch 'saoudrizwan/cli' into bee/cli 2026-01-26 16:33:28 -08:00
abeatrix f5679b5c91 Update Github Workflow to replace old cli package with cli-ts package 2026-01-26 16:30:23 -08:00
Saoud Rizwan ee88ddd678 refactor(cli): improve color contrast and hierarchy
- Remove dimColor with gray (too hard to read)
- Use white for primary text, gray for secondary
- Selected items: white/gray → primaryBlue
- Use COLORS.primaryBlue constant instead of blueBright
- Update .clinerules/cli.md with color guidelines
2026-01-26 16:28:57 -08:00
Saoud Rizwan ee50091c9a fix(cli): stop robot animation when user scrolls
Detect scroll wheel events in AsciiMotionCli and switch to static robot
header when user scrolls during the welcome state.
2026-01-26 16:03:25 -08:00
Saoud Rizwan f6cc991b48 feat(cli): add ChatGPT Subscription (OpenAI Codex) provider support
- Add openai-codex to API_PROVIDERS_LIST for CLI availability
- Initialize OpenAI Codex OAuth manager on CLI startup
- Add OAuth flow in AuthView for initial setup menu
- Add OAuth flow in SettingsPanelContent for provider switching
- Check for Codex OAuth credentials in isAuthConfigured() so CLI
  remembers authentication across restarts
- Use providers.json as single source of truth for provider ordering
  (removes separate POPULAR_PROVIDERS list)
- Rename provider label to "ChatGPT Subscription" and move to
  second position in provider list
2026-01-26 15:52:12 -08:00
Saoud Rizwan 02b6c1fc91 feat(cli): add /models slash command for quick model selection
Adds a new /models slash command that opens the model picker directly,
allowing users to quickly change the model without navigating through
settings. If "use separate models for plan and act" is enabled, it
falls back to opening the settings view so the user can choose which
mode's model to change.
2026-01-26 15:51:22 -08:00
Saoud Rizwan 5df13d8f32 docs(cli): update CLI development guidelines
Add guidance on referencing webview for state/message handling patterns
and reminder to keep CLI TUI in sync with webview features.
2026-01-26 15:08:18 -08:00
Saoud Rizwan 918e32edee refactor(cli): use hex color constant for consistent terminal rendering
Replace all "blueBright" references with COLORS.primaryBlue (#B1B9F9)
from a new colors.ts constants file. Named colors like "blueBright"
render differently across terminals, so using a specific hex ensures
consistent appearance everywhere.
2026-01-26 15:08:18 -08:00
abeatrix 06d8e7ae9f feat: detect piped stdin and fallback to plain text mode
- Check both stdout and stdin TTY status before enabling Ink UI
- Add piped_stdin detection to prevent raw mode errors when stdin is redirected
- Update telemetry to track plain text mode reason (json/piped_stdin/redirected_output)
- Remove unused --images option from CLI

Ink requires raw mode on stdin which isn't available when stdin is piped.
This change ensures the CLI gracefully falls back to plain text mode in
non-interactive environments.
2026-01-26 15:00:05 -08:00
abeatrix 2b35590de8 improve storage abstractions 2026-01-26 12:33:13 -08:00
abeatrix 2788d85556 rebase bee/cli 2026-01-26 11:44:10 -08:00
Tomás Barreiro 60436b3ddf Do not call feature_flag_called event if the value hasn't changed (#8867)
* Do not call feature_flag_called event if the value hasn't changed

* Send the feature flag called on startup
2026-01-26 10:51:33 -08:00
Saoud Rizwan df5954052d feat: disable extended thinking by default (#8863) 2026-01-26 10:21:21 -08:00
Igor Tceglevskii a66a2784c3 feat: disable PostHog services in self-hosted mode (#8842)
- Skip PostHog client initialization when running in self-hosted mode
- Return no-op config from ErrorProviderFactory and FeatureFlagsProviderFactory
- Add comprehensive tests for self-hosted mode PostHog disabling behavior

This ensures no telemetry or analytics data is sent when users run
the extension in a self-hosted environment.
2026-01-26 07:51:02 -08:00
Saoud Rizwan 4ffb28377a fix(cli): remove TerminalInfoProvider to fix escape sequence leak in macOS Terminal 2026-01-26 05:05:11 -08:00
Saoud Rizwan fa570fa500 fix(cli): sync model IDs when separate models setting is disabled
When planActSeparateModelsSetting is false, both plan and act modes
should use the same model. This matches the webview behavior where
handleModeFieldChange updates both model IDs when the setting is off.

- Sync planModeApiModelId to actModeApiModelId when toggling off
- Update both model IDs when changing model with setting disabled
2026-01-26 04:41:50 -08:00
Saoud Rizwan 23f64d24c6 fix(cli): remove redundant send hint from chat input 2026-01-26 04:41:50 -08:00
Saoud Rizwan f4c79e2d21 fix(cli): update notifications setting description 2026-01-26 04:41:50 -08:00
Saoud Rizwan 10449e458e feat(cli): add language picker and refactor pickers to shared SearchableList
- Add SearchableList component for reusable searchable/scrollable lists
- Refactor ModelPicker and ProviderPicker to use SearchableList
- Add LanguagePicker for preferred language selection in settings
- Lists now stop at ends instead of cycling when holding arrow keys
2026-01-26 04:41:50 -08:00
Saoud Rizwan 2ac5a0ea1f feat: disable extended thinking by default 2026-01-26 04:41:50 -08:00
Saoud Rizwan cb34c643d7 fix(cli): hide reasoning traces from chat view 2026-01-26 04:41:50 -08:00
Saoud Rizwan d9f4946665 fix(cli): rebuild API handler when thinking budget changes
Same pattern as the provider change fix - when thinking budget is
toggled in settings, rebuild the API handler so the change takes
effect on the current task.
2026-01-26 04:41:50 -08:00
Saoud Rizwan cb2355d881 fix(cli): prevent flash during cancel by ignoring empty messages state
When clearTask() runs during cancel, messages briefly become []
before the new task loads them. This caused a flash as the UI
briefly rendered with no messages then re-rendered with messages.

Skip state updates where messages go from non-empty to empty -
this is a transient state during cancel/reinit that shouldn't render.
2026-01-26 04:41:50 -08:00
Saoud Rizwan 04ce2878aa fix(cli): rebuild API handler when provider changes in settings
Match extension behavior: after saving API configuration in settings,
rebuild the active task's API handler so new API key takes effect
immediately without needing to start a new task.
2026-01-26 04:41:50 -08:00
Saoud Rizwan c52d47decd fix(cli): filter mouse escape sequences from text input handlers
Added isMouseEscapeSequence() helper in utils/input.ts to detect and
filter terminal mouse tracking sequences (e.g. [<35;46;17M) from the
AsciiMotionCli mouse tracker. Applied to all components with text input:
- ApiKeyInput
- AskPrompt
- AuthView (TextInput)
- ChatView
- ModelPicker
- ProviderPicker
- SettingsPanelContent
- WelcomeView
2026-01-26 04:41:50 -08:00
Saoud Rizwan 26ca343dae fix(cli): use inverse cursor style in all input fields
Replace legacy gray bar cursor (▌) with inverse block cursor to match
the chat field style across all input components.
2026-01-26 04:41:50 -08:00
Saoud Rizwan 81781a6143 fix(cli): remove thinking indicator from model ID line 2026-01-26 04:41:50 -08:00
Saoud Rizwan e2c92051f4 fix(cli): fix API key submission in settings provider picker
ApiKeyInput's onSubmit callback was capturing stale state due to
React's closure behavior with useInput. Fixed by:

1. Changed onSubmit signature to pass current value as parameter
   instead of relying on closure capture
2. Fixed settings to use stateManager.setApiConfiguration() instead
   of non-existent secretStorage.set() method
3. Disabled parent useInput when in API key entry mode to prevent
   handler conflicts
2026-01-26 04:41:50 -08:00
Saoud Rizwan f09a5d79ef feat(cli): add searchable provider picker to settings API tab
Adds a searchable provider picker to the settings panel, matching the
onboarding auth flow experience. When selecting a new provider, prompts
for the API key before switching.

Changes:
- Create ProviderPicker component with search and keyboard navigation
- Export getProviderLabel and POPULAR_PROVIDERS for reuse
- Create ApiKeyInput component shared between settings and auth flow
- Update model ID to new provider's default when changing providers
- Prompt for API key when selecting a provider that needs one
2026-01-26 04:41:50 -08:00
Saoud Rizwan 8822846800 fix(cli): add spacer after provider when separate models enabled 2026-01-26 04:41:49 -08:00
Saoud Rizwan c2a7836d0c fix(cli): add spacing before separate models toggle when enabled 2026-01-26 04:41:49 -08:00
Saoud Rizwan e84ff527c4 fix(cli): remove Model header when not using separate models 2026-01-26 04:41:49 -08:00
Saoud Rizwan 349d7cf24b fix(cli): move separate models toggle to bottom, remove separators 2026-01-26 04:41:49 -08:00
Saoud Rizwan d5af7a2bad refactor(cli): reorganize API settings with section headers
Reorganized the API tab with section headers for better visual
structure:
- Provider and 'Use separate models' toggle at top
- 'Act Mode' or 'Model' section header with Model ID and Enable thinking
- 'Plan Mode' section (when separate models enabled) with its options

Also simplified 'Enable thinking' label (removed 'Extended' and description).
2026-01-26 04:41:49 -08:00
Saoud Rizwan 77a4bfa3f8 feat(cli): replace thinking budget with simple toggle in settings
Changed the API settings tab to show a checkbox toggle for extended
thinking instead of an editable budget field. When enabled, sets the
budget to 1024 tokens (matching webview behavior). When disabled,
sets budget to 0.
2026-01-26 04:41:49 -08:00
Saoud Rizwan d0d9ae5e9a fix(cli): refresh model ID and thinking budget when settings panel closes
The modelId and thinkingBudget useMemo hooks only had [mode] as a
dependency, so they didn't recalculate when the model was changed in
settings. Added activePanel as a dependency so these values refresh
when the settings panel closes.
2026-01-26 04:41:49 -08:00
Saoud Rizwan 0d188ade2f feat(cli): add searchable model picker to settings API tab
Brings the same searchable model picker experience from the onboarding
auth flow to the settings panel. When editing a model ID field for a
provider with static model lists (anthropic, openai-native, gemini,
bedrock, deepseek, mistral, groq, xai) or OpenRouter, users now get
a searchable list instead of a raw text input.

Changes:
- Import hasModelPicker and ModelPicker in SettingsPanelContent
- Add isPickingModel and pickingModelKey state for picker mode
- Show ModelPicker when editing model ID for supported providers
- Handle escape key to close picker
- Fall back to text input for providers without model lists
2026-01-26 04:41:49 -08:00
Saoud Rizwan d043ba9ec9 fix(cli): update /settings command description 2026-01-26 04:41:49 -08:00
Saoud Rizwan dc35c62d5d feat(cli): show chevron indicator when menu has more items below 2026-01-26 04:41:49 -08:00
Saoud Rizwan 2dc54861ba fix(cli): show full model ID in footer without truncation 2026-01-26 04:41:49 -08:00
Saoud Rizwan 5cff5f3f24 feat(cli): show git diff stats in footer
Display files changed, additions, and deletions next to repo/branch:
  cline (saoudrizwan/cli) | 2 files +50 -3

Stats refresh when messages change to reflect file edits.
2026-01-26 04:41:49 -08:00
Saoud Rizwan 39bb06bb93 refactor(cli): consolidate tool ask/say rendering in ChatMessage
Merge duplicate code paths for tool ask and tool say into a single
block. Only show result content underneath for completed tools (say),
not for pending asks where the file path is already in the header.
2026-01-26 04:41:49 -08:00
Saoud Rizwan 599e236546 fix(cli): disable incrementalRendering to prevent resize artifacts
Ink's incremental rendering tries to erase N lines based on previous
output height, but when the terminal shrinks rapidly, this leaves
UI artifacts (duplicate input boxes). Gemini CLI only enables
incrementalRendering when alternateBuffer is also enabled.
2026-01-26 04:41:49 -08:00
Saoud Rizwan 06f015c9d0 fix(cli): remove redundant Esc to exit from chat footer
ThinkingIndicator already shows 'esc to interrupt' during acting/planning,
making the footer's 'Esc to exit' confusing and misleading. Removed the
double-esc-to-exit logic and UI from ChatView.

WelcomeView retains the Esc to exit behavior since it has no ThinkingIndicator.
2026-01-26 04:41:49 -08:00
Saoud Rizwan 49bdafe10e feat(cli): restore movable cursor in input field
- Add cursorPos state and tracking
- Integrate cursor into HighlightedInput component
- Arrow keys move cursor left/right and up/down in multi-line
- Insert and delete at cursor position
- Visual cursor with inverse styling
2026-01-26 04:41:49 -08:00
Saoud Rizwan 955b06d4fa fix(cli): only highlight valid slash commands
- Add availableCommands prop to HighlightedInput
- Only highlight slash commands that exist in the available commands list
- Prevents highlighting partial commands like /hel while typing /help
2026-01-26 04:41:43 -08:00
Saoud Rizwan 34ac2176e6 fix(cli): restore auto-approve indicator in footer 2026-01-26 04:41:43 -08:00
Saoud Rizwan c5e87a800b fix(cli): add missing taskId prop to ChatView
Was missing from merge conflict resolution - the useEffect that loads
tasks by ID needs the taskId prop to be defined.
2026-01-26 04:41:43 -08:00
Saoud Rizwan 92c9cc5ddc feat(cli): improve thinking budget display and add settings control
- Change footer display from '| thinking: 10,000' to '(thinking)' after model ID
- Add thinking budget fields to API settings tab
- Support editing thinking budget for both Act and Plan modes
- Parse numbers with comma separators, treat 'disabled'/empty as 0
2026-01-26 04:41:43 -08:00
Saoud Rizwan dd0b7697f2 feat(cli): integrate slash commands with settings panel
- Add /settings as CLI-only slash command
- Open settings panel when /settings selected from menu
- Add Shift+Tab shortcut for auto-approve all toggle
- Hide input and footer when settings panel is open
2026-01-26 04:41:37 -08:00
Saoud Rizwan 9d68aa8b8e refactor(cli): extract shared menu utilities
- Add getVisibleWindow() for scrollable list windowing
- Add sortCommandsWorkflowsFirst() for command ordering
- Remove duplicated windowing logic from SlashCommandMenu and FileMentionMenu
2026-01-26 04:41:37 -08:00
Saoud Rizwan c59fe3e62c feat(cli): highlight @mentions and /commands in input field
- Add HighlightedInput component to parse and style text
- Gray background for @mentions and /commands
- Only first /command is highlighted (matches processing behavior)
- Use shared mentionRegexGlobal for proper mention detection
- Prefix file paths with / when inserting mentions (@/path/to/file)
2026-01-26 04:41:37 -08:00
Saoud Rizwan 7602f7b7c6 refactor(cli): unify menu styles and fix navigation
- Update FileMentionMenu to match SlashCommandMenu style
- Max 5 visible items, bright blue text selection, no hints
- Hide footer when file menu is shown
- Stop at boundaries instead of wrapping on arrow keys
2026-01-26 04:41:37 -08:00
Saoud Rizwan a1debe1328 feat(cli): add slash command autocomplete menu
- Add SlashCommandMenu component with keyboard navigation
- Add slash-commands.ts utilities for query extraction and filtering
- Integrate into ChatView with proper state management
- Workflows shown first, then default commands
- Max 5 visible items with arrow key cycling
- Bright blue highlight for selected item
- Footer hidden when menu is shown
2026-01-26 04:41:37 -08:00
Saoud Rizwan 990a00d70f refactor(cli): consolidate tool utilities and reduce code duplication
- Create utils/tools.ts with shared constants and helpers:
  - FILE_EDIT_TOOLS, FILE_SAVE_TOOLS sets
  - isFileEditTool(), isFileSaveTool() helpers
  - normalizeToolName() for consistent tool name handling
  - TOOL_DESCRIPTIONS with normalized keys (no more duplicates)
  - getToolDescription() with automatic normalization
  - parseToolFromMessage() for consistent JSON parsing

- Update components to use shared utilities:
  - ChatMessage.tsx: Remove 60+ line TOOL_DESCRIPTIONS duplicate, use shared
  - ChatView.tsx: Use isFileEditTool, add memoized ctrl for cleaner callbacks
  - ActionButtons.tsx: Use isFileSaveTool and parseToolFromMessage
  - MessageRow.tsx: Use isFileEditTool

- Simplify ChatView.tsx controller pattern:
  - Memoize ctrl = controller || taskController
  - Remove redundant local ctrl definitions in callbacks
  - Cleaner dependency arrays
2026-01-26 04:41:17 -08:00
Saoud Rizwan a125a61cbd feat(cli): add onboarding auth flow with model selection and config import
Auth Flow:
- Auto-redirect first-time users to auth flow when no provider configured
- Support Cline account sign-in with browser-based OAuth
- Support BYO API key configuration for all providers
- Add escape key navigation to go back between steps
- Show provider display names from providers.json (single source of truth)

Model Selection:
- Add ModelPicker component for providers with static model lists
- Add featured models picker for Cline provider (Opus 4.5, GPT 5.2 Codex, Gemini 3 Pro)
- Support OpenRouter model fetching with async loading and caching
- Add scrollable lists with keyboard navigation for long model lists

Config Import:
- Detect and import API keys from Codex CLI (~/.codex/auth.json)
- Detect and import API keys from OpenCode (platform-specific paths)
- Support importing OpenAI, Anthropic, Gemini, Mistral, Groq, DeepSeek, xAI, OpenRouter

Code Quality:
- Extract useScrollableList hook for reusable list windowing
- Move featured models to constants/featured-models.ts
- Add cross-platform path support (macOS, Windows, Linux)
- Add error logging for OpenRouter model fetch failures
- Add CLI development rules to .clinerules/cli.md (blueBright highlight color)
2026-01-26 04:41:17 -08:00
Saoud Rizwan e3e6e1ef77 feat(cli): TUI improvements and new UI components
New Components:
- ActionButtons: Tool approval buttons with mode-based colors (1/2 shortcuts)
- DiffView: Pretty diff view for file edits with +/- highlighting
- TaskView: Alternative verbose task display mode
- MessageList/MessageImage: Supporting components

Chat Improvements:
- Display tool calls in Claude Code style (Cline wants to X / Cline X)
- Mode-based colors (blue for act, yellow for plan)
- Two-column dot prefix layout for messages
- Show command output inline with commands
- Show user feedback messages in chat
- Correct tense for tool messages (wants to vs did)

Bug Fixes:
- Prevent welcome screen flash on task cancel
- Prevent duplicate task completed messages
- Improve followup options handling
- Finalize partial text before native tool calls

Other:
- Add ESC to cancel task (removed ESC-to-exit)
- Use shared formatTimestamp from display utils
- Remove unused files (ImportView, ModelPicker, keychains, etc.)
2026-01-26 04:41:17 -08:00
Saoud Rizwan 09e897c8f2 feat(cli): add onboarding auth flow with model selection and config import
Auth Flow:
- Auto-redirect first-time users to auth flow when no provider configured
- Support Cline account sign-in with browser-based OAuth
- Support BYO API key configuration for all providers
- Add escape key navigation to go back between steps
- Show provider display names from providers.json (single source of truth)

Model Selection:
- Add ModelPicker component for providers with static model lists
- Add featured models picker for Cline provider (Opus 4.5, GPT 5.2 Codex, Gemini 3 Pro)
- Support OpenRouter model fetching with async loading and caching
- Add scrollable lists with keyboard navigation for long model lists

Config Import:
- Detect and import API keys from Codex CLI (~/.codex/auth.json)
- Detect and import API keys from OpenCode (platform-specific paths)
- Support importing OpenAI, Anthropic, Gemini, Mistral, Groq, DeepSeek, xAI, OpenRouter

Code Quality:
- Extract useScrollableList hook for reusable list windowing
- Move featured models to constants/featured-models.ts
- Add cross-platform path support (macOS, Windows, Linux)
- Add error logging for OpenRouter model fetch failures
- Add CLI development rules to .clinerules/cli.md (blueBright highlight color)
2026-01-26 04:41:17 -08:00
abeatrix 6caeeb5da6 Capture Telemetry Events 2026-01-23 23:41:21 -08:00
abeatrix 40550af42c Set up telemetry for CLI 2026-01-23 23:18:58 -08:00
abeatrix e85e4d6f0c Update build step and fix BannerService init 2026-01-23 23:02:47 -08:00
abeatrix d26f76c6e4 remove old task view components 2026-01-23 22:29:30 -08:00
abeatrix 5c6f725079 Replace TaskView with ChatView 2026-01-23 22:10:17 -08:00
abeatrix 4a456e2145 revert to file-base 2026-01-23 21:58:12 -08:00
abeatrix c029e0ffe4 set storage backup 2026-01-23 21:28:09 -08:00
abeatrix cff2217283 check 2026-01-23 21:06:02 -08:00
abeatrix 904617b573 store to system keychain 2026-01-23 21:02:59 -08:00
Bee 47031cea25 feat: add debugLog RPC for host bridge logging (#8841)
* feat: add appendOutputLog RPC for host bridge logging

Add new appendOutputLog RPC endpoint to EnvService proto definition
and refactor VSCode output channel creation to use a dedicated factory
function. This enables structured logging through the host bridge
service instead of direct Logger calls.

* rename appendOutputLog to debugLog and add subscriber pattern

- Rename `appendOutputLog` RPC to `debugLog` with documentation
- Refactor Logger to use subscriber pattern instead of single output
- Update HostProvider to use env.debugLog directly for logging
- Remove redundant logger callback from setupHostProvider

* feat: add multi-subscriber support for Logger output

- Rename Logger.setOutput to Logger.subscribe to better reflect behavior
- Subscribe both output channel and debug logger to receive log messages
- Enable logging to multiple destinations simultaneously

* update mock
2026-01-23 18:18:24 -08:00
Robin Newhouse e29740479e fix: skip diff error UI handling during streaming to prevent flickering (#8788)
* fix: skip diff error UI handling during streaming to prevent flickering

During streaming, handlePartialBlock is called repeatedly, and if the diff
application fails (e.g., search string not found), all the error handling code
was running on every chunk. This caused:
- consecutiveMistakeCount to rapidly increment
- diff_error messages to be added/removed repeatedly
- revertChanges/reset to be called repeatedly
- rapid flickering of the diff viewer

Now we return early from the catch block when block.partial is true, skipping
all error UI handling. The error is only processed once on the final block.

* chore: add changeset for diff error suppression

* test: add unit tests for partial block streaming behavior

Adds tests verifying that error handling is skipped during streaming
(block.partial=true) to prevent counter rapid increment and UI flickering.

* chore: remove unused errorPushedForCallIds tracking

This mechanism was replaced by the simpler block.partial check for
skipping error handling during streaming. Remove the dead code.
2026-01-23 16:55:32 -08:00
Ara 74f607ff8e chore(release): bump version to 3.53.1 (#8839)
- Fix bug in responses API
- Update changeset package name from "cline" to "claude-dev"
- Update version in package.json and package-lock.json
2026-01-23 15:46:59 -08:00
Robin Newhouse 0edf6d777b fix: prevent infinite retry loops when replace_in_file fails repeatedly (#8787)
* fix: prevent infinite retry loops when replace_in_file fails repeatedly

The consecutiveMistakeCount was being reset to 0 at the START of each
WriteToFileToolHandler execution, before the tooManyMistakes check could
see accumulated failures. This allowed the model to retry failing
replace_in_file operations indefinitely, causing context explosion.

Changes:
- Move counter reset from before operation to after successful saveChanges()
- Add consecutiveMistakeCount++ in the diff error catch block
- Fix typo: "his thought process" → "Cline's thought process"

* chore: add changeset for retry loop prevention

* test: add unit tests for consecutiveMistakeCount behavior

Verify the fix for infinite retry loops by testing that:
- Counter is NOT reset at the start of operations
- Counter IS reset only after successful saveChanges()
- Counter IS incremented on diff errors
- Repeated failures accumulate so tooManyMistakes can trigger
2026-01-23 15:37:01 -08:00
Robin Newhouse de630c64d4 fix: throttle diff view updates during streaming (#8785)
* fix: throttle diff view updates during streaming

Skip redundant rapid updates to reduce performance issues in large
streams (e.g., notebooks) and reset throttle state on cleanup.

* chore: add changeset for diff throttling fix

* test: add unit tests for diff view update throttling

Add comprehensive tests for the throttling behavior introduced in the
streaming diff updates fix. Tests cover empty content, unchanged content,
time-based throttling, final update bypass, and state reset.
2026-01-23 15:36:47 -08:00
Bee 3ff63562c8 chore: migrate host logging to shared Logger service (#8820)
* chore: migrate host logging to shared Logger service

- Replace HostProvider.logToChannel usage with Logger.log/error
  in controller, webview, and checkpoint migration code
- Remove redundant, low-value log statements from Cline API
  methods to reduce noise
- Centralize logging through shared Logger service for more
  consistent, structured logging and easier maintenance
- Remove redundant , low-value log statements from StateManager where
  we logged error that would be throw and get logged again

* Update src/integrations/checkpoints/CheckpointMigration.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix

* update tests

* update tests

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-23 15:27:11 -08:00
abeatrix 20fae7e14b update cli host info 2026-01-23 15:20:38 -08:00
Saoud Rizwan 699ed190b7 feat(cli): add CLI-specific system prompt adjustments
- Add isCliEnvironment boolean to SystemPromptContext, computed from
  platform check in task/index.ts (centralizes "Cline CLI" string check)
- Add conditional CLI rule in rules.ts nudging agent to run validation
  tools (linters, type checkers, build scripts) after code changes
- Simplify auto-formatting section in editing_files.ts for CLI mode
  (files saved exactly as written, no auto-formatting expectations)
2026-01-23 15:05:43 -08:00
Saoud Rizwan 4d6f908fbd fix: add null check when filtering tools by type in Responses API providers (#8837)
Users reported seeing this error with the OpenAI Codex provider:
{"message":"Cannot read properties of undefined (reading 'type')","modelId":"gpt-5.2-codex"}

The issue occurs when filtering tools before sending to the Responses API.
The filter accessed .type without checking if the tool element was defined:

  tools.filter((tool) => tool.type === "function")

If the tools array contains any undefined elements, this throws. Fixed by
adding optional chaining:

  tools.filter((tool) => tool?.type === "function")

Applied the same fix to all three providers using the Responses API:
- openai-codex.ts (ChatGPT Plus/Pro subscriptions)
- openai-native.ts (OpenAI API with Responses format)
- oca.ts (OpenAI-compatible API with Responses format)
2026-01-23 14:48:51 -08:00
abeatrix 28ae6d6ca1 Fix error not showing in Chat and use unified chat view 2026-01-23 14:21:01 -08:00
Igor Tceglevskii 6521fdcc94 disable telemetry for self-hosted environments (#8790) 2026-01-23 14:16:50 -08:00
Igor Tceglevskii 1393eace27 Endpoint configuration file (#8645) 2026-01-23 13:40:31 -08:00
abeatrix beca76fb32 implement logger 2026-01-23 13:27:49 -08:00
github-actions[bot] eebb99c1e3 Changeset version bump (#8800)
* changeset version bump

* Updating CHANGELOG.md format

* update changelog and banner for release

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-01-23 13:12:29 -08:00
Ara 9ed44f7a83 feat(cerebras): use model-specific temperature configuration (#8833)
- Extract model retrieval to avoid multiple function calls
- Use temperature from model.info with fallback to 0 instead of hardcoded value
- Allows temperature to be configured per model rather than using a fixed value

This change enables more flexible temperature configuration for different Cerebras models while maintaining backward compatibility with a default value of 0.
2026-01-23 12:50:57 -08:00
Ara 6a95cc5f19 feat: add default temperature to Cerebras model configuration (#8832)
Set default temperature value of 0.9 for Cerebras model in the model
configuration. This establishes a consistent default sampling temperature
for the model's response generation behavior.
2026-01-23 12:42:26 -08:00
abeatrix 8de9935f9a support plain text 2026-01-23 12:11:36 -08:00
abeatrix 6bc700e3db Support Image render 2026-01-23 12:11:23 -08:00
er-ri 1d0637f39c fix: add support for haiku 4.5 in JP_SUPPORTED_CRIS_MODELS and enable global endpoint support (#8298) 2026-01-23 11:46:02 -08:00
AJ Juaire f2c16bae7a Make the default bedrock model Sonnet 4.5 (#8830) 2026-01-23 11:45:49 -08:00
Ara 204f15ce1c Remove free period on grok (#8831) 2026-01-23 11:27:42 -08:00
abeatrix 2f17a1a341 Merge branch 'bee/cli' of https://github.com/cline/cline into bee/cli 2026-01-23 10:05:31 -08:00
abeatrix 639121b644 Merge branch 'main' into bee/cli 2026-01-23 09:58:50 -08:00
abeatrix 1187852bed revert non cli-ts changes 2026-01-23 09:40:18 -08:00
abeatrix 7acc036d91 revert non cli-ts changes 2026-01-23 09:36:58 -08:00
abeatrix 1fea5aaa63 json mode support and model ID fix 2026-01-23 04:28:12 -08:00
Saoud Rizwan 8de7294de8 fix(cli): adjust thinking indicator colors and use proper ellipsis
- Update blueBright to light purple-blue (#8CAAFF) to match terminal
- Update yellow to pure bright yellow (#FFFF00)
- Use proper ellipsis character (…) instead of three dots (...)
2026-01-23 03:16:52 -08:00
Saoud Rizwan 2e4ded7f14 feat(cli-ts): enhance thinking indicator with shimmer animation and elapsed time
- Add ThinkingIndicator component with shimmer effect that cycles through text
- Display different colors for act mode (blue) vs plan mode (yellow)
- Show elapsed time after 1 second with 'esc to interrupt' hint
- Extend useSpinnerState hook to return start time alongside active status
- Replace basic LoadingSpinner with enhanced ThinkingIndicator in ChatView and TaskView
- Maintain backward compatibility with useIsSpinnerActive hook
2026-01-23 03:10:45 -08:00
Saoud Rizwan ac94eb4cf4 fix(cli): query cursor position before Ink mounts for accurate robot tracking
Query terminal cursor position before render() to determine where the
robot will be displayed on screen. This fixes the issue where the robot's
gaze tracking threshold was incorrect when CLI was started partway down
the terminal.

- Add cursor-position utility to query position before Ink takes over stdin
- Pass robotTopRow through App -> ChatView -> AsciiMotionCli
- Add StaticRobotFrame component for header after messages start
- Remove cursor position query from inside component (was leaking to input)
2026-01-23 02:55:16 -08:00
Saoud Rizwan 73635cf68f Fix frames 2026-01-23 02:53:56 -08:00
Saoud Rizwan 9eb0da6c2b feat(cli): add animated robot that follows cursor
Replace static Cline logo with animated 3D robot head that tracks
mouse cursor position. Uses 192 pre-rendered frames with smooth
interpolation for real-time cursor following.

- Add AsciiMotionCli component from ink-playground
- Show animated robot in dynamic region during welcome state
- Filter mouse escape sequences from text input
- Robot looks straight ahead when cursor is above, follows when below
2026-01-23 02:53:56 -08:00
Saoud Rizwan 59c10df046 feat(cli): improve UI styling and fix flicker for long outputs
- User messages now have gray background bubble with > prefix
- Completion result text is green
- Progress bar uses gray shades instead of white
- Act mode uses blueBright color for border and text
- Thinking/Planning spinner with mode-aware text
- Skip dynamic rendering for completion_result and plan_mode_respond
  (these tend to be very long and cause flicker when exceeding terminal height)
- Progress bar shows at least 1 bar when any tokens used
- Always show default hint text (@ for files, / for commands)
2026-01-23 02:53:56 -08:00
Saoud Rizwan db4d9a267a fix(cli): use refs instead of state for Static tracking to prevent flicker
When messages transition from streaming (dynamic) to complete (Static),
useState was causing extra render cycles. Using useRef instead updates
synchronously without triggering re-renders.
2026-01-23 02:53:56 -08:00
Saoud Rizwan 734dc6e57f chore(cli): upgrade to @jrichman/ink fork with React 19 2026-01-23 02:53:56 -08:00
Saoud Rizwan 27befb16f5 chore: gitignore tsbuildinfo files 2026-01-23 02:53:56 -08:00
Saoud Rizwan 1c32b08644 fix(cli): improve chat message rendering
- Fix extra spacing between bullet and text by combining Text elements
- Change assistant message color from magenta to default foreground
- Show ⎿ only on first line of tool results, use spaces for alignment
- Add inline markdown rendering for **bold**, *italic*, and `code`

The inline markdown parser handles common formatting in model output
without the complexity of full block-level markdown parsing.
2026-01-23 02:53:56 -08:00
Saoud Rizwan 5d81bf0fe2 chore(cli): upgrade to forked Ink package for better rendering
- Switch ink to @jrichman/ink@6.4.7 (same fork Gemini CLI uses)
- Upgrade React to 19.2.3
- Add aws4fetch dependency
- Add aws4fetch to esbuild external list
2026-01-23 02:53:55 -08:00
Saoud Rizwan f44771acd5 feat(cli): add flicker-free ChatView with Static/Dynamic split
Replace the old welcome view with a unified ChatView component that
uses Ink's Static component to prevent terminal flickering.

The Problem:
When Ink output height >= terminal rows, it switches from efficient
line-erasing to clearTerminal() + full redraw on every re-render,
causing severe flickering.

The Solution:
- Split content into Static region (rendered once) and Dynamic region
- Completed messages move to Static, only current streaming + input
  stays dynamic
- This keeps dynamic height small, avoiding clearTerminal path

Also includes:
- ChatMessage component with Claude Code-style tool call rendering
- StatusBar component for model/tokens/cost display
- Manual centering via centerText() since Box centering breaks in Static
- Console suppression for console.info to prevent Ink corruption
- React.memo on AccountInfoView

We explored alternate screen buffer (like Gemini CLI) but disabled it
because scroll wheel doesn't work without custom mouse event handling.

See ChatView.tsx header comment for full documentation.
2026-01-23 02:53:55 -08:00
Robin Newhouse 8118e11596 fix(extract-text): strip notebook outputs to reduce context size (#8784)
* fix(extract-text): strip notebook outputs to reduce context size

* chore: add changeset for notebook outputs fix
2026-01-22 17:50:31 -08:00
Bee f7b593df35 chore: remove noisy log when checking file outside workspace (#8814)
* chore: remove noisy log when checking file outside workspace

Removes a `Logger.error` call in `ifFileExistsRelativePath` that triggered whenever a file path was checked without an active workspace. This log was creating excessive noise during long conversations where many files were mentioned but no workspace was open.

* update test
2026-01-22 17:01:22 -08:00
Saoud Rizwan 2e0358a7a1 fix: disable browser tool by default (#8815)
The browser tool conflicts with the new websearch tool. Disabling it by
default provides a better out-of-box experience.
2026-01-22 16:58:29 -08:00
Bee 0fbc10f807 chore: remove unhelpful and noisy log statements - part 1 (#8813)
* chore: remove unhelpful and noisy log statements - part 1

Removes excessive debug and info logs across several services to reduce console noise, specifically:
- Deletes `[DEBUG]` logs for request registration, subscription setup/cleanup, and event dispatching in the gRPC controller and UI handlers.
- Removes verbose file cleanup logs in `ClineTempManager` and process termination logs in `AudioRecordingService`.
- Simplifies the success log in `refreshOpenRouterModels` by removing the large JSON payload dump.
- Upgrades the log level from `debug` to `error` for request cleanup failures in `GrpcRequestRegistry` to ensure exceptions are properly highlighted.

* removes subscription logs
2026-01-22 16:44:05 -08:00
abeatrix 786728e0d4 fix removed hooks settings 2026-01-22 15:02:58 -08:00
abeatrix 9714cf8861 Merge branch 'main' into bee/cli-ts-ink-poc 2026-01-22 14:00:59 -08:00
Bee c093ca1760 refactor: replace console with Logger service (#8741)
* chore: add grit rule to enforce Logger service over console calls

Add a new Grit linting rule that detects direct console method usage
(log, debug, error, warn, info) and prompts developers to use the
Logger service instead for consistent logging practices.

The rule is configured in biome.jsonc to apply to most source files
while excluding test files, webview-ui, evals, standalone, e2e tests,
and scripts where direct console usage may be acceptable.

* support variadic args

* wip: migrate console to Logger

* migrate rest of console logger

* Switch to Logger

* Migrations

* shared

* use shared

* revert format change

* Update tests to stub Logger instead of console

* verbose in dev mode
2026-01-22 13:16:37 -08:00
Seb Duerr dc85299a73 Remove deprecated zai-glm-4.6 model from Cerebras provider (#8717) 2026-01-22 12:58:07 -08:00
ClineXDiego 4533ed3ea8 fix: support drag & drop files from SSH remote workspaces (#8804)
Add vscode-remote: scheme to the valid URI filter for drag & drop operations.
This allows files from SSH Remote workspaces to be dropped into the chat.

Fixes #7606
2026-01-22 12:37:11 -08:00
Max 66d7664d1b improve cline command permission parsing logic (#8544)
- cline command permission flag can now parse subshells correctly and
validate that subshells don't contain disallowed commands.

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-01-22 11:11:53 -08:00
dependabot[bot] 50aeb7098f chore(deps): bump undici (#8783)
Bumps [undici](https://github.com/nodejs/undici) to 6.23.0 and updates ancestor dependency . These dependencies need to be updated together.


Updates `undici` from 6.22.0 to 6.23.0
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v6.22.0...v6.23.0)

Updates `undici` from 7.16.0 to 7.19.0
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v6.22.0...v6.23.0)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 6.23.0
  dependency-type: indirect
- dependency-name: undici
  dependency-version: 7.19.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: ClineXDiego <diego@cline.bot>
2026-01-22 10:50:49 -08:00
github-actions[bot] c2e6eafb45 v3.52.0 Release Notes (#8633)
- Users with ChatGPT Plus or Pro subscriptions can now use GPT-5 models directly through Cline without needing an API key. Authentication is handled via OAuth through OpenAI's authentication system.
- Grok models are now moving out of free tier and into paid plans.
- Introduces comprehensive Jupyter Notebook support for Cline, enabling AI-assisted editing of `.ipynb` files with full cell-level context awareness.

- Bugs in DiffViewProvider for file editing
- Ollama's recommended models to use correct identifiers

Co-authored-by: Arafatkatze <arafat.da.khan@gmail.com>
2026-01-22 10:48:32 -08:00
Juan Pablo Flores 0220628c39 Adds Open AI Codex docs (#8791)
* feat(docs): add OpenAI Codex provider setup instructions and update model selection guidance

* fix(docs): improve clarity and formatting in OpenAI Codex documentation

* Update docs/provider-config/openai-codex.mdx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update docs/provider-config/openai-codex.mdx

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Tony Loehr <turingxo@gmail.com>
2026-01-22 09:30:12 -08:00
CandiedUniverse 5052220195 feat(hooks): Make hooks always enabled and remove its feature setting. [CLINE-1179] (#8777)
* feat(hooks): Standardize on calling getHooksEnabledSafe().

* feat(hooks): Hard-code getHookEnabledSafe() to return true unless on Windows.

* feat(hooks): Remove hooks setting from the CLI.

* feat(hooks): Remove hooks toggle from the Feature Settings UI.

* feat(hooks): Remove hooksEnabled toggles from settings/task APIs.

* feat(hooks): Stop using hooksEnabled setting.

* feat(hooks): npm run changeset

* feat(hooks): Simplify getHooksEnabledSafe() function signature.

* feat(hooks): Remove hooks setting migration.

feat(hooks): Remove hooksEnabled from updateSettingsCli() conversion.

feat(hooks): Use 'reserved' for removed fields in UpdateSettingsRequest protobuf.
2026-01-22 09:26:16 -08:00
CandiedUniverse abf3081e56 Rules: Wire up conditional rules functionality [ENG-1470] (#8669)
* feat(rules): Write technical design / implementation plan doc.

* update frontmatter plan

* feat(rules): Initial implementation based on plan doc.

* feat(rules): Add tool-call path harvesting for path-scoped Cline Rules.

* chore(rules): exclude internal paths-frontmatter plan doc from PR

* fix(rules): use latest user message for paths frontmatter context

* feat(rules): Implement conditional_rules_applied say type.

* feat(rules): changes as per Cline's code review feedback

* feat(rules): npm run changeset

* feat(rules): Changes as per ellipsis-dev feedback.

* feat(rules): Changes as per code review feedback (i.e. don't bloat the task context).

* feat(rules): Fix failing unit tests.
2026-01-22 09:25:59 -08:00
Michael Gutin 125cb78a31 Switch background element now has a 3:1 contrast ration with thumb and rule row background in vs code light and dark themes (#8747) 2026-01-22 12:16:51 -05:00
CandiedUniverse 17539228ea feat(cli): Add note about ctrl+c to exit the CLI. [CLINE-1162] (#8796)
* feat(cli): Add note about ctrl+c to exit the CLI.

* feat(cli): npm run changeset
2026-01-22 09:01:57 -08:00
abeatrix c5db44964e Supports Piped 2026-01-22 01:13:37 -08:00
abeatrix ed38739d65 store metadata with task history 2026-01-22 00:49:47 -08:00
Ara bdb7cc36fa feat(DiffEditRow): add button to open file in editor (#8564)
* refactor(diff): return result object with line tracking metadata

Change constructNewFileContent to return an object containing newContent
and line number information instead of just the string content. Add
charIndexToLineNumber helper function to support tracking where changes
occur in the file.

Update all callers and tests to access the newContent property from
the result object.

* minor fix

* fix: hide line numbers when not available from backend

* chore: add changeset

* feat: add startLineNumbers support to ApplyPatchHandler

* fix: split V4A @@ chunks into separate Patch objects for proper line numbers

* fix(DiffEditRow): preserve +/- prefix in diff line display for backwards compatibility

* fix line numbers
2026-01-21 21:38:45 -08:00
abeatrix 50bc90835c log 2026-01-21 18:53:49 -08:00
Tony Loehr d3e1065707 added jupyter docs (#8742)
* added jupyter docs

* fix jupyter docs and add gifs

* fix jupyter docs 2

* fix jupyter gif placement
2026-01-21 16:15:26 -08:00
494 changed files with 350328 additions and 5133 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Add claude 4.5 haiku
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
fix: finalize document content during approval flow
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Expose --version in cline cli command
+7
View File
@@ -0,0 +1,7 @@
---
"claude-dev": patch
---
Add endpoint configuration file support for on-premise deployments
Enterprise customers can now configure custom API endpoints by creating a `~/.cline/endpoints.json` file with custom URLs for `appBaseUrl`, `apiBaseUrl`, and `mcpBaseUrl`. When this file is present, Cline runs in on-premise mode with the custom endpoints.
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
fix: prevent duplicate diff errors when parallel tool calling is enabled
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Log Persistence errors to PostHog
-13
View File
@@ -1,13 +0,0 @@
---
"claude-dev": patch
---
feat: add OpenAI Codex (ChatGPT Plus/Pro) provider
Users with ChatGPT Plus or Pro subscriptions can now use GPT-5 models directly through Cline without needing an API key. Authentication is handled via OAuth through OpenAI's authentication system.
Models available:
- gpt-5.2-codex (default)
- gpt-5.1-codex-max
- gpt-5.1-codex-mini
- gpt-5.2
@@ -1,9 +0,0 @@
---
"claude-dev": patch
---
Fix two bugs in DiffViewProvider file editing:
1. **Line boundary validation**: Add `safelyTruncateDocument()` to prevent out-of-bounds line errors on JetBrains hosts (fixes #8423, #8429). The gRPC protocol strictly validates line numbers, causing "truncateDocument INTERNAL: Wrong line" errors when `truncateDocument()` was called with a line number >= document line count.
2. **Content concatenation on final update**: When replacing content without a trailing newline, the old content at line N+1 was concatenated to the new content. Fixed by extending the replacement range to cover the entire document on final update.
-9
View File
@@ -1,9 +0,0 @@
---
"claude-dev": patch
---
docs: fix outdated Ollama model names in documentation
Updated recommended Ollama models to use correct identifiers:
- Changed qwen3-coder-30b to qwen2.5-coder:32b
- Changed devstral-small to codellama:34b-code
@@ -0,0 +1,7 @@
---
"claude-dev": patch
---
fix: prevent infinite retry loops when replace_in_file fails repeatedly
Add safeguards to prevent the LLM from getting stuck in infinite retry loops when `replace_in_file` operations fail repeatedly. The system now detects repeated failures and provides better guidance to break out of retry cycles.
@@ -0,0 +1,7 @@
---
"claude-dev": patch
---
fix: skip diff error UI handling during streaming to prevent flickering
Suppress diff view error notifications while content is actively streaming to prevent visual flickering and improve user experience. Error handling is deferred until streaming completes.
+7
View File
@@ -0,0 +1,7 @@
---
"claude-dev": patch
---
fix(extract-text): strip notebook outputs to reduce context size
Strip notebook cell outputs when extracting text content from Jupyter notebooks, significantly reducing the amount of context sent to the LLM while preserving the essential code and markdown content.
+7
View File
@@ -0,0 +1,7 @@
---
"claude-dev": patch
---
fix: throttle diff view updates during streaming
Add throttling to diff view updates during content streaming to reduce UI flickering and improve performance. Updates are now batched at reasonable intervals instead of firing on every token received.
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
OpenAI GPT-5 Codex models are now using Apply Patch tool for diff edits.
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Disable PostHog telemetry, error tracking, and feature flags in self-hosted mode
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Fix issue where tool call ids are invalid when switching between models using the chat completion format and the responses api format.
-10
View File
@@ -1,10 +0,0 @@
---
"claude-dev": patch
---
fix: improve Jupyter notebook diff view and reduce LLM context for notebook edits
- Restore switchToSpecializedEditor() for Jupyter notebook diff views that was accidentally removed during rebase
- Open .ipynb files in Jupyter notebook editor after save instead of leaving stale diff view
- Strip notebook outputs from content sent to LLM, reducing context by 95% (196KB → 9KB)
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
fix: add chat output on skill use
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Adding telemetry for background exec terminal
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
This pull request introduces comprehensive Jupyter Notebook support for Cline, enabling AI-assisted editing of `.ipynb` files with full cell-level context awareness. The feature allows users to seamlessly work with Jupyter notebooks using Cline's AI capabilities while preserving the notebook's JSON structure.
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Remove deprecated zai-glm-4.6 model from Cerebras provider
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Make Sonnet 4.5 the default Amazon Bedrock model id
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
fix: removes retry message from UI after retry succeeds
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Support native tool calling for LM Studio and Ollama provider
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
fixing integration tests from testing framework
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
add claude 4.5 opus into sap provider.
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Limite Vertex and LiteLLM options when they're remote configured
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Fix crash when the Context Menu has a type but no options
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Disable PostHog and build-time OpenTelemetry telemetry in self-hosted/on-premise mode. Enterprise customers running self-hosted deployments will no longer send any telemetry to Cline's collectors. Runtime environment OTEL and remote config OTEL remain available for enterprises to configure their own telemetry collection.
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Throttle the remote config fetch
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Improve history view filter menu
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Add git worktree management UI for running parallel Cline sessions
+8
View File
@@ -0,0 +1,8 @@
# CLI Development
The CLI lives in `cli-ts/` and uses React Ink for terminal UI.
- If needed, look at `cli-ts/src/constants/colors.ts` for re-used terminal colors, e.g. `COLORS.primaryBlue` highlight color (selections, spinners, success states).
- Never use `dimColor` with gray (e.g. `<Text color="gray" dimColor>`) - it's too hard to read. Use `color="gray"` for secondary text and normal foreground (no color) for primary text.
- When thinking about how to handle state or messages from core, look at webview for how it communicates with the vs code extension.
- When updating the webview, consider and suggest to the user to update the CLI TUI since we want to provide a similar experience to our terminal users as we do our vs code extension users.
+21 -39
View File
@@ -33,13 +33,7 @@ jobs:
node-version: '20.x'
registry-url: 'https://registry.npmjs.org'
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
cache-dependency-path: cli/go.sum
# Cache root dependencies - only reuse if package-lock.json exactly matches
# Cache root dependencies
- name: Cache root dependencies
uses: actions/cache@v4
id: root-cache
@@ -47,46 +41,37 @@ jobs:
path: node_modules
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
- name: Cache webview-ui dependencies
# Cache cli-ts dependencies
- name: Cache cli-ts dependencies
uses: actions/cache@v4
id: webview-cache
id: cli-ts-cache
with:
path: webview-ui/node_modules
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
path: cli-ts/node_modules
key: ${{ runner.os }}-npm-cli-ts-${{ hashFiles('cli-ts/package-lock.json') }}
- name: Install root dependencies
if: steps.root-cache.outputs.cache-hit != 'true'
run: npm ci --include=optional
- name: Install webview-ui dependencies
if: steps.webview-cache.outputs.cache-hit != 'true'
run: cd webview-ui && npm ci --include=optional
- name: Install cli-ts dependencies
if: steps.cli-ts-cache.outputs.cache-hit != 'true'
run: cd cli-ts && npm ci
- name: Generate Protos
run: npm run protos
- name: Read release version
id: version
run: |
# Read version from cli/package.json (stable version)
VERSION=$(node -p "require('./cli/package.json').version")
# Read version from cli-ts/package.json
VERSION=$(node -p "require('./cli-ts/package.json').version")
echo "Release version: $VERSION"
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Download ripgrep binaries
run: npm run download-ripgrep
- name: Clean previous builds
run: rm -rf dist-standalone
- name: Generate Protos (First Pass)
run: npm run protos && npm run protos-go
- name: Compile CLI
run: npm run compile-cli
- name: Compile CLI for all platforms
run: npm run compile-cli-all-platforms
- name: Build standalone NPM package
- name: Build and package CLI
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
@@ -98,21 +83,18 @@ jobs:
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
POSTHOG_TELEMETRY_ENABLED: "true"
run: npm run compile-standalone-npm
- name: Generate Protos (Second Pass - Bug Workaround)
run: npm run protos && npm run protos-go
run: node scripts/package-npm.mjs
- name: Verify build output
run: |
echo "Checking dist-standalone directory..."
ls -la dist-standalone/
echo "Verifying CLI binaries..."
ls -lh cli/bin/cline-* || echo "Warning: CLI binaries not found"
echo "Checking dist-standalone/dist..."
ls -la dist-standalone/dist/
echo "Checking package.json in dist-standalone..."
cat dist-standalone/package.json | grep version
cat dist-standalone/package.json
- name: Publish to NPM with latest tag
env:
+31 -54
View File
@@ -42,14 +42,7 @@ jobs:
node-version: '20.x'
registry-url: 'https://registry.npmjs.org'
- name: Setup Go
if: steps.check_commits.outputs.skip != 'true'
uses: actions/setup-go@v5
with:
go-version: '1.24'
cache-dependency-path: cli/go.sum
# Cache root dependencies - only reuse if package-lock.json exactly matches
# Cache root dependencies
- name: Cache root dependencies
if: steps.check_commits.outputs.skip != 'true'
uses: actions/cache@v4
@@ -58,75 +51,63 @@ jobs:
path: node_modules
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
- name: Cache webview-ui dependencies
# Cache cli-ts dependencies
- name: Cache cli-ts dependencies
if: steps.check_commits.outputs.skip != 'true'
uses: actions/cache@v4
id: webview-cache
id: cli-ts-cache
with:
path: webview-ui/node_modules
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
path: cli-ts/node_modules
key: ${{ runner.os }}-npm-cli-ts-${{ hashFiles('cli-ts/package-lock.json') }}
- name: Install root dependencies
if: steps.check_commits.outputs.skip != 'true' && steps.root-cache.outputs.cache-hit != 'true'
run: npm ci --include=optional
- name: Install webview-ui dependencies
if: steps.check_commits.outputs.skip != 'true' && steps.webview-cache.outputs.cache-hit != 'true'
run: cd webview-ui && npm ci --include=optional
- name: Install cli-ts dependencies
if: steps.check_commits.outputs.skip != 'true' && steps.cli-ts-cache.outputs.cache-hit != 'true'
run: cd cli-ts && npm ci
- name: Generate Protos
if: steps.check_commits.outputs.skip != 'true'
run: npm run protos
- name: Generate nightly version with timestamp
if: steps.check_commits.outputs.skip != 'true'
id: version
run: |
# Read base version from cli/package.json (e.g., "1.0.9")
BASE_VERSION=$(node -p "require('./cli/package.json').version")
# Read base version from cli-ts/package.json (e.g., "2.0.0")
BASE_VERSION=$(node -p "require('./cli-ts/package.json').version")
# Generate timestamp (Unix epoch seconds)
TIMESTAMP=$(date +%s)
# Create unique nightly version: 1.0.9-nightly.1736365200
# Create unique nightly version: 2.0.0-nightly.1736365200
VERSION="${BASE_VERSION}-nightly.${TIMESTAMP}"
echo "Base version: $BASE_VERSION"
echo "Generated nightly version: $VERSION"
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Update cli/package.json with nightly version
- name: Update cli-ts/package.json with nightly version
if: steps.check_commits.outputs.skip != 'true'
run: |
# Update version with timestamp-based nightly version
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('cli/package.json', 'utf8'));
const pkg = JSON.parse(fs.readFileSync('cli-ts/package.json', 'utf8'));
pkg.version = '${{ steps.version.outputs.version }}';
fs.writeFileSync('cli/package.json', JSON.stringify(pkg, null, '\t'));
fs.writeFileSync('cli-ts/package.json', JSON.stringify(pkg, null, '\t'));
"
echo "Using version ${{ steps.version.outputs.version }} for build"
cat cli/package.json | grep '"version"'
- name: Download ripgrep binaries
if: steps.check_commits.outputs.skip != 'true'
run: npm run download-ripgrep
echo "Using version ${{ steps.version.outputs.version }} for build"
cat cli-ts/package.json | grep '"version"'
- name: Clean previous builds
if: steps.check_commits.outputs.skip != 'true'
run: rm -rf dist-standalone
- name: Generate Protos (First Pass)
if: steps.check_commits.outputs.skip != 'true'
run: npm run protos && npm run protos-go
- name: Compile CLI
if: steps.check_commits.outputs.skip != 'true'
run: npm run compile-cli
- name: Compile CLI for all platforms
if: steps.check_commits.outputs.skip != 'true'
run: npm run compile-cli-all-platforms
- name: Build standalone NPM package
- name: Build and package CLI
if: steps.check_commits.outputs.skip != 'true'
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
@@ -139,23 +120,19 @@ jobs:
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
POSTHOG_TELEMETRY_ENABLED: "true"
run: npm run compile-standalone-npm
- name: Generate Protos (Second Pass - Bug Workaround)
if: steps.check_commits.outputs.skip != 'true'
run: npm run protos && npm run protos-go
run: node scripts/package-npm.mjs
- name: Verify build output
if: steps.check_commits.outputs.skip != 'true'
run: |
echo "Checking dist-standalone directory..."
ls -la dist-standalone/
echo "Verifying CLI binaries..."
ls -lh cli/bin/cline-* || echo "Warning: CLI binaries not found"
echo "Checking dist-standalone/dist..."
ls -la dist-standalone/dist/
echo "Checking package.json in dist-standalone..."
cat dist-standalone/package.json | grep version
cat dist-standalone/package.json
- name: Publish to NPM with nightly tag
if: steps.check_commits.outputs.skip != 'true'
+1
View File
@@ -46,3 +46,4 @@ test-results
/pkg
.secrets
*.tsbuildinfo
+25 -2
View File
@@ -1,10 +1,33 @@
# Changelog
## [3.53.1]
### Fixed
- Bug in responses API
## [3.53.0]
### Fixed
- Removed grok model from free tier
## [3.52.0]
### Added
- Users with ChatGPT Plus or Pro subscriptions can now use GPT-5 models directly through Cline without needing an API key. Authentication is handled via OAuth through OpenAI's authentication system.
- Grok models are now moving out of free tier and into paid plans.
- Introduces comprehensive Jupyter Notebook support for Cline, enabling AI-assisted editing of `.ipynb` files with full cell-level context awareness.
### Fixed
- Bugs in DiffViewProvider for file editing
- Ollama's recommended models to use correct identifiers
## [3.51.0]
### Added
- Adding OpenAI gpt-5.2-codex model to the model picker
- Adding OpenAI gpt-5.2-codex model to the model picker
## [3.50.0]
@@ -1737,4 +1760,4 @@ Add Opus 4.1 through Claude Code
## [0.0.6]
- Initial release
- Initial release
+1
View File
@@ -1,2 +1,3 @@
@.clinerules/general.md
@.clinerules/network.md
@.clinerules/cli.md
+24 -1
View File
@@ -44,7 +44,7 @@
"style": {
"useNodejsImportProtocol": "off",
"useImportType": "off",
"useBlockStatements": "warn",
"useBlockStatements": "off",
"useNamingConvention": "off",
"useThrowOnlyError": "info",
"useConsistentArrayType": "off",
@@ -147,6 +147,29 @@
"src/dev/grit/vscode-api.grit"
]
},
{
// Do not use console logging directly, use the Logger service instead.
"plugins": [
"src/dev/grit/console-log.grit"
],
"includes": [
"**",
"!**/webview-ui/**",
"!**/evals/**",
"!**/standalone/**",
"!**/e2e/**",
"!**/test/**",
"!**/__tests__/**",
"!**/*.test.ts",
"!**/*.stories.ts",
"!src/dev/**",
"!**/*.mjs",
"!**/*.js",
"!**/scripts/**",
"!**/*.tsx",
"!**/testing-platform/**"
]
},
{
"includes": [
"**",
+33 -11
View File
@@ -28,12 +28,19 @@ npm run install:all
npm run protos
# Build the CLI
npm run compile-cli-ts
npm run build:cli
```
Or install the CLI globally:
```bash
# Install all dependencies first
npm run install:all
# Ensure protos are generated
npm run protos
# Build and link the CLI globally
cd cli-ts
npm install
npm run link
@@ -189,7 +196,6 @@ These options are available for the default command (running a task directly):
| Option | Description |
|--------|-------------|
| `-i, --images <paths...>` | Image file paths to include with the task |
| `-v, --verbose` | Show verbose output |
| `-c, --cwd <path>` | Working directory |
| `--config <path>` | Configuration directory |
@@ -197,20 +203,36 @@ These options are available for the default command (running a task directly):
## Development
```bash
# Build and link the package to your terminal
npm run link
For active development, at the root of this repo:
# Set your provider (No Cline provider support yet)
cline auth
1. **Initial setup:**
```bash
npm run install:all
npm run protos
```
# Run a task
cline task "Tell me about this codebase"
```
2. **Make changes to cli-ts:**
```bash
npm run cli:dev
# or
cd cli-ts && npm run dev
```
3. **Test your changes:**
```bash
cline [your-command] # In a new Terminal
```
4. **When done:**
```bash
npm run cli:unlink
```
### Build
```bash
cd cli-ts
# Development build with source maps
npm run build
@@ -294,4 +316,4 @@ npm run protos
Make the CLI executable:
```bash
chmod +x dist/cli.js
```
```
+36 -7
View File
@@ -1,3 +1,5 @@
import "dotenv/config"
import fs from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
@@ -78,7 +80,7 @@ const vscodeStubPlugin = {
name: "vscode-stub",
setup(build) {
// Redirect 'vscode' imports to our shim
build.onResolve({ filter: /^vscode$/ }, (args) => {
build.onResolve({ filter: /^vscode$/ }, () => {
return { path: path.join(__dirname, "src", "vscode-shim.ts") }
})
},
@@ -169,15 +171,31 @@ const buildEnvVars = {
"process.env.IS_CLI": JSON.stringify("true"),
}
const buildTimeEnvs = [
"TELEMETRY_SERVICE_API_KEY",
"ERROR_SERVICE_API_KEY",
"POSTHOG_TELEMETRY_ENABLED",
"OTEL_TELEMETRY_ENABLED",
"OTEL_LOGS_EXPORTER",
"OTEL_METRICS_EXPORTER",
"OTEL_EXPORTER_OTLP_PROTOCOL",
"OTEL_EXPORTER_OTLP_ENDPOINT",
"OTEL_EXPORTER_OTLP_HEADERS",
"OTEL_METRIC_EXPORT_INTERVAL",
"CLINE_ENVIRONMENT",
]
buildTimeEnvs.forEach((envVar) => {
if (process.env[envVar]) {
console.log(`[cli-ts esbuild] ${envVar} env var is set`)
buildEnvVars[`process.env.${envVar}`] = JSON.stringify(process.env[envVar])
}
})
if (production) {
buildEnvVars["process.env.IS_DEV"] = "false"
}
// Set the environment
if (process.env.CLINE_ENVIRONMENT) {
buildEnvVars["process.env.CLINE_ENVIRONMENT"] = JSON.stringify(process.env.CLINE_ENVIRONMENT)
}
const config = {
entryPoints: [path.join(__dirname, "src", "index.ts")],
bundle: true,
@@ -193,7 +211,18 @@ const config = {
target: "node20",
outfile: path.join(__dirname, "dist", "cli.mjs"),
// These modules need to load files from the module directory at runtime
external: ["@grpc/reflection", "grpc-health-check", "better-sqlite3", "ink", "ink-spinner", "react"],
external: [
"@grpc/reflection",
"grpc-health-check",
"better-sqlite3",
"ink",
"ink-spinner",
"ink-picture",
"react",
"aws4fetch",
"pino",
"pino-roll",
],
supported: { "top-level-await": true },
banner: {
js: `#!/usr/bin/env node
+206 -201
View File
@@ -9,13 +9,14 @@
"version": "1.0.0",
"license": "Apache-2.0",
"dependencies": {
"aws4fetch": "^1.0.20",
"chalk": "^5.3.0",
"commander": "^12.1.0",
"ink": "^5.0.1",
"ink": "npm:@jrichman/ink@6.4.7",
"ink-spinner": "^5.0.0",
"ora": "^8.0.1",
"prompts": "^2.4.2",
"react": "^18.3.0"
"react": "^19.2.3"
},
"bin": {
"clinedev": "dist/cli.mjs"
@@ -23,7 +24,7 @@
"devDependencies": {
"@types/node": "20.x",
"@types/prompts": "^2.4.9",
"@types/react": "^18.3.27",
"@types/react": "^19.2.9",
"esbuild": "^0.25.0",
"ink-testing-library": "^4.0.0",
"rimraf": "^6.0.1",
@@ -32,16 +33,31 @@
}
},
"node_modules/@alcalzone/ansi-tokenize": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.1.3.tgz",
"integrity": "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==",
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.3.tgz",
"integrity": "sha512-jsElTJ0sQ4wHRz+C45tfect76BwbTbgkgKByOzpCN9xG61N5V6u/glvg1CsNJhq2xJIFpKHSwG3D2wPPuEYOrQ==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"is-fullwidth-code-point": "^4.0.0"
"is-fullwidth-code-point": "^5.0.0"
},
"engines": {
"node": ">=14.13.1"
"node": ">=18"
}
},
"node_modules/@alcalzone/ansi-tokenize/node_modules/is-fullwidth-code-point": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz",
"integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==",
"license": "MIT",
"dependencies": {
"get-east-asian-width": "^1.3.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@esbuild/aix-ppc64": {
@@ -517,9 +533,9 @@
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.55.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.2.tgz",
"integrity": "sha512-21J6xzayjy3O6NdnlO6aXi/urvSRjm6nCI6+nF6ra2YofKruGixN9kfT+dt55HVNwfDmpDHJcaS3JuP/boNnlA==",
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.56.0.tgz",
"integrity": "sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==",
"cpu": [
"arm"
],
@@ -531,9 +547,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.55.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.2.tgz",
"integrity": "sha512-eXBg7ibkNUZ+sTwbFiDKou0BAckeV6kIigK7y5Ko4mB/5A1KLhuzEKovsmfvsL8mQorkoincMFGnQuIT92SKqA==",
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.56.0.tgz",
"integrity": "sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q==",
"cpu": [
"arm64"
],
@@ -545,9 +561,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.55.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.2.tgz",
"integrity": "sha512-UCbaTklREjrc5U47ypLulAgg4njaqfOVLU18VrCrI+6E5MQjuG0lSWaqLlAJwsD7NpFV249XgB0Bi37Zh5Sz4g==",
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.56.0.tgz",
"integrity": "sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w==",
"cpu": [
"arm64"
],
@@ -559,9 +575,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.55.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.2.tgz",
"integrity": "sha512-dP67MA0cCMHFT2g5XyjtpVOtp7y4UyUxN3dhLdt11at5cPKnSm4lY+EhwNvDXIMzAMIo2KU+mc9wxaAQJTn7sQ==",
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.56.0.tgz",
"integrity": "sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g==",
"cpu": [
"x64"
],
@@ -573,9 +589,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.55.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.2.tgz",
"integrity": "sha512-WDUPLUwfYV9G1yxNRJdXcvISW15mpvod1Wv3ok+Ws93w1HjIVmCIFxsG2DquO+3usMNCpJQ0wqO+3GhFdl6Fow==",
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.56.0.tgz",
"integrity": "sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ==",
"cpu": [
"arm64"
],
@@ -587,9 +603,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.55.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.2.tgz",
"integrity": "sha512-Ng95wtHVEulRwn7R0tMrlUuiLVL/HXA8Lt/MYVpy88+s5ikpntzZba1qEulTuPnPIZuOPcW9wNEiqvZxZmgmqQ==",
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.56.0.tgz",
"integrity": "sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg==",
"cpu": [
"x64"
],
@@ -601,9 +617,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.55.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.2.tgz",
"integrity": "sha512-AEXMESUDWWGqD6LwO/HkqCZgUE1VCJ1OhbvYGsfqX2Y6w5quSXuyoy/Fg3nRqiwro+cJYFxiw5v4kB2ZDLhxrw==",
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.56.0.tgz",
"integrity": "sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==",
"cpu": [
"arm"
],
@@ -615,9 +631,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.55.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.2.tgz",
"integrity": "sha512-ZV7EljjBDwBBBSv570VWj0hiNTdHt9uGznDtznBB4Caj3ch5rgD4I2K1GQrtbvJ/QiB+663lLgOdcADMNVC29Q==",
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.56.0.tgz",
"integrity": "sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==",
"cpu": [
"arm"
],
@@ -629,9 +645,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.55.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.2.tgz",
"integrity": "sha512-uvjwc8NtQVPAJtq4Tt7Q49FOodjfbf6NpqXyW/rjXoV+iZ3EJAHLNAnKT5UJBc6ffQVgmXTUL2ifYiLABlGFqA==",
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.56.0.tgz",
"integrity": "sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==",
"cpu": [
"arm64"
],
@@ -643,9 +659,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.55.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.2.tgz",
"integrity": "sha512-s3KoWVNnye9mm/2WpOZ3JeUiediUVw6AvY/H7jNA6qgKA2V2aM25lMkVarTDfiicn/DLq3O0a81jncXszoyCFA==",
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.56.0.tgz",
"integrity": "sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==",
"cpu": [
"arm64"
],
@@ -657,9 +673,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.55.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.2.tgz",
"integrity": "sha512-gi21faacK+J8aVSyAUptML9VQN26JRxe484IbF+h3hpG+sNVoMXPduhREz2CcYr5my0NE3MjVvQ5bMKX71pfVA==",
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.56.0.tgz",
"integrity": "sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==",
"cpu": [
"loong64"
],
@@ -671,9 +687,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.55.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.2.tgz",
"integrity": "sha512-qSlWiXnVaS/ceqXNfnoFZh4IiCA0EwvCivivTGbEu1qv2o+WTHpn1zNmCTAoOG5QaVr2/yhCoLScQtc/7RxshA==",
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.56.0.tgz",
"integrity": "sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==",
"cpu": [
"loong64"
],
@@ -685,9 +701,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.55.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.2.tgz",
"integrity": "sha512-rPyuLFNoF1B0+wolH277E780NUKf+KoEDb3OyoLbAO18BbeKi++YN6gC/zuJoPPDlQRL3fIxHxCxVEWiem2yXw==",
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.56.0.tgz",
"integrity": "sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==",
"cpu": [
"ppc64"
],
@@ -699,9 +715,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.55.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.2.tgz",
"integrity": "sha512-g+0ZLMook31iWV4PvqKU0i9E78gaZgYpSrYPed/4Bu+nGTgfOPtfs1h11tSSRPXSjC5EzLTjV/1A7L2Vr8pJoQ==",
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.56.0.tgz",
"integrity": "sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==",
"cpu": [
"ppc64"
],
@@ -713,9 +729,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.55.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.2.tgz",
"integrity": "sha512-i+sGeRGsjKZcQRh3BRfpLsM3LX3bi4AoEVqmGDyc50L6KfYsN45wVCSz70iQMwPWr3E5opSiLOwsC9WB4/1pqg==",
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.56.0.tgz",
"integrity": "sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==",
"cpu": [
"riscv64"
],
@@ -727,9 +743,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.55.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.2.tgz",
"integrity": "sha512-C1vLcKc4MfFV6I0aWsC7B2Y9QcsiEcvKkfxprwkPfLaN8hQf0/fKHwSF2lcYzA9g4imqnhic729VB9Fo70HO3Q==",
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.56.0.tgz",
"integrity": "sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==",
"cpu": [
"riscv64"
],
@@ -741,9 +757,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.55.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.2.tgz",
"integrity": "sha512-68gHUK/howpQjh7g7hlD9DvTTt4sNLp1Bb+Yzw2Ki0xvscm2cOdCLZNJNhd2jW8lsTPrHAHuF751BygifW4bkQ==",
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.56.0.tgz",
"integrity": "sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==",
"cpu": [
"s390x"
],
@@ -755,9 +771,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.55.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.2.tgz",
"integrity": "sha512-1e30XAuaBP1MAizaOBApsgeGZge2/Byd6wV4a8oa6jPdHELbRHBiw7wvo4dp7Ie2PE8TZT4pj9RLGZv9N4qwlw==",
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.56.0.tgz",
"integrity": "sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==",
"cpu": [
"x64"
],
@@ -769,9 +785,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.55.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.2.tgz",
"integrity": "sha512-4BJucJBGbuGnH6q7kpPqGJGzZnYrpAzRd60HQSt3OpX/6/YVgSsJnNzR8Ot74io50SeVT4CtCWe/RYIAymFPwA==",
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.56.0.tgz",
"integrity": "sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==",
"cpu": [
"x64"
],
@@ -783,9 +799,9 @@
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.55.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.2.tgz",
"integrity": "sha512-cT2MmXySMo58ENv8p6/O6wI/h/gLnD3D6JoajwXFZH6X9jz4hARqUhWpGuQhOgLNXscfZYRQMJvZDtWNzMAIDw==",
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.56.0.tgz",
"integrity": "sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==",
"cpu": [
"x64"
],
@@ -797,9 +813,9 @@
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.55.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.2.tgz",
"integrity": "sha512-sZnyUgGkuzIXaK3jNMPmUIyJrxu/PjmATQrocpGA1WbCPX8H5tfGgRSuYtqBYAvLuIGp8SPRb1O4d1Fkb5fXaQ==",
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.56.0.tgz",
"integrity": "sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ==",
"cpu": [
"arm64"
],
@@ -811,9 +827,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.55.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.2.tgz",
"integrity": "sha512-sDpFbenhmWjNcEbBcoTV0PWvW5rPJFvu+P7XoTY0YLGRupgLbFY0XPfwIbJOObzO7QgkRDANh65RjhPmgSaAjQ==",
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.56.0.tgz",
"integrity": "sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing==",
"cpu": [
"arm64"
],
@@ -825,9 +841,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.55.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.2.tgz",
"integrity": "sha512-GvJ03TqqaweWCigtKQVBErw2bEhu1tyfNQbarwr94wCGnczA9HF8wqEe3U/Lfu6EdeNP0p6R+APeHVwEqVxpUQ==",
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.56.0.tgz",
"integrity": "sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg==",
"cpu": [
"ia32"
],
@@ -839,9 +855,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.55.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.2.tgz",
"integrity": "sha512-KvXsBvp13oZz9JGe5NYS7FNizLe99Ny+W8ETsuCyjXiKdiGrcz2/J/N8qxZ/RSwivqjQguug07NLHqrIHrqfYw==",
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.56.0.tgz",
"integrity": "sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ==",
"cpu": [
"x64"
],
@@ -853,9 +869,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.55.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.2.tgz",
"integrity": "sha512-xNO+fksQhsAckRtDSPWaMeT1uIM+JrDRXlerpnWNXhn1TdB3YZ6uKBMBTKP0eX9XtYEP978hHk1f8332i2AW8Q==",
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.56.0.tgz",
"integrity": "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==",
"cpu": [
"x64"
],
@@ -899,9 +915,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "20.19.29",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.29.tgz",
"integrity": "sha512-YrT9ArrGaHForBaCNwFjoqJWmn8G1Pr7+BH/vwyLHciA9qT/wSiuOhxGCT50JA5xLvFBd6PIiGkE3afxcPE1nw==",
"version": "20.19.30",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz",
"integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -919,21 +935,13 @@
"kleur": "^3.0.3"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.27",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"devOptional": true,
"version": "19.2.9",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz",
"integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
}
},
@@ -1109,6 +1117,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/aws4fetch": {
"version": "1.0.20",
"resolved": "https://registry.npmjs.org/aws4fetch/-/aws4fetch-1.0.20.tgz",
"integrity": "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==",
"license": "MIT"
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@@ -1236,7 +1250,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/emoji-regex": {
@@ -1265,9 +1279,9 @@
"license": "MIT"
},
"node_modules/es-toolkit": {
"version": "1.43.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.43.0.tgz",
"integrity": "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==",
"version": "1.44.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz",
"integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==",
"license": "MIT",
"workspaces": [
"docs",
@@ -1421,43 +1435,43 @@
}
},
"node_modules/ink": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/ink/-/ink-5.2.1.tgz",
"integrity": "sha512-BqcUyWrG9zq5HIwW6JcfFHsIYebJkWWb4fczNah1goUO0vv5vneIlfwuS85twyJ5hYR/y18FlAYUxrO9ChIWVg==",
"name": "@jrichman/ink",
"version": "6.4.7",
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.7.tgz",
"integrity": "sha512-QHyxhNF5VonF5cRmdAJD/UPucB9nRx3FozWMjQrDGfBxfAL9lpyu72/MlFPgloS1TMTGsOt7YN6dTPPA6mh0Aw==",
"license": "MIT",
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.1.3",
"@alcalzone/ansi-tokenize": "^0.2.1",
"ansi-escapes": "^7.0.0",
"ansi-styles": "^6.2.1",
"auto-bind": "^5.0.1",
"chalk": "^5.3.0",
"chalk": "^5.6.0",
"cli-boxes": "^3.0.0",
"cli-cursor": "^4.0.0",
"cli-truncate": "^4.0.0",
"code-excerpt": "^4.0.0",
"es-toolkit": "^1.22.0",
"es-toolkit": "^1.39.10",
"indent-string": "^5.0.0",
"is-in-ci": "^1.0.0",
"is-in-ci": "^2.0.0",
"mnemonist": "^0.40.3",
"patch-console": "^2.0.0",
"react-reconciler": "^0.29.0",
"scheduler": "^0.23.0",
"react-reconciler": "^0.32.0",
"signal-exit": "^3.0.7",
"slice-ansi": "^7.1.0",
"stack-utils": "^2.0.6",
"string-width": "^7.2.0",
"string-width": "^8.1.0",
"type-fest": "^4.27.0",
"widest-line": "^5.0.0",
"wrap-ansi": "^9.0.0",
"ws": "^8.18.0",
"yoga-layout": "~3.2.1"
},
"engines": {
"node": ">=18"
"node": ">=20"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"react": ">=18.0.0",
"react-devtools-core": "^4.19.1"
"@types/react": ">=19.0.0",
"react": ">=19.0.0",
"react-devtools-core": "^6.1.2"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -1502,6 +1516,22 @@
}
}
},
"node_modules/ink/node_modules/string-width": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz",
"integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==",
"license": "MIT",
"dependencies": {
"get-east-asian-width": "^1.3.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz",
@@ -1515,15 +1545,15 @@
}
},
"node_modules/is-in-ci": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-1.0.0.tgz",
"integrity": "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-2.0.0.tgz",
"integrity": "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==",
"license": "MIT",
"bin": {
"is-in-ci": "cli.js"
},
"engines": {
"node": ">=18"
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
@@ -1553,12 +1583,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
"node_modules/kleur": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
@@ -1596,18 +1620,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/lru-cache": {
"version": "11.2.4",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
@@ -1675,6 +1687,15 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/mnemonist": {
"version": "0.40.3",
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.3.tgz",
"integrity": "sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ==",
"license": "MIT",
"dependencies": {
"obliterator": "^2.0.4"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -1694,6 +1715,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/obliterator": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
"integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==",
"license": "MIT"
},
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
@@ -1904,31 +1931,27 @@
}
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-reconciler": {
"version": "0.29.2",
"resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz",
"integrity": "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==",
"version": "0.32.0",
"resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.32.0.tgz",
"integrity": "sha512-2NPMOzgTlG0ZWdIf3qG+dcbLSoAc/uLfOwckc3ofy5sSK0pLJqnQLpUFxvGcN2rlXSjnVtGeeFLNimCQEj5gOQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
"scheduler": "^0.26.0"
},
"engines": {
"node": ">=0.10.0"
},
"peerDependencies": {
"react": "^18.3.1"
"react": "^19.1.0"
}
},
"node_modules/restore-cursor": {
@@ -1968,9 +1991,9 @@
}
},
"node_modules/rollup": {
"version": "4.55.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.2.tgz",
"integrity": "sha512-PggGy4dhwx5qaW+CKBilA/98Ql9keyfnb7lh4SR6shQ91QQQi1ORJ1v4UinkdP2i87OBs9AQFooQylcrrRfIcg==",
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.56.0.tgz",
"integrity": "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1984,42 +2007,39 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.55.2",
"@rollup/rollup-android-arm64": "4.55.2",
"@rollup/rollup-darwin-arm64": "4.55.2",
"@rollup/rollup-darwin-x64": "4.55.2",
"@rollup/rollup-freebsd-arm64": "4.55.2",
"@rollup/rollup-freebsd-x64": "4.55.2",
"@rollup/rollup-linux-arm-gnueabihf": "4.55.2",
"@rollup/rollup-linux-arm-musleabihf": "4.55.2",
"@rollup/rollup-linux-arm64-gnu": "4.55.2",
"@rollup/rollup-linux-arm64-musl": "4.55.2",
"@rollup/rollup-linux-loong64-gnu": "4.55.2",
"@rollup/rollup-linux-loong64-musl": "4.55.2",
"@rollup/rollup-linux-ppc64-gnu": "4.55.2",
"@rollup/rollup-linux-ppc64-musl": "4.55.2",
"@rollup/rollup-linux-riscv64-gnu": "4.55.2",
"@rollup/rollup-linux-riscv64-musl": "4.55.2",
"@rollup/rollup-linux-s390x-gnu": "4.55.2",
"@rollup/rollup-linux-x64-gnu": "4.55.2",
"@rollup/rollup-linux-x64-musl": "4.55.2",
"@rollup/rollup-openbsd-x64": "4.55.2",
"@rollup/rollup-openharmony-arm64": "4.55.2",
"@rollup/rollup-win32-arm64-msvc": "4.55.2",
"@rollup/rollup-win32-ia32-msvc": "4.55.2",
"@rollup/rollup-win32-x64-gnu": "4.55.2",
"@rollup/rollup-win32-x64-msvc": "4.55.2",
"@rollup/rollup-android-arm-eabi": "4.56.0",
"@rollup/rollup-android-arm64": "4.56.0",
"@rollup/rollup-darwin-arm64": "4.56.0",
"@rollup/rollup-darwin-x64": "4.56.0",
"@rollup/rollup-freebsd-arm64": "4.56.0",
"@rollup/rollup-freebsd-x64": "4.56.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.56.0",
"@rollup/rollup-linux-arm-musleabihf": "4.56.0",
"@rollup/rollup-linux-arm64-gnu": "4.56.0",
"@rollup/rollup-linux-arm64-musl": "4.56.0",
"@rollup/rollup-linux-loong64-gnu": "4.56.0",
"@rollup/rollup-linux-loong64-musl": "4.56.0",
"@rollup/rollup-linux-ppc64-gnu": "4.56.0",
"@rollup/rollup-linux-ppc64-musl": "4.56.0",
"@rollup/rollup-linux-riscv64-gnu": "4.56.0",
"@rollup/rollup-linux-riscv64-musl": "4.56.0",
"@rollup/rollup-linux-s390x-gnu": "4.56.0",
"@rollup/rollup-linux-x64-gnu": "4.56.0",
"@rollup/rollup-linux-x64-musl": "4.56.0",
"@rollup/rollup-openbsd-x64": "4.56.0",
"@rollup/rollup-openharmony-arm64": "4.56.0",
"@rollup/rollup-win32-arm64-msvc": "4.56.0",
"@rollup/rollup-win32-ia32-msvc": "4.56.0",
"@rollup/rollup-win32-x64-gnu": "4.56.0",
"@rollup/rollup-win32-x64-msvc": "4.56.0",
"fsevents": "~2.3.2"
}
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
}
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
"integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
"license": "MIT"
},
"node_modules/siginfo": {
"version": "2.0.0",
@@ -2882,21 +2902,6 @@
"node": ">=8"
}
},
"node_modules/widest-line": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz",
"integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==",
"license": "MIT",
"dependencies": {
"string-width": "^7.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/wrap-ansi": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
+39 -10
View File
@@ -1,35 +1,59 @@
{
"name": "@cline/cli",
"version": "1.0.0",
"description": "Cline CLI - TypeScript implementation that reuses core Cline functionality",
"version": "2.0.0",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"main": "dist/cli.mjs",
"bin": {
"clinedev": "./dist/cli.mjs"
"cline": "./dist/cli.mjs"
},
"type": "module",
"engines": {
"node": ">=20.0.0"
},
"scripts": {
"build": "node esbuild.mjs",
"build:production": "node esbuild.mjs --production",
"watch": "node esbuild.mjs --watch",
"dev": "npm run watch",
"dev": "IS_DEV=true && npm run link && npm run watch ; npm run unlink",
"clean": "rimraf dist",
"typecheck": "tsc --noEmit",
"link": "npm run build && npm link",
"unlink": "npm unlink -g @cline/cli && npm unlink -g cline",
"test": "vitest",
"test:run": "vitest run"
},
"keywords": [
"cline",
"cli",
"claude",
"dev",
"mcp",
"openrouter",
"coding",
"agent",
"autonomous",
"chatgpt",
"sonnet",
"ai",
"coding-assistant"
"llama",
"cli"
],
"author": "Cline Bot Inc.",
"author": {
"name": "Cline Bot Inc."
},
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "https://github.com/cline/cline"
},
"homepage": "https://cline.bot",
"bugs": {
"url": "https://github.com/cline/cline/issues"
},
"devDependencies": {
"@types/node": "20.x",
"@types/prompts": "^2.4.9",
"@types/react": "^18.3.27",
"@types/react": "^19.2.9",
"dotenv": "^16.4.5",
"esbuild": "^0.25.0",
"ink-testing-library": "^4.0.0",
"rimraf": "^6.0.1",
@@ -37,12 +61,17 @@
"vitest": "^4.0.17"
},
"dependencies": {
"aws4fetch": "^1.0.20",
"chalk": "^5.3.0",
"commander": "^12.1.0",
"ink": "^5.0.1",
"ink": "npm:@jrichman/ink@6.4.7",
"ink-picture": "^1.3.3",
"ink-spinner": "^5.0.0",
"ora": "^8.0.1",
"nanoid": "^5.1.6",
"pino": "^10.0.0",
"pino-roll": "^4.0.0",
"prompts": "^2.4.2",
"react": "^18.3.0"
"react": "^19.2.3"
}
}
+2 -2
View File
@@ -35,7 +35,7 @@ function formatBalance(balance: number | null): string {
return `$${(balance / 1000000).toFixed(2)}`
}
export const AccountInfoView: React.FC<AccountInfoViewProps> = ({ controller }) => {
export const AccountInfoView: React.FC<AccountInfoViewProps> = React.memo(({ controller }) => {
const [provider, setProvider] = useState<string | null>(null)
const [balance, setBalance] = useState<number | null>(null)
const [organization, setOrganization] = useState<ClineAccountOrganization | null>(null)
@@ -191,4 +191,4 @@ export const AccountInfoView: React.FC<AccountInfoViewProps> = ({ controller })
</Box>
</Box>
)
}
})
+329
View File
@@ -0,0 +1,329 @@
/**
* Action buttons component for CLI
* Shows primary/secondary buttons above the input field
* Supports keyboard navigation (1/2 for buttons, arrows to navigate, esc to cancel)
*/
import type { ClineMessage } from "@shared/ExtensionMessage"
import { Box, Text } from "ink"
import React from "react"
import { COLORS } from "../constants/colors"
import { isFileSaveTool, parseToolFromMessage } from "../utils/tools"
/**
* Button action types that determine the behavior
*/
export type ButtonActionType =
| "approve" // Send yesButtonClicked
| "reject" // Send noButtonClicked
| "proceed" // Send messageResponse or yesButtonClicked
| "new_task" // Start a new task
| "cancel" // Cancel streaming
| "retry" // Retry the last action
/**
* Button configuration for different message states
*/
export interface ButtonConfig {
sendingDisabled: boolean
enableButtons: boolean
primaryText?: string
secondaryText?: string
primaryAction?: ButtonActionType
secondaryAction?: ButtonActionType
}
/**
* Centralized button state configurations based on task lifecycle
*/
const BUTTON_CONFIGS: Record<string, ButtonConfig> = {
// Error recovery states
api_req_failed: {
sendingDisabled: true,
enableButtons: true,
primaryText: "Retry",
secondaryText: "Start New Task",
primaryAction: "retry",
secondaryAction: "new_task",
},
mistake_limit_reached: {
sendingDisabled: false,
enableButtons: true,
primaryText: "Proceed Anyways",
secondaryText: "Start New Task",
primaryAction: "proceed",
secondaryAction: "new_task",
},
// Tool approval states
tool_approve: {
sendingDisabled: false,
enableButtons: true,
primaryText: "Approve",
secondaryText: "Reject",
primaryAction: "approve",
secondaryAction: "reject",
},
tool_save: {
sendingDisabled: false,
enableButtons: true,
primaryText: "Save",
secondaryText: "Reject",
primaryAction: "approve",
secondaryAction: "reject",
},
// Command execution states
command: {
sendingDisabled: false,
enableButtons: true,
primaryText: "Run Command",
secondaryText: "Reject",
primaryAction: "approve",
secondaryAction: "reject",
},
command_output: {
sendingDisabled: false,
enableButtons: true,
primaryText: "Proceed While Running",
secondaryText: undefined,
primaryAction: "proceed",
secondaryAction: undefined,
},
// Browser and external tool states
browser_action_launch: {
sendingDisabled: false,
enableButtons: true,
primaryText: "Approve",
secondaryText: "Reject",
primaryAction: "approve",
secondaryAction: "reject",
},
use_mcp_server: {
sendingDisabled: false,
enableButtons: true,
primaryText: "Approve",
secondaryText: "Reject",
primaryAction: "approve",
secondaryAction: "reject",
},
followup: {
sendingDisabled: false,
enableButtons: false,
primaryText: undefined,
secondaryText: undefined,
primaryAction: undefined,
secondaryAction: undefined,
},
plan_mode_respond: {
sendingDisabled: false,
enableButtons: false,
primaryText: undefined,
secondaryText: undefined,
primaryAction: undefined,
secondaryAction: undefined,
},
// Task lifecycle states
completion_result: {
sendingDisabled: false,
enableButtons: true,
primaryText: "Start New Task",
secondaryText: undefined,
primaryAction: "new_task",
secondaryAction: undefined,
},
resume_task: {
sendingDisabled: false,
enableButtons: true,
primaryText: "Resume Task",
secondaryText: "Exit",
primaryAction: "proceed",
secondaryAction: "reject",
},
resume_completed_task: {
sendingDisabled: false,
enableButtons: true,
primaryText: "Start New Task",
secondaryText: "Exit",
primaryAction: "new_task",
secondaryAction: "reject",
},
new_task: {
sendingDisabled: false,
enableButtons: true,
primaryText: "Start New Task with Context",
secondaryText: undefined,
primaryAction: "new_task",
secondaryAction: undefined,
},
// Streaming/partial states
partial: {
sendingDisabled: true,
enableButtons: true,
primaryText: undefined,
secondaryText: "Cancel",
primaryAction: undefined,
secondaryAction: "cancel",
},
// Default states
default: {
sendingDisabled: false,
enableButtons: false,
primaryText: undefined,
secondaryText: undefined,
primaryAction: undefined,
secondaryAction: undefined,
},
api_req_active: {
sendingDisabled: true,
enableButtons: true,
primaryText: undefined,
secondaryText: "Cancel",
primaryAction: undefined,
secondaryAction: "cancel",
},
}
const errorTypes = ["api_req_failed", "mistake_limit_reached"]
/**
* Get button configuration based on message type and state
*/
export function getButtonConfig(message: ClineMessage | undefined, isStreaming: boolean = false): ButtonConfig {
if (!message) {
return BUTTON_CONFIGS.default
}
const isError = message?.ask ? errorTypes.includes(message.ask) : false
// Special case: command_output should show "Proceed While Running" button even while streaming
if (message.type === "ask" && message.ask === "command_output") {
return BUTTON_CONFIGS.command_output
}
// Handle partial/streaming messages first
if (isStreaming && !isError) {
return BUTTON_CONFIGS.partial
}
// Handle ask messages (user interaction required)
if (message.type === "ask") {
switch (message.ask) {
// Error recovery states
case "api_req_failed":
return BUTTON_CONFIGS.api_req_failed
case "mistake_limit_reached":
return BUTTON_CONFIGS.mistake_limit_reached
// Tool approval (most common)
case "tool": {
const toolInfo = parseToolFromMessage(message.text)
if (toolInfo && isFileSaveTool(toolInfo.toolName)) {
return BUTTON_CONFIGS.tool_save
}
return BUTTON_CONFIGS.tool_approve
}
// Command execution
case "command":
return BUTTON_CONFIGS.command
case "command_output":
return BUTTON_CONFIGS.command_output
// Standard approvals
case "followup":
return BUTTON_CONFIGS.followup
case "browser_action_launch":
return BUTTON_CONFIGS.browser_action_launch
case "use_mcp_server":
return BUTTON_CONFIGS.use_mcp_server
case "plan_mode_respond":
return BUTTON_CONFIGS.plan_mode_respond
// Task lifecycle
case "completion_result":
return BUTTON_CONFIGS.completion_result
case "resume_task":
return BUTTON_CONFIGS.resume_task
case "resume_completed_task":
return BUTTON_CONFIGS.resume_completed_task
case "new_task":
return BUTTON_CONFIGS.new_task
default:
return BUTTON_CONFIGS.tool_approve
}
}
// Handle say messages
if (message.type === "say" && message.say === "api_req_started") {
return BUTTON_CONFIGS.api_req_active
}
if (message.type === "say" && message.say === "command_output") {
return BUTTON_CONFIGS.command_output
}
return BUTTON_CONFIGS.partial
}
interface ActionButtonsProps {
config: ButtonConfig
mode?: "act" | "plan"
}
/**
* Action buttons component
* Shows primary and/or secondary buttons based on config
* Buttons take full width (one button = full, two buttons = half each)
* Does not show cancel-only buttons (ThinkingIndicator handles that with esc)
*/
export const ActionButtons: React.FC<ActionButtonsProps> = ({ config, mode = "act" }) => {
if (!config.enableButtons) {
return null
}
// Don't show cancel buttons (ThinkingIndicator handles esc to interrupt)
// Don't show new_task buttons (CLI doesn't handle starting new tasks)
const hiddenActions = ["cancel", "new_task"]
const hasPrimary = !!config.primaryText && !hiddenActions.includes(config.primaryAction || "")
const hasSecondary = !!config.secondaryText && !hiddenActions.includes(config.secondaryAction || "")
if (!hasPrimary && !hasSecondary) {
return null
}
// Calculate button widths based on terminal width
const terminalWidth = process.stdout.columns || 80
const buttonCount = (hasPrimary ? 1 : 0) + (hasSecondary ? 1 : 0)
const gapWidth = buttonCount > 1 ? 1 : 0 // 1 char gap between buttons
const availableWidth = terminalWidth - 2 - gapWidth // 1 space padding on each side
const buttonWidth = Math.floor(availableWidth / buttonCount)
const modeColor = mode === "plan" ? "yellow" : COLORS.primaryBlue
const renderButton = (text: string, shortcut: string) => {
const label = ` ${text} (${shortcut}) `
const padding = Math.max(0, buttonWidth - label.length)
const leftPad = Math.floor(padding / 2)
const rightPad = padding - leftPad
const paddedLabel = " ".repeat(leftPad) + label + " ".repeat(rightPad)
return (
<Text backgroundColor={modeColor} color="black">
{paddedLabel}
</Text>
)
}
return (
<Box flexDirection="row" gap={1} marginLeft={1} width="100%">
{hasPrimary && renderButton(config.primaryText!, "1")}
{hasSecondary && renderButton(config.secondaryText!, "2")}
</Box>
)
}
+74
View File
@@ -0,0 +1,74 @@
/**
* Reusable API key input component
* Shows a password-masked input field for entering API keys
*/
import { Box, Text, useInput } from "ink"
import React from "react"
import { COLORS } from "../constants/colors"
import { useStdinContext } from "../context/StdinContext"
import { isMouseEscapeSequence } from "../utils/input"
interface ApiKeyInputProps {
providerName: string
value: string
onChange: (value: string) => void
onSubmit: (value: string) => void
onCancel: () => void
isActive?: boolean
}
export const ApiKeyInput: React.FC<ApiKeyInputProps> = ({
providerName,
value,
onChange,
onSubmit,
onCancel,
isActive = true,
}) => {
const { isRawModeSupported } = useStdinContext()
useInput(
(input, key) => {
// Filter out mouse escape sequences
if (isMouseEscapeSequence(input)) {
return
}
if (key.escape) {
onCancel()
return
}
if (key.return) {
onSubmit(value)
return
}
if (key.backspace || key.delete) {
onChange(value.slice(0, -1))
return
}
if (input && !key.ctrl && !key.meta) {
onChange(value + input)
}
},
{ isActive: isRawModeSupported && isActive },
)
return (
<Box flexDirection="column">
<Text bold color={COLORS.primaryBlue}>
{providerName} API Key
</Text>
<Box marginTop={1}>
<Text color="gray">Paste your API key below</Text>
</Box>
<Box marginTop={1}>
<Text color="white">{"•".repeat(value.length)}</Text>
<Text inverse> </Text>
</Box>
<Box marginTop={1}>
<Text color="gray">Enter to save, Esc to cancel</Text>
</Box>
</Box>
)
}
+32 -16
View File
@@ -5,9 +5,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"
import { App } from "./App"
// Mock the child components to isolate App routing logic
vi.mock("./TaskView", () => ({
TaskView: ({ taskId, verbose }: any) =>
React.createElement(Text, null, `TaskView: ${taskId || "no-id"} verbose=${String(verbose)}`),
vi.mock("./ChatView", () => ({
ChatView: ({ taskId, controller }: any) =>
React.createElement(Text, null, `ChatView: ${taskId || "no-id"} controller=${controller ? "present" : "none"}`),
}))
vi.mock("./TaskJsonView", () => ({
TaskJsonView: ({ taskId, verbose }: any) =>
React.createElement(Text, null, `TaskJsonView: ${taskId || "no-id"} verbose=${String(verbose)}`),
}))
vi.mock("./HistoryView", () => ({
@@ -22,14 +27,14 @@ vi.mock("./AuthView", () => ({
AuthView: ({ quickSetup }: any) => React.createElement(Text, null, `AuthView: ${quickSetup?.provider || "no-provider"}`),
}))
vi.mock("./WelcomeView", () => ({
WelcomeView: () => React.createElement(Text, null, "WelcomeView"),
}))
vi.mock("../context/TaskContext", () => ({
TaskContextProvider: ({ children }: any) => children,
}))
vi.mock("../context/StdinContext", () => ({
StdinProvider: ({ children }: any) => children,
}))
describe("App", () => {
const mockController = {
dispose: vi.fn(),
@@ -41,9 +46,15 @@ describe("App", () => {
})
describe("view routing", () => {
it("should render TaskView when view is task", () => {
it("should render ChatView when view is task", () => {
const { lastFrame } = render(<App controller={mockController} taskId="test-task" view="task" />)
expect(lastFrame()).toContain("TaskView")
expect(lastFrame()).toContain("ChatView")
expect(lastFrame()).toContain("test-task")
})
it("should render TaskJsonView when view is task with jsonOutput", () => {
const { lastFrame } = render(<App controller={mockController} jsonOutput={true} taskId="test-task" view="task" />)
expect(lastFrame()).toContain("TaskJsonView")
expect(lastFrame()).toContain("test-task")
})
@@ -71,17 +82,17 @@ describe("App", () => {
expect(lastFrame()).toContain("openai")
})
it("should render WelcomeView when view is welcome", () => {
it("should render ChatView when view is welcome", () => {
const { lastFrame } = render(
<App controller={mockController} onWelcomeExit={() => {}} onWelcomeSubmit={() => {}} view="welcome" />,
)
expect(lastFrame()).toContain("WelcomeView")
expect(lastFrame()).toContain("ChatView")
})
})
describe("default props", () => {
it("should use default verbose=false", () => {
const { lastFrame } = render(<App controller={mockController} view="task" />)
it("should use default verbose=false with jsonOutput", () => {
const { lastFrame } = render(<App controller={mockController} jsonOutput={true} view="task" />)
expect(lastFrame()).toContain("verbose=false")
})
@@ -92,14 +103,19 @@ describe("App", () => {
})
describe("props passing", () => {
it("should pass verbose to TaskView", () => {
const { lastFrame } = render(<App controller={mockController} verbose={true} view="task" />)
it("should pass verbose to TaskJsonView", () => {
const { lastFrame } = render(<App controller={mockController} jsonOutput={true} verbose={true} view="task" />)
expect(lastFrame()).toContain("verbose=true")
})
it("should pass taskId to TaskView", () => {
it("should pass taskId to ChatView", () => {
const { lastFrame } = render(<App controller={mockController} taskId="my-task-123" view="task" />)
expect(lastFrame()).toContain("my-task-123")
})
it("should pass controller to ChatView", () => {
const { lastFrame } = render(<App controller={mockController} view="task" />)
expect(lastFrame()).toContain("controller=present")
})
})
})
+43 -14
View File
@@ -5,12 +5,13 @@
import { Box } from "ink"
import React, { ReactNode, useCallback, useState } from "react"
import { StdinProvider } from "../context/StdinContext"
import { TaskContextProvider } from "../context/TaskContext"
import { AuthView } from "./AuthView"
import { ChatView } from "./ChatView"
import { ConfigView } from "./ConfigView"
import { HistoryView } from "./HistoryView"
import { TaskView } from "./TaskView"
import { WelcomeView } from "./WelcomeView"
import { TaskJsonView } from "./TaskJsonView"
export type ViewType = "task" | "history" | "config" | "auth" | "welcome"
@@ -42,8 +43,11 @@ interface SkillInfo {
interface AppProps {
view: ViewType
taskId?: string
verbose?: boolean
controller?: any
// Output Style
verbose?: boolean
jsonOutput?: boolean
// Status Callbacks
onComplete?: () => void
onError?: () => void
// For history view
@@ -86,12 +90,19 @@ interface AppProps {
// For welcome view
onWelcomeSubmit?: (prompt: string, imagePaths: string[]) => void
onWelcomeExit?: () => void
initialPrompt?: string
initialImages?: string[]
// Stdin support
isRawModeSupported?: boolean
// Robot position (calculated before Ink mounts)
robotTopRow?: number
}
export const App: React.FC<AppProps> = ({
view: initialView,
taskId,
verbose = false,
jsonOutput = false,
controller,
onComplete,
onError,
@@ -126,6 +137,10 @@ export const App: React.FC<AppProps> = ({
authQuickSetup,
onWelcomeSubmit,
onWelcomeExit,
initialPrompt,
initialImages,
isRawModeSupported = true,
robotTopRow,
}) => {
const [currentView, setCurrentView] = useState<ViewType>(initialView)
const [selectedTaskId, setSelectedTaskId] = useState<string | undefined>(taskId)
@@ -140,7 +155,7 @@ export const App: React.FC<AppProps> = ({
}, [])
// Handle welcome submit when navigating internally (e.g., from auth -> welcome)
const handleInternalWelcomeSubmit = useCallback(
const _handleInternalWelcomeSubmit = useCallback(
async (prompt: string, imagePaths: string[]) => {
if (onWelcomeSubmit) {
// If external handler provided, use it
@@ -176,14 +191,6 @@ export const App: React.FC<AppProps> = ({
let content: ReactNode
switch (currentView) {
case "task":
content = (
<TaskContextProvider controller={controller}>
<TaskView onComplete={onComplete} onError={onError} taskId={selectedTaskId} verbose={verbose} />
</TaskContextProvider>
)
break
case "history":
content = (
<HistoryView
@@ -236,13 +243,35 @@ export const App: React.FC<AppProps> = ({
)
break
case "task":
case "welcome":
content = <WelcomeView controller={controller} onExit={onWelcomeExit} onSubmit={handleInternalWelcomeSubmit} />
content = (
<TaskContextProvider controller={controller}>
{jsonOutput ? (
<TaskJsonView onComplete={onComplete} onError={onError} taskId={selectedTaskId} verbose={verbose} />
) : (
<ChatView
controller={controller}
initialImages={initialImages}
initialPrompt={initialPrompt}
onComplete={onComplete}
onError={onError}
onExit={onWelcomeExit}
robotTopRow={robotTopRow}
taskId={selectedTaskId}
/>
)}
</TaskContextProvider>
)
break
default:
content = null
}
return <Box>{content}</Box>
return (
<StdinProvider isRawModeSupported={isRawModeSupported}>
<Box>{content}</Box>
</StdinProvider>
)
}
File diff suppressed because it is too large Load Diff
+20 -23
View File
@@ -6,8 +6,10 @@
import type { ClineAsk } from "@shared/ExtensionMessage"
import { Box, Text, useApp, useInput } from "ink"
import React, { useCallback, useEffect, useRef, useState } from "react"
import { useStdinContext } from "../context/StdinContext"
import { useTaskController } from "../context/TaskContext"
import { useLastCompletedAskMessage } from "../hooks/useStateSubscriber"
import { isMouseEscapeSequence } from "../utils/input"
import { jsonParseSafe } from "../utils/parser"
import { getCliMessagePrefixIcon } from "./MessageRow"
@@ -60,7 +62,7 @@ function getPromptType(ask: ClineAsk, text: string): PromptType {
export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
const { exit } = useApp()
const { isRawModeSupported } = useStdinContext()
const controller = useTaskController()
const lastAskMessage = useLastCompletedAskMessage()
const [textInput, setTextInput] = useState("")
@@ -108,6 +110,11 @@ export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
// Handle keyboard input
useInput(
(input, key) => {
// Filter out mouse escape sequences
if (isMouseEscapeSequence(input)) {
return
}
if (!lastAskMessage || responded) {
return
}
@@ -142,7 +149,7 @@ export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
const num = parseInt(input, 10)
if (textInput === "" && !Number.isNaN(num) && num >= 1 && num <= parts.options.length) {
const selectedOption = parts.options[num - 1]
sendResponse("optionSelected", selectedOption)
sendResponse("messageResponse", selectedOption)
} else {
// Regular character input for free text
setTextInput((prev) => prev + input)
@@ -195,7 +202,7 @@ export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
}
}
},
{ isActive: !!lastAskMessage && !responded },
{ isActive: isRawModeSupported && !!lastAskMessage && !responded },
)
if (!lastAskMessage || responded) {
@@ -231,11 +238,9 @@ export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
<Text>{icon} </Text>
<Text color="cyan">Or type: </Text>
<Text>{textInput}</Text>
<Text color="gray"></Text>
<Text inverse> </Text>
</Box>
<Text color="gray" dimColor>
(Enter number to select, or type response + Enter)
</Text>
<Text color="gray">(Enter number to select, or type response + Enter)</Text>
</Box>
)
}
@@ -247,11 +252,9 @@ export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
<Text>{icon} </Text>
<Text color="cyan">Reply: </Text>
<Text>{textInput}</Text>
<Text color="gray"></Text>
<Text inverse> </Text>
</Box>
<Text color="gray" dimColor>
(Type your response and press Enter)
</Text>
<Text color="gray">(Type your response and press Enter)</Text>
</Box>
)
}
@@ -275,11 +278,9 @@ export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
<Text>{icon} </Text>
<Text color="cyan">Or type: </Text>
<Text>{textInput}</Text>
<Text color="gray"></Text>
<Text inverse> </Text>
</Box>
<Text color="gray" dimColor>
(Enter number to select, or type response + Enter)
</Text>
<Text color="gray">(Enter number to select, or type response + Enter)</Text>
</Box>
)
}
@@ -291,11 +292,9 @@ export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
<Text>{icon} </Text>
<Text color="cyan">Reply: </Text>
<Text>{textInput}</Text>
<Text color="gray"></Text>
<Text inverse> </Text>
</Box>
<Text color="gray" dimColor>
(Type response + Enter, or just Enter to switch to Act mode)
</Text>
<Text color="gray">(Type response + Enter, or just Enter to switch to Act mode)</Text>
</Box>
)
}
@@ -329,11 +328,9 @@ export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
<Text>{icon} </Text>
<Text color="cyan">Follow-up: </Text>
<Text>{textInput}</Text>
<Text color="gray"></Text>
<Text inverse> </Text>
</Box>
<Text color="gray" dimColor>
(Type follow-up question + Enter, or q to exit)
</Text>
<Text color="gray">(Type follow-up question + Enter, or q to exit)</Text>
</Box>
)
File diff suppressed because it is too large Load Diff
+557
View File
@@ -0,0 +1,557 @@
/**
* Claude Code style chat message component
* Renders messages with:
* - for user messages
* - ⏺ for assistant messages and tool calls
* - ⎿ for tool results (indented)
*/
import { COMMAND_OUTPUT_STRING } from "@shared/combineCommandSequences"
import type { ClineMessage } from "@shared/ExtensionMessage"
import { Box, Text } from "ink"
import React from "react"
import { COLORS } from "../constants/colors"
import { jsonParseSafe } from "../utils/parser"
import { getToolDescription, isFileEditTool, parseToolFromMessage } from "../utils/tools"
import { DiffView } from "./DiffView"
/**
* Render inline markdown: **bold**, *italic*, `code`
* Returns array of React nodes with appropriate styling
*/
function renderInlineMarkdown(text: string): React.ReactNode[] {
const nodes: React.ReactNode[] = []
// Match **bold**, *italic*, or `code` - order matters (** before *)
const regex = /(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g
let lastIndex = 0
let match
while ((match = regex.exec(text)) !== null) {
// Add text before match
if (match.index > lastIndex) {
nodes.push(text.slice(lastIndex, match.index))
}
const fullMatch = match[0]
const key = `md-${match.index}`
if (fullMatch.startsWith("**") && fullMatch.endsWith("**")) {
// Bold
nodes.push(
<Text bold key={key}>
{fullMatch.slice(2, -2)}
</Text>,
)
} else if (fullMatch.startsWith("*") && fullMatch.endsWith("*")) {
// Italic
nodes.push(
<Text italic key={key}>
{fullMatch.slice(1, -1)}
</Text>,
)
} else if (fullMatch.startsWith("`") && fullMatch.endsWith("`")) {
// Inline code
nodes.push(
<Text dimColor key={key}>
{fullMatch.slice(1, -1)}
</Text>,
)
}
lastIndex = regex.lastIndex
}
// Add remaining text
if (lastIndex < text.length) {
nodes.push(text.slice(lastIndex))
}
return nodes.length > 0 ? nodes : [text]
}
/**
* Render text with inline markdown support
*/
const MarkdownText: React.FC<{ children: string; color?: string }> = ({ children, color }) => {
const nodes = renderInlineMarkdown(children)
return <Text color={color}>{nodes}</Text>
}
interface ChatMessageProps {
message: ClineMessage
isStreaming?: boolean
mode?: "act" | "plan"
}
/**
* Two-column layout for messages with a dot prefix.
* Keeps content from wrapping under the dot.
*
* For this to work properly, parent containers must have width="100%"
* so flexGrow={1} on the content box has a reference width to fill.
*/
const DotRow: React.FC<{ children: React.ReactNode; color?: string }> = ({ children, color }) => (
<Box flexDirection="row">
<Box width={2}>
<Text color={color}></Text>
</Box>
<Box flexGrow={1}>{children}</Box>
</Box>
)
/**
* Two-column layout for tool results with ⎿ prefix.
* Keeps content from wrapping under the prefix.
*/
const ResultRow: React.FC<{ children: React.ReactNode; isFirst?: boolean }> = ({ children, isFirst }) => (
<Box flexDirection="row">
<Box width={3}>
<Text dimColor>{isFirst ? "⎿ " : " "}</Text>
</Box>
<Box flexGrow={1}>{children}</Box>
</Box>
)
/**
* Get the primary argument to display for a tool (file path, command, url, etc.)
*/
function getToolMainArg(_toolName: string, args: Record<string, unknown>): string {
// File path
if (typeof args.path === "string") return args.path
if (typeof args.file_path === "string") return args.file_path
// Command - truncate long commands
if (typeof args.command === "string") {
return args.command.length > 60 ? args.command.substring(0, 57) + "..." : args.command
}
// Search regex
if (typeof args.regex === "string") return args.regex
// URL
if (typeof args.url === "string") return args.url
// Search query
if (typeof args.query === "string") return args.query
return ""
}
/**
* Render a tool call in webview style: "Cline wants to read this file:" / "Cline read this file:"
*/
const ToolCallText: React.FC<{
toolName: string
args: Record<string, unknown>
mode?: "act" | "plan"
isAsk?: boolean
}> = ({ toolName, args, mode, isAsk = false }) => {
const desc = getToolDescription(toolName)
const actionText = isAsk ? desc.ask : desc.say
const mainArg = getToolMainArg(toolName, args)
const toolColor = mode === "plan" ? "yellow" : COLORS.primaryBlue
return (
<Text>
<Text color={toolColor}>Cline {actionText}</Text>
{mainArg && (
<Text>
<Text color={toolColor}>: </Text>
<Text>{mainArg}</Text>
</Text>
)}
</Text>
)
}
/**
* Truncate text with ellipsis
*/
function truncate(text: string, maxLength: number): string {
if (text.length <= maxLength) return text
return text.substring(0, maxLength - 3) + "..."
}
/**
* Format tool result for display
*/
function formatToolResult(result: string, maxLines: number = 5): string[] {
const lines = result.split("\n")
if (lines.length <= maxLines) {
return lines
}
const displayLines = lines.slice(0, maxLines)
displayLines.push(`... ${lines.length - maxLines} more lines`)
return displayLines
}
export const ChatMessage: React.FC<ChatMessageProps> = ({ message, mode }) => {
const { type, ask, say, text } = message
const toolColor = mode === "plan" ? "yellow" : COLORS.primaryBlue
// User messages (task, user_feedback)
if (say === "task" || say === "user_feedback") {
return (
<Box flexDirection="column" marginBottom={1}>
<Box backgroundColor="blackBright" paddingRight={1}>
<Text color="white" dimColor>
{" "}
&gt;{" "}
</Text>
<Text color="white">{text}</Text>
</Box>
</Box>
)
}
// Assistant text response (hide reasoning traces - they're verbose and clutter the UI)
if (say === "reasoning") {
return null
}
if (say === "text") {
if (!text?.trim()) return null
return (
<Box flexDirection="column" marginBottom={1} width="100%">
<DotRow>
<Text>{text}</Text>
</DotRow>
</Box>
)
}
// Tool calls (ask) and tool results (say)
const isToolAsk = type === "ask" && ask === "tool"
const isToolSay = say === "tool"
if ((isToolAsk || isToolSay) && text) {
const toolInfo = parseToolFromMessage(text)
if (toolInfo) {
const filePath = toolInfo.args.path || toolInfo.args.file_path
// File edit tools - show diff
if (isFileEditTool(toolInfo.toolName) && filePath && toolInfo.args.content) {
return (
<Box flexDirection="column" marginBottom={1} width="100%">
<DotRow color={toolColor}>
<ToolCallText args={toolInfo.args} isAsk={isToolAsk} mode={mode} toolName={toolInfo.toolName} />
</DotRow>
<Box marginLeft={2}>
<DiffView content={toolInfo.args.content} />
</Box>
</Box>
)
}
// Only show result content for completed tools (say), not for pending asks
const resultLines = isToolSay && toolInfo.result?.trim() ? formatToolResult(toolInfo.result, 5) : []
return (
<Box flexDirection="column" marginBottom={1} width="100%">
<DotRow color={toolColor}>
<ToolCallText args={toolInfo.args} isAsk={isToolAsk} mode={mode} toolName={toolInfo.toolName} />
</DotRow>
{resultLines.length > 0 && (
<Box flexDirection="column" marginLeft={2} width="100%">
{resultLines.map((line, idx) => (
<ResultRow isFirst={idx === 0} key={idx}>
<Text dimColor>{line}</Text>
</ResultRow>
))}
</Box>
)}
</Box>
)
}
// Fallback for unparseable tool messages
if (isToolSay) {
return (
<Box flexDirection="column" marginBottom={1} width="100%">
<DotRow color={toolColor}>
<Text color={toolColor}>{truncate(text, 100)}</Text>
</DotRow>
</Box>
)
}
}
// Command execution (ask or say) - now includes combined output
if ((type === "ask" && ask === "command") || say === "command") {
if (!text) return null
// Parse command and output from combined text
const outputIndex = text.indexOf(COMMAND_OUTPUT_STRING)
const command = outputIndex === -1 ? text : text.slice(0, outputIndex).trim()
const output = outputIndex === -1 ? "" : text.slice(outputIndex + COMMAND_OUTPUT_STRING.length).trim()
const isAsk = type === "ask"
const label = isAsk ? "Cline wants to execute this command: " : "Cline executed this command: "
return (
<Box flexDirection="column" marginBottom={1} width="100%">
<DotRow color={toolColor}>
<Text>
<Text color={toolColor}>{label}</Text>
<Text>{truncate(command, 60)}</Text>
</Text>
</DotRow>
{output && (
<Box flexDirection="column" marginLeft={2} width="100%">
{formatToolResult(output, 8).map((line, idx) => (
<ResultRow isFirst={idx === 0} key={idx}>
<Text dimColor>{line}</Text>
</ResultRow>
))}
</Box>
)}
</Box>
)
}
// Command output - should not appear after combineCommandSequences, but handle as fallback
if (say === "command_output" && text) {
const lines = formatToolResult(text, 8)
return (
<Box flexDirection="column" marginBottom={1} width="100%">
<Box flexDirection="column" marginLeft={2} width="100%">
{lines.map((line, idx) => (
<ResultRow isFirst={idx === 0} key={idx}>
<Text dimColor>{line}</Text>
</ResultRow>
))}
</Box>
</Box>
)
}
// Error messages
if (say === "error" || (type === "ask" && ask === "api_req_failed")) {
// Try to parse error message if it's JSON
let errorMessage = text || "Unknown error"
if (text) {
const parsed = jsonParseSafe(text, { message: undefined as string | undefined })
if (parsed.message) {
errorMessage = parsed.message
}
}
return (
<Box flexDirection="column" marginBottom={1} width="100%">
<DotRow color="red">
<Text bold color="red">
Error
</Text>
<Text color="red">: {errorMessage}</Text>
</DotRow>
</Box>
)
}
// Error retry messages
if (say === "error_retry" && text) {
const retryInfo = jsonParseSafe(text, {
failed: false,
attempt: 0,
maxAttempts: 3,
errorMessage: undefined as string | undefined,
})
// Parse nested errorMessage if it's a JSON string
let errorMsg = "Request failed"
if (retryInfo.errorMessage) {
try {
const errorObj = jsonParseSafe(retryInfo.errorMessage, { message: undefined as string | undefined })
errorMsg = errorObj.message || retryInfo.errorMessage
} catch {
errorMsg = retryInfo.errorMessage
}
}
if (retryInfo.failed) {
return (
<Box flexDirection="column" marginBottom={1} width="100%">
<DotRow color="red">
<Text bold color="red">
Failed
</Text>
<Text color="red"> after {retryInfo.maxAttempts} retries</Text>
</DotRow>
<Box marginLeft={2}>
<Text color="red" dimColor>
{errorMsg}
</Text>
</Box>
</Box>
)
}
return (
<Box flexDirection="column" marginBottom={1} width="100%">
<DotRow color="yellow">
<Text bold color="yellow">
Retrying
</Text>
<Text color="yellow">
... (attempt {retryInfo.attempt}/{retryInfo.maxAttempts})
</Text>
</DotRow>
<Box marginLeft={2}>
<Text color="yellow" dimColor>
{errorMsg}
</Text>
</Box>
</Box>
)
}
// Completion result
// Only render ask: "completion_result" if it has text - the empty ask is just for UI confirmation
if (say === "completion_result" || (type === "ask" && ask === "completion_result" && text)) {
return (
<Box flexDirection="column" marginBottom={1} width="100%">
<DotRow color="green">
<Text color="green">Task completed</Text>
</DotRow>
{text && (
<Box marginLeft={2}>
<MarkdownText color="greenBright">{text}</MarkdownText>
</Box>
)}
</Box>
)
}
// API request info (show cost/tokens inline)
if (say === "api_req_started" && text) {
// Skip showing these - they're summarized in the status bar
return null
}
// Browser actions
if (say === "browser_action" || say === "browser_action_launch") {
return (
<Box flexDirection="column" marginBottom={1} width="100%">
<DotRow color={toolColor}>
<Text>
<Text color={toolColor}>Cline used the browser</Text>
{text && (
<Text>
<Text color={toolColor}>: </Text>
<Text>{truncate(text, 50)}</Text>
</Text>
)}
</Text>
</DotRow>
</Box>
)
}
// MCP server
if (say === "mcp_server_request_started") {
return (
<Box flexDirection="column" marginBottom={1} width="100%">
<DotRow color={toolColor}>
<Text>
<Text color={toolColor}>Cline is using an MCP tool</Text>
{text && (
<Text>
<Text color={toolColor}>: </Text>
<Text>{truncate(text, 50)}</Text>
</Text>
)}
</Text>
</DotRow>
</Box>
)
}
// Info messages
if (say === "info") {
return (
<Box flexDirection="column" marginBottom={1} width="100%">
<DotRow color="gray">
<Text color="gray">{text}</Text>
</DotRow>
</Box>
)
}
// Followup questions from assistant
if (type === "ask" && ask === "followup" && text) {
const parsed = jsonParseSafe(text, {
question: undefined as string | undefined,
options: undefined as string[] | undefined,
selected: undefined as string | undefined,
})
if (parsed.question) {
return (
<Box flexDirection="column" marginBottom={1} width="100%">
<DotRow>
<MarkdownText>{parsed.question}</MarkdownText>
</DotRow>
{parsed.options && parsed.options.length > 0 && (
<Box flexDirection="column" paddingLeft={2}>
{parsed.options.map((opt, idx) => {
const isSelected = parsed.selected === opt
return (
<Text color={isSelected ? "green" : "gray"} key={opt}>
{isSelected ? "✓" : `${idx + 1}.`} {opt}
</Text>
)
})}
</Box>
)}
</Box>
)
}
}
// Plan mode response
if (type === "ask" && ask === "plan_mode_respond" && text) {
const parsed = jsonParseSafe(text, { response: undefined as string | undefined })
if (parsed.response) {
return (
<Box flexDirection="column" marginBottom={1} width="100%">
<DotRow color="yellow">
<MarkdownText color="yellow">{parsed.response}</MarkdownText>
</DotRow>
</Box>
)
}
}
// Skip other message types
return null
}
/**
* Render a list of messages in Claude Code style
*/
interface ChatMessageListProps {
messages: ClineMessage[]
maxMessages?: number
}
export const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, maxMessages }) => {
// Filter out messages we don't want to display
const displayMessages = messages.filter((m) => {
// Skip api_req_finished, they're just markers
if (m.say === "api_req_finished") return false
// Skip empty text messages
if (m.say === "text" && !m.text?.trim()) return false
// Skip checkpoint messages
if (m.say === "checkpoint_created") return false
return true
})
// Optionally limit number of messages shown
const messagesToShow = maxMessages ? displayMessages.slice(-maxMessages) : displayMessages
// Check if last message is streaming
const lastMessage = messagesToShow[messagesToShow.length - 1]
const isLastStreaming = lastMessage?.partial === true
return (
<Box flexDirection="column">
{messagesToShow.map((msg, idx) => (
<ChatMessage isStreaming={idx === messagesToShow.length - 1 && isLastStreaming} key={msg.ts} message={msg} />
))}
</Box>
)
}
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
/**
* Reusable Checkbox component for settings panels
*/
import { Box, Text } from "ink"
import React from "react"
import { COLORS } from "../constants/colors"
interface CheckboxProps {
/** Label displayed next to the checkbox */
label: string
/** Current checked state */
checked: boolean
/** Whether this checkbox is currently selected/focused */
isSelected?: boolean
/** Optional description shown below the label */
description?: string
}
export const Checkbox: React.FC<CheckboxProps> = ({ label, checked, isSelected = false, description }) => {
return (
<Box flexDirection="column">
<Text>
<Text bold color={isSelected ? COLORS.primaryBlue : undefined}>
{isSelected ? "" : " "}{" "}
</Text>
<Text color={isSelected || checked ? COLORS.primaryBlue : "gray"}>{checked ? "[✓]" : "[ ]"}</Text>
<Text color={isSelected ? COLORS.primaryBlue : "white"}> {label}</Text>
{isSelected && <Text color="gray"> (Tab to toggle)</Text>}
</Text>
{description && (
<Box marginLeft={6}>
<Text color="gray">{description}</Text>
</Box>
)}
</Box>
)
}
+49 -54
View File
@@ -6,6 +6,7 @@
import type { ClineMessage } from "@shared/ExtensionMessage"
import { Box, Text, useInput } from "ink"
import React, { useState } from "react"
import { useStdinContext } from "../context/StdinContext"
export type RestoreType = "task" | "workspace" | "taskAndWorkspace"
@@ -78,63 +79,63 @@ const RESTORE_TYPE_OPTIONS: { type: RestoreType; label: string; description: str
]
export const CheckpointMenu: React.FC<CheckpointMenuProps> = ({ messages, onSelect, onCancel }) => {
const { isRawModeSupported } = useStdinContext()
const checkpoints = getCheckpointOptions(messages)
const [selectedCheckpoint, setSelectedCheckpoint] = useState(0)
const [selectedRestoreType, setSelectedRestoreType] = useState(0)
const [stage, setStage] = useState<"checkpoint" | "restoreType">("checkpoint")
useInput((input, key) => {
if (key.escape) {
if (stage === "restoreType") {
setStage("checkpoint")
} else {
onCancel()
useInput(
(input, key) => {
if (key.escape) {
if (stage === "restoreType") {
setStage("checkpoint")
} else {
onCancel()
}
return
}
return
}
if (stage === "checkpoint") {
if (key.upArrow) {
setSelectedCheckpoint((i) => Math.max(0, i - 1))
} else if (key.downArrow) {
setSelectedCheckpoint((i) => Math.min(checkpoints.length - 1, i + 1))
} else if (key.return && checkpoints.length > 0) {
setStage("restoreType")
}
} else if (stage === "restoreType") {
if (key.upArrow) {
setSelectedRestoreType((i) => Math.max(0, i - 1))
} else if (key.downArrow) {
setSelectedRestoreType((i) => Math.min(RESTORE_TYPE_OPTIONS.length - 1, i + 1))
} else if (key.return) {
const checkpoint = checkpoints[selectedCheckpoint]
const restoreType = RESTORE_TYPE_OPTIONS[selectedRestoreType]
if (checkpoint && restoreType) {
onSelect(checkpoint.ts, restoreType.type)
if (stage === "checkpoint") {
if (key.upArrow) {
setSelectedCheckpoint((i) => Math.max(0, i - 1))
} else if (key.downArrow) {
setSelectedCheckpoint((i) => Math.min(checkpoints.length - 1, i + 1))
} else if (key.return && checkpoints.length > 0) {
setStage("restoreType")
}
} else if (stage === "restoreType") {
if (key.upArrow) {
setSelectedRestoreType((i) => Math.max(0, i - 1))
} else if (key.downArrow) {
setSelectedRestoreType((i) => Math.min(RESTORE_TYPE_OPTIONS.length - 1, i + 1))
} else if (key.return) {
const checkpoint = checkpoints[selectedCheckpoint]
const restoreType = RESTORE_TYPE_OPTIONS[selectedRestoreType]
if (checkpoint && restoreType) {
onSelect(checkpoint.ts, restoreType.type)
}
}
}
}
// Quick number selection for checkpoints
if (stage === "checkpoint") {
const num = parseInt(input, 10)
if (!Number.isNaN(num) && num >= 1 && num <= checkpoints.length) {
setSelectedCheckpoint(num - 1)
setStage("restoreType")
// Quick number selection for checkpoints
if (stage === "checkpoint") {
const num = parseInt(input, 10)
if (!Number.isNaN(num) && num >= 1 && num <= checkpoints.length) {
setSelectedCheckpoint(num - 1)
setStage("restoreType")
}
}
}
})
},
{ isActive: isRawModeSupported },
)
if (checkpoints.length === 0) {
return (
<Box borderColor="yellow" borderStyle="round" flexDirection="column" marginTop={1} paddingLeft={1} paddingRight={1}>
<Text color="yellow">No checkpoints available</Text>
<Text color="gray" dimColor>
Checkpoints are created at task completion points
</Text>
<Text color="gray" dimColor>
Press Escape to close
</Text>
<Text color="gray">Checkpoints are created at task completion points</Text>
<Text color="gray">Press Escape to close</Text>
</Box>
)
}
@@ -145,9 +146,7 @@ export const CheckpointMenu: React.FC<CheckpointMenuProps> = ({ messages, onSele
<Text bold color="cyan">
Restore Checkpoint
</Text>
<Text color="gray" dimColor>
Select a checkpoint to restore (/ or number, Enter to select, Escape to cancel)
</Text>
<Text color="gray">Select a checkpoint to restore (/ or number, Enter to select, Escape to cancel)</Text>
<Box flexDirection="column" marginTop={1}>
{checkpoints.map((cp, idx) => {
const isSelected = idx === selectedCheckpoint
@@ -158,9 +157,9 @@ export const CheckpointMenu: React.FC<CheckpointMenuProps> = ({ messages, onSele
<Text color={isSelected ? "green" : "gray"}>{isSelected ? "> " : " "}</Text>
<Text color={isSelected ? "white" : "gray"}>{idx + 1}. </Text>
<Text color={isSelected ? "cyan" : undefined}>{cp.label}</Text>
<Text color="gray"> - </Text>
<Text dimColor>
{dateStr} {timeStr}
<Text color="gray">
{" "}
- {dateStr} {timeStr}
</Text>
</Box>
)
@@ -177,7 +176,7 @@ export const CheckpointMenu: React.FC<CheckpointMenuProps> = ({ messages, onSele
<Text bold color="cyan">
Restore Type
</Text>
<Text color="gray" dimColor>
<Text color="gray">
Restoring to: {selectedCp?.label} ({selectedCp?.date.toLocaleString()})
</Text>
<Box flexDirection="column" marginTop={1}>
@@ -192,17 +191,13 @@ export const CheckpointMenu: React.FC<CheckpointMenuProps> = ({ messages, onSele
</Text>
</Box>
<Box marginLeft={4}>
<Text color="gray" dimColor>
{opt.description}
</Text>
<Text color="gray">{opt.description}</Text>
</Box>
</Box>
)
})}
</Box>
<Text color="gray" dimColor marginTop={1}>
(/ to select, Enter to confirm, Escape to go back)
</Text>
<Text color="gray">(/ to select, Enter to confirm, Escape to go back)</Text>
</Box>
)
}
+10 -12
View File
@@ -12,6 +12,7 @@ import {
} from "@shared/storage/state-keys"
import { Box, Text, useApp, useInput } from "ink"
import React, { useMemo, useState } from "react"
import { useStdinContext } from "../context/StdinContext"
import {
BooleanSelect,
buildConfigEntries,
@@ -99,6 +100,7 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
onOpenFolder,
}) => {
const { exit } = useApp()
const { isRawModeSupported } = useStdinContext()
const [currentTab, setCurrentTab] = useState<TabView>("settings")
const [isEditing, setIsEditing] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(0)
@@ -253,10 +255,10 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
return
}
// List navigation
if (key.upArrow) {
// List navigation (arrow keys and vim-style j/k)
if (key.upArrow || input === "k") {
setSelectedIndex((i) => (i > 0 ? i - 1 : currentListLength - 1))
} else if (key.downArrow) {
} else if (key.downArrow || input === "j") {
setSelectedIndex((i) => (i < currentListLength - 1 ? i + 1 : 0))
}
@@ -289,7 +291,7 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
onOpenFolder(currentTab as "rules" | "workflows" | "hooks" | "skills", isGlobal)
}
},
{ isActive: !isEditing },
{ isActive: isRawModeSupported && !isEditing },
)
// Scrolling window
@@ -505,7 +507,7 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
// Help text based on current tab
const getHelpText = () => {
const base = "↑/↓ Navigate • Tab/1-5 Switch tabs • q/Esc Exit"
const base = "↑/↓/j/k Navigate • Tab/1-5 Switch tabs • q/Esc Exit"
if (currentTab === "settings") {
return `${base} • Enter/e Edit • r Reset`
}
@@ -528,7 +530,7 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
{currentListLength > MAX_VISIBLE && (
<Box marginTop={1}>
<Text color="gray" dimColor>
<Text color="gray">
{startIndex > 0 ? "↑ " : " "}
Showing {startIndex + 1}-{Math.min(startIndex + MAX_VISIBLE, currentListLength)} of {currentListLength}
{startIndex + MAX_VISIBLE < currentListLength ? " ↓" : " "}
@@ -539,13 +541,9 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
<Text color="gray">{SEPARATOR}</Text>
<Box flexDirection="column">
<Text color="gray" dimColor>
{getHelpText()}
</Text>
<Text color="gray">{getHelpText()}</Text>
{currentTab === "settings" && selectedConfigEntry && !selectedConfigEntry.isEditable && (
<Text color="yellow" dimColor>
This field is read-only ({selectedConfigEntry.type} type or not a setting)
</Text>
<Text color="yellow">This field is read-only ({selectedConfigEntry.type} type or not a setting)</Text>
)}
</Box>
</Box>
+36 -39
View File
@@ -4,6 +4,7 @@
import { Box, Text, useInput } from "ink"
import React, { useState } from "react"
import { useStdinContext } from "../context/StdinContext"
// ============================================================================
// Types & Constants
@@ -192,17 +193,22 @@ interface TextInputProps {
}
export const TextInput: React.FC<TextInputProps> = ({ label, onChange, onCancel, onSubmit, type, value }) => {
useInput((input, key) => {
if (key.escape) {
onCancel()
} else if (key.return) {
onSubmit(value)
} else if (key.backspace || key.delete) {
onChange(value.slice(0, -1))
} else if (input && !key.ctrl && !key.meta) {
onChange(value + input)
}
})
const { isRawModeSupported } = useStdinContext()
useInput(
(input, key) => {
if (key.escape) {
onCancel()
} else if (key.return) {
onSubmit(value)
} else if (key.backspace || key.delete) {
onChange(value.slice(0, -1))
} else if (input && !key.ctrl && !key.meta) {
onChange(value + input)
}
},
{ isActive: isRawModeSupported },
)
return (
<Box flexDirection="column" marginTop={1}>
@@ -211,11 +217,9 @@ export const TextInput: React.FC<TextInputProps> = ({ label, onChange, onCancel,
</Text>
<Box>
<Text color="white">{value}</Text>
<Text color="gray"></Text>
<Text inverse> </Text>
</Box>
<Text color="gray" dimColor>
Type: {type} Enter to save Esc to cancel
</Text>
<Text color="gray">Type: {type} Enter to save Esc to cancel</Text>
</Box>
)
}
@@ -228,17 +232,21 @@ interface BooleanSelectProps {
}
export const BooleanSelect: React.FC<BooleanSelectProps> = ({ label, onCancel, onSelect, value }) => {
const { isRawModeSupported } = useStdinContext()
const [selected, setSelected] = useState(value)
useInput((_input, key) => {
if (key.escape) {
onCancel()
} else if (key.return) {
onSelect(selected)
} else if (key.upArrow || key.downArrow) {
setSelected((prev) => !prev)
}
})
useInput(
(_input, key) => {
if (key.escape) {
onCancel()
} else if (key.return) {
onSelect(selected)
} else if (key.upArrow || key.downArrow) {
setSelected((prev) => !prev)
}
},
{ isActive: isRawModeSupported },
)
return (
<Box flexDirection="column" marginTop={1}>
@@ -249,9 +257,7 @@ export const BooleanSelect: React.FC<BooleanSelectProps> = ({ label, onCancel, o
<Text color={selected ? "green" : undefined}>{selected ? " " : " "}true</Text>
<Text color={!selected ? "green" : undefined}>{!selected ? " " : " "}false</Text>
</Box>
<Text color="gray" dimColor>
/ to toggle Enter to save Esc to cancel
</Text>
<Text color="gray">/ to toggle Enter to save Esc to cancel</Text>
</Box>
)
}
@@ -266,12 +272,7 @@ export const ConfigRow: React.FC<{ entry: ConfigEntry; isSelected: boolean }> =
<Text color="cyan">{entry.key}</Text>
<Text color="gray">: </Text>
<Text color={valueColor}>{formatValue(entry.value)}</Text>
{!entry.isEditable && (
<Text color="gray" dimColor>
{" "}
(read-only)
</Text>
)}
{!entry.isEditable && <Text color="gray"> (read-only)</Text>}
</Text>
</Box>
)
@@ -292,11 +293,7 @@ export const ToggleRow: React.FC<{
<Text color={entry.enabled ? "green" : "red"}>{entry.enabled ? "●" : "○"}</Text>
<Text> </Text>
<Text color="white">{fileName}</Text>
{showType && (
<Text color="gray" dimColor>
{typeLabel}
</Text>
)}
{showType && <Text color="gray">{typeLabel}</Text>}
</Text>
</Box>
)
@@ -336,7 +333,7 @@ export const SkillRow: React.FC<{
</Box>
{skill.description && (
<Box marginLeft={4}>
<Text color="gray" dimColor>
<Text color="gray">
{skill.description.length > 60 ? skill.description.slice(0, 57) + "..." : skill.description}
</Text>
</Box>
+30 -25
View File
@@ -12,6 +12,7 @@ import React, { useCallback, useEffect, useState } from "react"
import type { Controller } from "@/core/controller"
import { HostProvider } from "@/hosts/host-provider"
import { StdinProvider } from "../context/StdinContext"
import { ConfigView } from "./ConfigView"
interface HookInfo {
@@ -39,6 +40,7 @@ interface ConfigViewWrapperProps {
workspaceState: Record<string, unknown>
hooksEnabled: boolean
skillsEnabled: boolean
isRawModeSupported?: boolean
}
export const ConfigViewWrapper: React.FC<ConfigViewWrapperProps> = ({
@@ -48,6 +50,7 @@ export const ConfigViewWrapper: React.FC<ConfigViewWrapperProps> = ({
workspaceState: initialWorkspaceState,
hooksEnabled,
skillsEnabled,
isRawModeSupported = true,
}) => {
// Settings state (managed locally for UI updates)
const [globalStateLocal, setGlobalStateLocal] = useState<Record<string, unknown>>(initialGlobalState)
@@ -267,30 +270,32 @@ export const ConfigViewWrapper: React.FC<ConfigViewWrapperProps> = ({
)
return (
<ConfigView
dataDir={dataDir}
globalClineRulesToggles={globalClineRulesToggles}
globalHooks={globalHooks}
globalSkills={globalSkills}
globalState={globalStateLocal}
globalWorkflowToggles={globalWorkflowToggles}
hooksEnabled={hooksEnabled}
localAgentsRulesToggles={localAgentsRulesToggles}
localClineRulesToggles={localClineRulesToggles}
localCursorRulesToggles={localCursorRulesToggles}
localSkills={localSkills}
localWindsurfRulesToggles={localWindsurfRulesToggles}
localWorkflowToggles={localWorkflowToggles}
onOpenFolder={handleOpenFolder}
onToggleHook={handleToggleHook}
onToggleRule={handleToggleRule}
onToggleSkill={handleToggleSkill}
onToggleWorkflow={handleToggleWorkflow}
onUpdateGlobal={handleUpdateGlobal}
onUpdateWorkspace={handleUpdateWorkspace}
skillsEnabled={skillsEnabled}
workspaceHooks={workspaceHooksState}
workspaceState={workspaceStateLocal}
/>
<StdinProvider isRawModeSupported={isRawModeSupported}>
<ConfigView
dataDir={dataDir}
globalClineRulesToggles={globalClineRulesToggles}
globalHooks={globalHooks}
globalSkills={globalSkills}
globalState={globalStateLocal}
globalWorkflowToggles={globalWorkflowToggles}
hooksEnabled={hooksEnabled}
localAgentsRulesToggles={localAgentsRulesToggles}
localClineRulesToggles={localClineRulesToggles}
localCursorRulesToggles={localCursorRulesToggles}
localSkills={localSkills}
localWindsurfRulesToggles={localWindsurfRulesToggles}
localWorkflowToggles={localWorkflowToggles}
onOpenFolder={handleOpenFolder}
onToggleHook={handleToggleHook}
onToggleRule={handleToggleRule}
onToggleSkill={handleToggleSkill}
onToggleWorkflow={handleToggleWorkflow}
onUpdateGlobal={handleUpdateGlobal}
onUpdateWorkspace={handleUpdateWorkspace}
skillsEnabled={skillsEnabled}
workspaceHooks={workspaceHooksState}
workspaceState={workspaceStateLocal}
/>
</StdinProvider>
)
}
+173 -132
View File
@@ -1,59 +1,93 @@
/**
* DiffView component for displaying file diffs in Ink
* Shows unified diff output with colored lines for additions/deletions
* Supports SEARCH/REPLACE format and ApplyPatch format
*/
import { Box, Text } from "ink"
import React from "react"
interface DiffViewProps {
/** File path being displayed */
path: string
/** For newFileCreated: the full content of the new file */
/** Diff content (SEARCH/REPLACE format, ApplyPatch format, or raw content for new files) */
content?: string
/** For editedExistingFile: the unified diff string */
diff?: string
/** Maximum lines to display before truncating */
maxLines?: number
}
interface DiffLine {
type: "add" | "remove" | "context" | "header"
lineNumber?: number
type: "add" | "remove" | "context" | "separator"
content: string
}
/**
* Parse a unified diff string into structured lines
*/
function parseDiff(diff: string): DiffLine[] {
const lines = diff.split("\n")
const result: DiffLine[] = []
let oldLine = 0
let newLine = 0
interface ParsedPatch {
additions: number
deletions: number
lines: DiffLine[]
}
for (const line of lines) {
if (line.startsWith("@@")) {
// Parse hunk header like @@ -1,5 +1,7 @@
const match = line.match(/@@ -(\d+),?\d* \+(\d+),?\d* @@/)
if (match) {
oldLine = parseInt(match[1], 10)
newLine = parseInt(match[2], 10)
// Constants for format markers
const MARKERS = {
SEARCH_BLOCK: "------- SEARCH",
SEARCH_SEPARATOR: "=======",
REPLACE_BLOCK: "+++++++ REPLACE",
NEW_BEGIN: "*** Begin Patch",
NEW_END: "*** End Patch",
} as const
/**
* Parse SEARCH/REPLACE format into diff lines
* Format: ------- SEARCH\n...\n=======\n...\n+++++++ REPLACE
*/
function parseSearchReplaceFormat(content: string): ParsedPatch {
const result: ParsedPatch = { additions: 0, deletions: 0, lines: [] }
// Find all SEARCH blocks
const searchRegex = /-{7,} SEARCH/g
const searchPositions: number[] = []
let match: RegExpExecArray | null
while ((match = searchRegex.exec(content)) !== null) {
searchPositions.push(match.index)
}
for (let i = 0; i < searchPositions.length; i++) {
// Add separator between blocks
if (i > 0) {
result.lines.push({ type: "separator", content: "" })
}
const start = searchPositions[i]
const end = i < searchPositions.length - 1 ? searchPositions[i + 1] : content.length
const blockContent = content.substring(start, end)
// Extract content after SEARCH marker
const afterSearch = blockContent.substring(MARKERS.SEARCH_BLOCK.length).replace(/^\r?\n/, "")
const separatorIndex = afterSearch.indexOf(MARKERS.SEARCH_SEPARATOR)
if (separatorIndex === -1) {
// Still streaming - only SEARCH block available
const searchContent = afterSearch.trimEnd()
for (const line of searchContent.split("\n")) {
result.lines.push({ type: "remove", content: line })
result.deletions++
}
} else {
// Extract SEARCH block (deletions)
const searchContent = afterSearch.substring(0, separatorIndex).replace(/\r?\n$/, "")
for (const line of searchContent.split("\n")) {
result.lines.push({ type: "remove", content: line })
result.deletions++
}
// Extract REPLACE block (additions)
const afterSeparator = afterSearch.substring(separatorIndex + MARKERS.SEARCH_SEPARATOR.length).replace(/^\r?\n/, "")
const replaceEndIndex = afterSeparator.indexOf(MARKERS.REPLACE_BLOCK)
const replaceContent =
replaceEndIndex !== -1
? afterSeparator.substring(0, replaceEndIndex).replace(/\r?\n$/, "")
: afterSeparator.trimEnd()
for (const line of replaceContent.split("\n")) {
result.lines.push({ type: "add", content: line })
result.additions++
}
result.push({ type: "header", content: line })
} else if (line.startsWith("+") && !line.startsWith("+++")) {
result.push({ type: "add", lineNumber: newLine, content: line.slice(1) })
newLine++
} else if (line.startsWith("-") && !line.startsWith("---")) {
result.push({ type: "remove", lineNumber: oldLine, content: line.slice(1) })
oldLine++
} else if (line.startsWith(" ")) {
result.push({ type: "context", lineNumber: newLine, content: line.slice(1) })
oldLine++
newLine++
} else if (line.startsWith("---") || line.startsWith("+++")) {
// File headers - skip or show as header
result.push({ type: "header", content: line })
}
}
@@ -61,115 +95,122 @@ function parseDiff(diff: string): DiffLine[] {
}
/**
* Format line number with padding
* Parse ApplyPatch format into diff lines
* Format: *** Begin Patch\n*** Update File: path\n+line\n-line\n*** End Patch
*/
function formatLineNumber(num: number | undefined, width: number): string {
if (num === undefined) {
return " ".repeat(width)
function parseApplyPatchFormat(content: string): ParsedPatch {
const result: ParsedPatch = { additions: 0, deletions: 0, lines: [] }
const beginIndex = content.indexOf(MARKERS.NEW_BEGIN)
if (beginIndex === -1) return result
const endIndex = content.indexOf(MARKERS.NEW_END)
const contentStart = beginIndex + MARKERS.NEW_BEGIN.length
const contentEnd = endIndex !== -1 ? endIndex : content.length
const patchContent = content.substring(contentStart, contentEnd).trim()
for (const line of patchContent.split("\n")) {
// Skip file header lines
if (line.match(/^\*\*\* (Add|Update|Delete) File:/)) continue
if (line.trim() === "@@") continue
if (line.startsWith("+")) {
const hasSpace = line.startsWith("+ ")
result.lines.push({ type: "add", content: hasSpace ? line.slice(2) : line.slice(1) })
result.additions++
} else if (line.startsWith("-")) {
const hasSpace = line.startsWith("- ")
result.lines.push({ type: "remove", content: hasSpace ? line.slice(2) : line.slice(1) })
result.deletions++
} else if (line.trim()) {
// Context line
result.lines.push({ type: "context", content: line })
}
}
return String(num).padStart(width, " ")
return result
}
/**
* Renders a new file with all lines shown as additions
* Parse tool content into diff lines
* Detects format and delegates to appropriate parser
*/
const NewFileView: React.FC<{ path: string; content: string; maxLines: number }> = ({ path, content, maxLines }) => {
function parseToolContent(content: string): ParsedPatch {
// Try SEARCH/REPLACE format first
if (content.includes(MARKERS.SEARCH_BLOCK)) {
return parseSearchReplaceFormat(content)
}
// Try ApplyPatch format
if (content.includes(MARKERS.NEW_BEGIN)) {
return parseApplyPatchFormat(content)
}
// Fallback: treat as new file (all additions)
const lines = content.split("\n")
const displayLines = lines.slice(0, maxLines)
const lineNumWidth = String(lines.length).length
// Calculate max line length for padding
const maxLineLength = Math.max(...displayLines.map((l) => l.length), 40)
return {
additions: lines.length,
deletions: 0,
lines: lines.map((line) => ({ type: "add", content: line })),
}
}
return (
<Box flexDirection="column">
<Text bold color="green">
+ {path} (new file)
</Text>
{displayLines.map((line, idx) => (
<Box key={idx}>
<Text dimColor>{formatLineNumber(idx + 1, lineNumWidth)} </Text>
<Text backgroundColor="rgb(117, 176, 111)" color="white">
+{line.padEnd(maxLineLength)}
</Text>
// Dim diff colors similar to IDE diff views
const DIFF_COLORS = {
addBg: "rgb(35, 61, 41)", // dark muted green
addFg: "rgb(156, 204, 122)", // light green text
removeBg: "rgb(62, 36, 36)", // dark muted red
removeFg: "rgb(224, 139, 139)", // light red/pink text
} as const
/**
* Render a diff line with full-width background color highlighting
* Uses Box with backgroundColor to handle wrapping properly
*/
const DiffLineRow: React.FC<{ line: DiffLine }> = ({ line }) => {
if (line.type === "separator") {
return <Text> </Text>
}
const prefix = line.type === "add" ? "+" : line.type === "remove" ? "-" : " "
const content = prefix + line.content
switch (line.type) {
case "add":
return (
<Box backgroundColor={DIFF_COLORS.addBg} width="100%">
<Text color={DIFF_COLORS.addFg}>{content}</Text>
</Box>
))}
{lines.length > maxLines && <Text dimColor>... and {lines.length - maxLines} more lines</Text>}
</Box>
)
)
case "remove":
return (
<Box backgroundColor={DIFF_COLORS.removeBg} width="100%">
<Text color={DIFF_COLORS.removeFg}>{content}</Text>
</Box>
)
case "context":
return <Text dimColor>{content}</Text>
default:
return null
}
}
/**
* Renders a unified diff with colored additions and deletions
* DiffView component that renders file edits as a diff
* Supports SEARCH/REPLACE format and ApplyPatch format
*/
const UnifiedDiffView: React.FC<{ path: string; diff: string; maxLines: number }> = ({ path, diff, maxLines }) => {
const diffLines = parseDiff(diff)
const displayLines = diffLines.slice(0, maxLines)
const maxLineNum = Math.max(...diffLines.filter((l) => l.lineNumber !== undefined).map((l) => l.lineNumber!), 0)
const lineNumWidth = String(maxLineNum).length || 3
// Calculate max line length for padding
const maxLineLength = Math.max(...diffLines.map((l) => l.content.length), 40)
export const DiffView: React.FC<DiffViewProps> = ({ content }) => {
if (!content) {
return null
}
const parsed = parseToolContent(content)
return (
<Box flexDirection="column">
<Text bold color="blue">
~ {path} (modified)
</Text>
{displayLines.map((line, idx) => {
switch (line.type) {
case "header":
return (
<Text color="cyan" key={idx}>
{line.content}
</Text>
)
case "add":
return (
<Box key={idx}>
<Text dimColor>{formatLineNumber(line.lineNumber, lineNumWidth)} </Text>
<Text backgroundColor="rgb(117, 176, 111)" color="white">
+{line.content.padEnd(maxLineLength)}
</Text>
</Box>
)
case "remove":
return (
<Box key={idx}>
<Text dimColor>{formatLineNumber(line.lineNumber, lineNumWidth)} </Text>
<Text backgroundColor="rgb(246, 48, 73)" color="white">
-{line.content.padEnd(maxLineLength)}
</Text>
</Box>
)
case "context":
return (
<Box key={idx}>
<Text dimColor>{formatLineNumber(line.lineNumber, lineNumWidth)} </Text>
<Text> {line.content.padEnd(maxLineLength)}</Text>
</Box>
)
default:
return null
}
})}
{diffLines.length > maxLines && <Text dimColor>... and {diffLines.length - maxLines} more lines</Text>}
<Box flexDirection="column" width="100%">
{parsed.lines.map((line, idx) => (
<DiffLineRow key={idx} line={line} />
))}
</Box>
)
}
/**
* DiffView component that renders either a new file or a unified diff
*/
export const DiffView: React.FC<DiffViewProps> = ({ path, content, diff, maxLines = 20 }) => {
// For new files, show all content as additions
if (content && !diff) {
return <NewFileView content={content} maxLines={maxLines} path={path} />
}
// For edited files, show the unified diff
if (diff) {
return <UnifiedDiffView diff={diff} maxLines={maxLines} path={path} />
}
// Fallback if neither content nor diff is provided
return <Text color="blue">{path} (no diff available)</Text>
}
+8 -41
View File
@@ -5,7 +5,9 @@
import { Box, Text } from "ink"
import React from "react"
import { COLORS } from "../constants/colors"
import type { FileSearchResult } from "../utils/file-search"
import { getVisibleWindow } from "../utils/slash-commands"
interface FileMentionMenuProps {
results: FileSearchResult[]
@@ -41,59 +43,24 @@ export const FileMentionMenu: React.FC<FileMentionMenuProps> = ({ results, selec
)
}
// Show max 8 items, centered around selected item
const maxVisible = 8
let startIndex = 0
let endIndex = results.length
if (results.length > maxVisible) {
// Center the selected item in the visible window
const halfWindow = Math.floor(maxVisible / 2)
startIndex = Math.max(0, selectedIndex - halfWindow)
endIndex = Math.min(results.length, startIndex + maxVisible)
// Adjust if we're near the end
if (endIndex - startIndex < maxVisible) {
startIndex = Math.max(0, endIndex - maxVisible)
}
}
const visibleResults = results.slice(startIndex, endIndex)
const { items: visibleResults, startIndex } = getVisibleWindow(results, selectedIndex)
const hasMoreBelow = startIndex + visibleResults.length < results.length
return (
<Box flexDirection="column" marginBottom={1} paddingLeft={1} paddingRight={1}>
{startIndex > 0 && (
<Text color="gray" dimColor>
{startIndex} more...
</Text>
)}
{visibleResults.map((result, idx) => {
const actualIndex = startIndex + idx
const isSelected = actualIndex === selectedIndex
const isSelected = startIndex + idx === selectedIndex
const displayPath = truncatePath(result.path)
return (
<Box key={result.path}>
<Text backgroundColor={isSelected ? "blue" : undefined} color={isSelected ? "white" : undefined}>
{isSelected ? " " : " "}
{displayPath}
<Text color={isSelected ? COLORS.primaryBlue : undefined}>
{isSelected ? "" : " "} {displayPath}
</Text>
</Box>
)
})}
{endIndex < results.length && (
<Text color="gray" dimColor>
{results.length - endIndex} more...
</Text>
)}
<Box>
<Text color="cyan" dimColor>
/ to select, Tab/Enter to insert
</Text>
</Box>
{hasMoreBelow && <Text color="gray">{" "}</Text>}
</Box>
)
}
+189
View File
@@ -0,0 +1,189 @@
/**
* Highlighted input component for CLI
* Renders text with @ mentions and / commands highlighted, plus a movable cursor
*/
import { mentionRegexGlobal } from "@shared/context-mentions"
import { Text } from "ink"
import React from "react"
interface HighlightedInputProps {
text: string
cursorPos?: number
availableCommands?: string[]
}
// Regex for / commands (at start or after whitespace)
const slashCommandRegex = /(^|\s)(\/[a-zA-Z0-9_.-]+)/g
interface Segment {
text: string
type: "normal" | "mention" | "command"
startIndex: number
}
function parseInput(text: string, availableCommands?: string[]): Segment[] {
const highlights: { start: number; end: number; type: "mention" | "command" }[] = []
// Find all mentions
mentionRegexGlobal.lastIndex = 0
let match
while ((match = mentionRegexGlobal.exec(text)) !== null) {
highlights.push({
start: match.index,
end: match.index + match[0].length,
type: "mention",
})
}
// Find first slash command only (must be complete and valid)
slashCommandRegex.lastIndex = 0
const slashMatch = slashCommandRegex.exec(text)
if (slashMatch) {
const prefix = slashMatch[1] || ""
const commandText = slashMatch[2] // e.g., "/help"
const commandName = commandText.slice(1) // e.g., "help"
const commandStart = slashMatch.index + prefix.length
const commandEnd = commandStart + commandText.length
// Only highlight if command exists in available commands (or if no list provided)
if (!availableCommands || availableCommands.includes(commandName)) {
highlights.push({
start: commandStart,
end: commandEnd,
type: "command",
})
}
}
// Sort highlights by start position
highlights.sort((a, b) => a.start - b.start)
// Build segments
const segments: Segment[] = []
let lastIndex = 0
for (const highlight of highlights) {
// Skip overlapping highlights
if (highlight.start < lastIndex) continue
// Add normal text before this highlight
if (highlight.start > lastIndex) {
segments.push({
text: text.slice(lastIndex, highlight.start),
type: "normal",
startIndex: lastIndex,
})
}
// Add highlighted segment
segments.push({
text: text.slice(highlight.start, highlight.end),
type: highlight.type,
startIndex: highlight.start,
})
lastIndex = highlight.end
}
// Add remaining text
if (lastIndex < text.length) {
segments.push({
text: text.slice(lastIndex),
type: "normal",
startIndex: lastIndex,
})
}
// If no segments (empty or whitespace only), add a single normal segment
if (segments.length === 0 && text.length > 0) {
segments.push({
text: text,
type: "normal",
startIndex: 0,
})
}
return segments
}
export const HighlightedInput: React.FC<HighlightedInputProps> = ({ text, cursorPos, availableCommands }) => {
// If no cursor position provided, just render text with highlights (backward compatible)
if (cursorPos === undefined) {
if (!text) return null
const segments = parseInput(text, availableCommands)
return (
<Text>
{segments.map((segment, idx) => {
if (segment.type === "mention" || segment.type === "command") {
return (
<Text backgroundColor="gray" key={idx}>
{segment.text}
</Text>
)
}
return <Text key={idx}>{segment.text}</Text>
})}
</Text>
)
}
// With cursor position - render cursor within the text
const safeCursorPos = Math.min(Math.max(0, cursorPos), text.length)
const segments = parseInput(text, availableCommands)
// Render segments with cursor
const renderSegmentWithCursor = (segment: Segment, segmentIdx: number) => {
const segmentStart = segment.startIndex
const segmentEnd = segmentStart + segment.text.length
const isHighlighted = segment.type === "mention" || segment.type === "command"
// Check if cursor is within this segment
if (safeCursorPos >= segmentStart && safeCursorPos < segmentEnd) {
// Cursor is in this segment - split it
const localCursorPos = safeCursorPos - segmentStart
const beforeCursor = segment.text.slice(0, localCursorPos)
const cursorChar = segment.text[localCursorPos]
const afterCursor = segment.text.slice(localCursorPos + 1)
if (isHighlighted) {
return (
<Text key={segmentIdx}>
{beforeCursor && <Text backgroundColor="gray">{beforeCursor}</Text>}
<Text backgroundColor="gray" inverse>
{cursorChar}
</Text>
{afterCursor && <Text backgroundColor="gray">{afterCursor}</Text>}
</Text>
)
}
return (
<Text key={segmentIdx}>
{beforeCursor}
<Text inverse>{cursorChar}</Text>
{afterCursor}
</Text>
)
}
// Cursor not in this segment - render normally
if (isHighlighted) {
return (
<Text backgroundColor="gray" key={segmentIdx}>
{segment.text}
</Text>
)
}
return <Text key={segmentIdx}>{segment.text}</Text>
}
// Check if cursor is at the end (past all text)
const cursorAtEnd = safeCursorPos >= text.length
return (
<Text>
{segments.map((segment, idx) => renderSegmentWithCursor(segment, idx))}
{cursorAtEnd && <Text inverse> </Text>}
</Text>
)
}
+32 -26
View File
@@ -8,6 +8,7 @@ import React, { useCallback, useState } from "react"
import { Controller } from "@/core/controller"
import { showTaskWithId } from "@/core/controller/task/showTaskWithId"
import { StringRequest } from "@/shared/proto/cline/common"
import { useStdinContext } from "../context/StdinContext"
interface TaskHistoryItem {
id: string
@@ -51,6 +52,7 @@ export const HistoryView: React.FC<HistoryViewProps> = ({
onPageChange,
allItems,
}) => {
const { isRawModeSupported } = useStdinContext()
const [selectedIndex, setSelectedIndex] = useState(0)
const [internalPage, setInternalPage] = useState(pagination?.page ?? 1)
const { stdout } = useStdout()
@@ -103,23 +105,26 @@ export const HistoryView: React.FC<HistoryViewProps> = ({
[useInternalPagination, onPageChange],
)
useInput((input, key) => {
if (key.upArrow) {
setSelectedIndex((prev) => Math.max(0, prev - 1))
} else if (key.downArrow) {
setSelectedIndex((prev) => Math.min(pageItems.length - 1, prev + 1))
} else if (key.return && pageItems[selectedIndex]) {
onSelect(pageItems[selectedIndex])
} else if (key.leftArrow && hasPrevPage) {
handlePageChange(currentPage - 1)
} else if (key.rightArrow && hasNextPage) {
handlePageChange(currentPage + 1)
} else if (input === "n" && hasNextPage) {
handlePageChange(currentPage + 1)
} else if (input === "p" && hasPrevPage) {
handlePageChange(currentPage - 1)
}
})
useInput(
(input, key) => {
if (key.upArrow || input === "k") {
setSelectedIndex((prev) => Math.max(0, prev - 1))
} else if (key.downArrow || input === "j") {
setSelectedIndex((prev) => Math.min(pageItems.length - 1, prev + 1))
} else if (key.return && pageItems[selectedIndex]) {
onSelect(pageItems[selectedIndex])
} else if (key.leftArrow && hasPrevPage) {
handlePageChange(currentPage - 1)
} else if (key.rightArrow && hasNextPage) {
handlePageChange(currentPage + 1)
} else if (input === "n" && hasNextPage) {
handlePageChange(currentPage + 1)
} else if (input === "p" && hasPrevPage) {
handlePageChange(currentPage - 1)
}
},
{ isActive: isRawModeSupported },
)
// Calculate visible window around selected item
const halfVisible = Math.floor(effectiveVisibleCount / 2)
@@ -139,14 +144,15 @@ export const HistoryView: React.FC<HistoryViewProps> = ({
<Text bold color="white">
{"📜 Task History (" + totalCount + " total)"}
</Text>
<Text dimColor>Use to navigate, Enter to select</Text>
<Text color="gray">Use /j/k to navigate, Enter to select</Text>
{totalPages > 1 && (
<Box>
<Text dimColor>
<Text color="gray">
Page {currentPage} of {totalPages}{" "}
</Text>
{hasPrevPage ? <Text color="blue">[ prev] </Text> : <Text dimColor>[ prev] </Text>}
{hasNextPage ? <Text color="blue">[next ]</Text> : <Text dimColor>[next ]</Text>}
{hasPrevPage ? <Text color="blue">[ prev] </Text> : <Text color="gray">[ prev] </Text>}
{hasNextPage ? <Text color="blue">[next ]</Text> : <Text color="gray">[next ]</Text>}
</Box>
)}
<Text>{formatSeparator()}</Text>
@@ -155,7 +161,7 @@ export const HistoryView: React.FC<HistoryViewProps> = ({
<Text>No task history available.</Text>
) : (
<Box flexDirection="column">
{showUpIndicator && <Text dimColor>{" ↑ " + startIndex + " more above"}</Text>}
{showUpIndicator && <Text color="gray">{" ↑ " + startIndex + " more above"}</Text>}
{visibleTasks.map((task, index) => {
const actualIndex = startIndex + index
const isSelected = actualIndex === selectedIndex
@@ -167,7 +173,7 @@ export const HistoryView: React.FC<HistoryViewProps> = ({
<Box flexDirection="column" key={`${task.id}-${actualIndex}`} marginBottom={1}>
<Box>
<Text color={isSelected ? "green" : undefined}>{isSelected ? "> " : " "}</Text>
<Text dimColor>{date}</Text>
<Text color="gray">{date}</Text>
</Box>
<Box marginLeft={4}>
<Text color="cyan">{task.id}</Text>
@@ -180,18 +186,18 @@ export const HistoryView: React.FC<HistoryViewProps> = ({
</Box>
{typeof task.totalCost === "number" && (
<Box marginLeft={4}>
<Text dimColor>Cost: ${task.totalCost ? task.totalCost.toFixed(4) : "0"}</Text>
<Text color="gray">Cost: ${task.totalCost ? task.totalCost.toFixed(4) : "0"}</Text>
</Box>
)}
{task.modelId && (
<Box marginLeft={4}>
<Text dimColor>Model: {task.modelId}</Text>
<Text color="gray">Model: {task.modelId}</Text>
</Box>
)}
</Box>
)
})}
{showDownIndicator && <Text dimColor>{" ↓ " + (items.length - endIndex) + " more below"}</Text>}
{showDownIndicator && <Text color="gray">{" ↓ " + (pageItems.length - endIndex) + " more below"}</Text>}
</Box>
)}
+215
View File
@@ -0,0 +1,215 @@
/**
* Import view component
* Handles importing API keys from competing CLI agents (Codex, OpenCode)
*/
import { Box, Text, useInput } from "ink"
import React, { useCallback, useEffect, useState } from "react"
import { StateManager } from "@/core/storage/StateManager"
import { COLORS } from "../constants/colors"
import { useStdinContext } from "../context/StdinContext"
import {
getProviderDisplayName,
getSourceDisplayName,
type ImportedKey,
type ImportSource,
importFromCodex,
importFromOpenCode,
} from "../utils/import-configs"
type ImportStep = "select" | "confirm" | "saving" | "error"
interface ImportViewProps {
source: ImportSource
onComplete: () => void
onCancel: () => void
}
export const ImportView: React.FC<ImportViewProps> = ({ source, onComplete, onCancel }) => {
const { isRawModeSupported } = useStdinContext()
const [step, setStep] = useState<ImportStep>("select")
const [keys, setKeys] = useState<ImportedKey[]>([])
const [selectedIndex, setSelectedIndex] = useState(0)
const [confirmIndex, setConfirmIndex] = useState(0)
const [errorMessage, setErrorMessage] = useState("")
// Load keys on mount
useEffect(() => {
const result = source === "codex" ? importFromCodex() : importFromOpenCode()
if (result && result.keys.length > 0) {
setKeys(result.keys)
if (result.keys.length === 1) {
// Only one key, go straight to confirm
setStep("confirm")
}
} else {
setErrorMessage(`Could not read API keys from ${getSourceDisplayName(source)} config`)
setStep("error")
}
}, [source])
const handleConfirm = useCallback(async () => {
try {
setStep("saving")
const selectedKey = keys[selectedIndex]
if (!selectedKey) {
setErrorMessage("No key selected")
setStep("error")
return
}
const stateManager = StateManager.get()
const config: Record<string, string> = {
actModeApiProvider: selectedKey.provider,
planModeApiProvider: selectedKey.provider,
apiProvider: selectedKey.provider,
}
// Set API key
config[selectedKey.keyField] = selectedKey.key
// Set model ID if available
if (selectedKey.modelId) {
config.actModeApiModelId = selectedKey.modelId
config.planModeApiModelId = selectedKey.modelId
}
stateManager.setApiConfiguration(config)
await stateManager.flushPendingState()
onComplete()
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : String(error))
setStep("error")
}
}, [keys, selectedIndex, onComplete])
useInput(
(input, key) => {
if (key.escape) {
if (step === "confirm" && keys.length > 1) {
setStep("select")
setConfirmIndex(0)
} else if (step === "error") {
onCancel()
} else {
onCancel()
}
return
}
if (step === "select") {
if (key.upArrow) {
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : keys.length - 1))
} else if (key.downArrow) {
setSelectedIndex((prev) => (prev < keys.length - 1 ? prev + 1 : 0))
} else if (key.return) {
setStep("confirm")
}
} else if (step === "confirm") {
if (key.upArrow || key.downArrow) {
setConfirmIndex((prev) => (prev === 0 ? 1 : 0))
} else if (key.return) {
if (confirmIndex === 0) {
handleConfirm()
} else {
onCancel()
}
}
} else if (step === "error") {
if (key.return) {
onCancel()
}
}
},
{ isActive: isRawModeSupported && step !== "saving" },
)
const sourceName = getSourceDisplayName(source)
if (step === "select") {
return (
<Box flexDirection="column">
<Text color="white">Select which key to import from {sourceName}</Text>
<Text> </Text>
{keys.map((k, i) => (
<Box key={`${k.provider}-${i}`}>
<Text color={i === selectedIndex ? COLORS.primaryBlue : undefined}>
{i === selectedIndex ? " " : " "}
{getProviderDisplayName(k.provider)}
</Text>
</Box>
))}
<Text> </Text>
<Text color="gray">Arrows to navigate, Enter to select, Esc to go back</Text>
</Box>
)
}
if (step === "confirm") {
const selectedKey = keys[selectedIndex]
const providerName = selectedKey ? getProviderDisplayName(selectedKey.provider) : ""
const maskedKey = selectedKey ? `${selectedKey.key.slice(0, 8)}...${selectedKey.key.slice(-4)}` : ""
return (
<Box flexDirection="column">
<Text color="white">Import API key from {sourceName}?</Text>
<Text> </Text>
<Box>
<Text color="gray">Provider: </Text>
<Text color="white">{providerName}</Text>
</Box>
<Box>
<Text color="gray">API Key: </Text>
<Text color="white">{maskedKey}</Text>
</Box>
{selectedKey?.modelId && (
<Box>
<Text color="gray">Model: </Text>
<Text color="white">{selectedKey.modelId}</Text>
</Box>
)}
<Text> </Text>
<Box>
<Text color={confirmIndex === 0 ? COLORS.primaryBlue : undefined}>
{confirmIndex === 0 ? " " : " "}
Confirm import
</Text>
</Box>
<Box>
<Text color={confirmIndex === 1 ? COLORS.primaryBlue : undefined}>
{confirmIndex === 1 ? " " : " "}
Cancel
</Text>
</Box>
<Text> </Text>
<Text color="gray">Enter to confirm, Esc to go back</Text>
</Box>
)
}
if (step === "saving") {
return (
<Box>
<Text color="white">Importing configuration...</Text>
</Box>
)
}
if (step === "error") {
return (
<Box flexDirection="column">
<Text bold color="red">
Something went wrong
</Text>
<Text> </Text>
<Text color="yellow">{errorMessage}</Text>
<Text> </Text>
<Text color="gray">Press Enter or Esc to go back</Text>
</Box>
)
}
return null
}
+46
View File
@@ -0,0 +1,46 @@
/**
* Language picker component for user preference
*/
import React, { useMemo } from "react"
import { SearchableList, SearchableListItem } from "./SearchableList"
// Available languages - English names only to avoid Unicode rendering issues
const LANGUAGES = [
"English",
"Arabic",
"Czech",
"French",
"German",
"Hindi",
"Hungarian",
"Italian",
"Japanese",
"Korean",
"Polish",
"Portuguese (Brazil)",
"Portuguese (Portugal)",
"Russian",
"Simplified Chinese",
"Spanish",
"Traditional Chinese",
"Turkish",
]
interface LanguagePickerProps {
onSelect: (language: string) => void
isActive?: boolean
}
export const LanguagePicker: React.FC<LanguagePickerProps> = ({ onSelect, isActive = true }) => {
const items: SearchableListItem[] = useMemo(
() =>
LANGUAGES.map((lang) => ({
id: lang,
label: lang,
})),
[],
)
return <SearchableList isActive={isActive} items={items} onSelect={(item) => onSelect(item.id)} />
}
-34
View File
@@ -1,34 +0,0 @@
/**
* Message list component
* Renders all messages from the task
*/
import { Box } from "ink"
import React from "react"
import { useTaskState } from "../context/TaskContext"
import { MessageRow } from "./MessageRow"
interface MessageListProps {
verbose?: boolean
}
export const MessageList: React.FC<MessageListProps> = ({ verbose = false }) => {
const state = useTaskState()
const messages = state.clineMessages || []
// Filter out some noisy messages when not verbose
const messagesToShow = verbose
? messages
: messages.filter((m) => {
// Show everything in non-verbose mode for now
return true
})
return (
<Box flexDirection="column">
{messagesToShow.map((message, idx) => (
<MessageRow key={`${message.ts}-${idx}`} message={message} verbose={verbose} />
))}
</Box>
)
}
-378
View File
@@ -1,378 +0,0 @@
/**
* Individual message row component
* Renders a single ClineMessage based on its type
*/
import type { ClineAsk, ClineMessage, ClineSay } from "@shared/ExtensionMessage"
import { Box, Text } from "ink"
import React from "react"
import { jsonParseSafe } from "../utils/parser"
import { DiffView } from "./DiffView"
interface MessageRowProps {
message: ClineMessage
verbose?: boolean
}
/**
* Get emoji icon for message type
*/
export function getCliMessagePrefixIcon(message: ClineMessage): string {
if (message.type === "ask") {
switch (message.ask) {
case "followup":
return "❓"
case "command":
case "command_output":
return "⚙️"
case "tool":
return "🔧"
case "completion_result":
return "✅"
case "api_req_failed":
return "❌"
case "resume_task":
case "resume_completed_task":
return "▶️"
case "browser_action_launch":
return "🌐"
case "use_mcp_server":
return "🔌"
case "plan_mode_respond":
return "📋"
default:
return "❔"
}
} else {
switch (message.say) {
case "task":
return "📋"
case "error":
return "❌"
case "text":
return "💬"
case "reasoning":
return "🧠"
case "completion_result":
return "✅"
case "user_feedback":
return "👤"
case "command":
case "command_output":
return "⚙️"
case "tool":
return "🔧"
case "browser_action":
case "browser_action_launch":
case "browser_action_result":
return "🌐"
case "mcp_server_request_started":
case "mcp_server_response":
return "🔌"
case "api_req_started":
case "api_req_finished":
return "🔄"
case "checkpoint_created":
return "💾"
case "info":
return "️"
case "generate_explanation":
return "📝"
default:
return " "
}
}
}
/**
* Format timestamp
*/
function formatTimestamp(ts: number): string {
const date = new Date(ts)
return date.toLocaleTimeString("en-US", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})
}
/**
* Render ask message based on type
*/
const AskMessageContent: React.FC<{ message: ClineMessage; verbose?: boolean }> = ({ message, verbose }) => {
const ask = message.ask as ClineAsk
const text = message.text || ""
switch (ask) {
case "followup":
case "plan_mode_respond": {
const parts = jsonParseSafe(text, {
response: undefined as string | undefined,
question: undefined as string | undefined,
})
if (parts.question) {
return (
<Text>
<Text color="cyan">Question:</Text> {parts.question}
</Text>
)
}
if (parts.response) {
return (
<Text>
<Text color="cyan">[{ask}]</Text> {parts.response}
</Text>
)
}
return null
}
case "command":
return (
<Text>
<Text color="magenta">Execute command?</Text> <Text dimColor>{text}</Text>
</Text>
)
case "tool":
return (
<Text>
<Text color="blue">Use tool?</Text> {text}
</Text>
)
case "completion_result":
return (
<Text>
<Text color="green">Task completed</Text> {text ? `- ${text}` : ""}
</Text>
)
case "api_req_failed":
return (
<Text>
<Text color="red">API request failed</Text> {text}
</Text>
)
case "resume_task":
case "resume_completed_task":
return (
<Text>
<Text color="cyan">Resume task?</Text> {text}
</Text>
)
case "browser_action_launch":
return (
<Text>
<Text color="cyan">Launch browser?</Text> {text}
</Text>
)
case "use_mcp_server":
return (
<Text>
<Text color="cyan">Use MCP server?</Text> {text}
</Text>
)
default:
return verbose ? (
<Text>
<Text color="gray">[ASK:{ask}]</Text> {text}
</Text>
) : null
}
}
/**
* Render say message based on type
*/
const SayMessageContent: React.FC<{ message: ClineMessage; verbose?: boolean }> = ({ message, verbose }) => {
const say = message.say as ClineSay
const text = message.text || ""
switch (say) {
case "task":
return (
<Text bold>
<Text color="white">Task:</Text> {text}
</Text>
)
case "text":
return <Text>{text}</Text>
case "reasoning":
return (
<Text color="yellow">
<Text italic>{text}</Text>
</Text>
)
case "error":
return (
<Text color="red">
<Text bold>Error:</Text> {text}
</Text>
)
case "completion_result":
return (
<Text color="green">
<Text bold>Completed:</Text> {text}
</Text>
)
case "user_feedback":
return (
<Text>
<Text color="green">User:</Text> {text}
</Text>
)
case "command":
return (
<Text>
<Text color="magenta">Command:</Text> <Text dimColor>{text}</Text>
</Text>
)
case "command_output": {
const lines = text.split("\n")
const displayLines = lines.slice(0, 10)
return (
<Box flexDirection="column">
<Text dimColor>Output:</Text>
{displayLines.map((line, idx) => (
<Text dimColor key={idx}>
{line}
</Text>
))}
{lines.length > 10 && <Text dimColor> ... and {lines.length - 10} more lines</Text>}
</Box>
)
}
case "tool": {
const { tool, content, path } = jsonParseSafe(text, {
tool: undefined as string | undefined,
content: undefined as string | undefined,
path: undefined as string | undefined,
diff: undefined as string | undefined,
})
if (path) {
if (tool === "newFileCreated") {
return <DiffView content={content} path={path} />
}
// if (tool === "editedExistingFile") {
// return <DiffView diff={diff} path={path} />
// }
}
return (
<Text>
<Text color="blue">{text}</Text>
</Text>
)
}
case "api_req_started": {
const { cost, tokensOut, cacheWrites, cacheReads, tokensIn } = jsonParseSafe(text, {
cost: 0 as number,
tokensIn: 0 as number,
tokensOut: 0 as number,
cacheWrites: 0 as number,
cacheReads: 0 as number,
})
return verbose ? (
<Text dimColor>{text}</Text>
) : (
<Text dimColor>
Cost: {cost} | Tokens In: {tokensIn} | Tokens Out: {tokensOut} | Cache Writes: {cacheWrites} | Cache Reads:{" "}
{cacheReads}
</Text>
)
}
case "api_req_finished":
return null
case "checkpoint_created":
return <Text dimColor>Checkpoint created: {message.lastCheckpointHash}</Text>
case "info":
return <Text color="cyan">{text}</Text>
case "browser_action":
case "browser_action_launch":
return (
<Text>
<Text color="cyan">Browser:</Text> {text}
</Text>
)
case "browser_action_result":
return <Text dimColor>Browser result {text ? `- ${text.substring(0, 100)}...` : ""}</Text>
case "mcp_server_request_started":
return <Text color="cyan">MCP request started {text}</Text>
case "mcp_server_response":
return <Text color="cyan">MCP response {text ? text.substring(0, 200) : ""}</Text>
default:
return verbose ? (
<Text dimColor>
[SAY:{say}] {text}
</Text>
) : null
}
}
export const MessageRow: React.FC<MessageRowProps> = ({ message, verbose = false }) => {
const icon = getCliMessagePrefixIcon(message)
const timestamp = formatTimestamp(message.ts)
// Don't render silent messages
if (message.say === "api_req_finished") {
return null
}
if (message.say === "text" && message.text?.trim() === "") {
return null
}
const content =
message.type === "ask" ? (
<AskMessageContent message={message} verbose={verbose} />
) : (
<SayMessageContent message={message} verbose={verbose} />
)
// command_output and tool return a Box, which can't be nested inside Text
if (message.say === "command_output" || message.say === "tool") {
return (
<Box flexDirection="column">
<Box>
<Text dimColor>{timestamp} </Text>
<Text>{icon} </Text>
</Box>
{content}
</Box>
)
}
return (
<Box flexDirection="column">
<Box>
<Text dimColor>{timestamp} </Text>
<Text>{icon} </Text>
{content}
</Box>
</Box>
)
}
+134
View File
@@ -0,0 +1,134 @@
/**
* Model picker component for model selection
* Supports static model lists and async loading for OpenRouter
*/
import { Box, Text } from "ink"
import Spinner from "ink-spinner"
import React, { useEffect, useMemo, useState } from "react"
import {
anthropicDefaultModelId,
anthropicModels,
bedrockDefaultModelId,
bedrockModels,
deepSeekDefaultModelId,
deepSeekModels,
geminiDefaultModelId,
geminiModels,
groqDefaultModelId,
groqModels,
mistralDefaultModelId,
mistralModels,
openAiNativeDefaultModelId,
openAiNativeModels,
xaiDefaultModelId,
xaiModels,
} from "@/shared/api"
import { COLORS } from "../constants/colors"
import { fetchOpenRouterModels, getOpenRouterDefaultModelId, usesOpenRouterModels } from "../utils/openrouter-models"
import { SearchableList, SearchableListItem } from "./SearchableList"
// Map providers to their static model lists and defaults
export const providerModels: Record<string, { models: Record<string, unknown>; defaultId: string }> = {
anthropic: { models: anthropicModels, defaultId: anthropicDefaultModelId },
"openai-native": { models: openAiNativeModels, defaultId: openAiNativeDefaultModelId },
gemini: { models: geminiModels, defaultId: geminiDefaultModelId },
bedrock: { models: bedrockModels, defaultId: bedrockDefaultModelId },
deepseek: { models: deepSeekModels, defaultId: deepSeekDefaultModelId },
mistral: { models: mistralModels, defaultId: mistralDefaultModelId },
groq: { models: groqModels, defaultId: groqDefaultModelId },
xai: { models: xaiModels, defaultId: xaiDefaultModelId },
}
export function hasStaticModels(provider: string): boolean {
return provider in providerModels
}
export function hasModelPicker(provider: string): boolean {
return hasStaticModels(provider) || usesOpenRouterModels(provider)
}
export function getDefaultModelId(provider: string): string {
if (usesOpenRouterModels(provider)) {
return getOpenRouterDefaultModelId()
}
return providerModels[provider]?.defaultId || ""
}
export function getModelList(provider: string): string[] {
if (!hasStaticModels(provider)) return []
return Object.keys(providerModels[provider].models)
}
interface ModelPickerProps {
provider: string
onChange: (modelId: string) => void
onSubmit: (modelId: string) => void
isActive?: boolean
}
export const ModelPicker: React.FC<ModelPickerProps> = ({ provider, onChange, onSubmit, isActive = true }) => {
const [isLoading, setIsLoading] = useState(false)
const [asyncModels, setAsyncModels] = useState<string[]>([])
// Fetch OpenRouter models when needed
useEffect(() => {
if (usesOpenRouterModels(provider)) {
setIsLoading(true)
fetchOpenRouterModels()
.then((models) => {
setAsyncModels(models)
})
.finally(() => {
setIsLoading(false)
})
}
}, [provider])
const modelList = useMemo(() => {
if (usesOpenRouterModels(provider)) {
return asyncModels
}
return getModelList(provider)
}, [provider, asyncModels])
const items: SearchableListItem[] = useMemo(() => {
return modelList.map((modelId) => ({
id: modelId,
label: modelId,
}))
}, [modelList])
// For providers without a model picker, render nothing
if (!hasModelPicker(provider)) {
return null
}
// Show loading state for async providers
if (isLoading) {
return (
<Box>
<Text color={COLORS.primaryBlue}>
<Spinner type="dots" />
</Text>
<Text color="gray"> Loading models...</Text>
</Box>
)
}
// If async fetch returned no models, render nothing
if (usesOpenRouterModels(provider) && modelList.length === 0) {
return null
}
return (
<SearchableList
isActive={isActive}
items={items}
onSelect={(item) => {
onChange(item.id)
onSubmit(item.id)
}}
/>
)
}
+71
View File
@@ -0,0 +1,71 @@
/**
* Reusable bottom panel component
* Used for displaying contextual UI below the chat input (settings, etc.)
*/
import { Box, Text } from "ink"
import React, { ReactNode } from "react"
import { COLORS } from "../constants/colors"
export interface PanelTab {
key: string
label: string
}
interface PanelProps {
/** Label for the panel (e.g., "Settings") */
label: string
/** Optional tabs configuration */
tabs?: PanelTab[]
/** Current tab key - required when tabs are provided */
currentTab?: string
/** Panel content */
children: ReactNode
}
export const Panel: React.FC<PanelProps> = ({ label, tabs, currentTab, children }) => {
const currentTabIndex = currentTab && tabs ? tabs.findIndex((t) => t.key === currentTab) : 0
return (
<Box borderColor={COLORS.primaryBlue} borderStyle="round" flexDirection="column" width="100%">
{/* Header */}
<Box paddingLeft={1} paddingRight={1}>
<Text bold color={COLORS.primaryBlue}>
{label}
</Text>
<Text color="gray"> (Esc to close)</Text>
</Box>
{/* Tab bar if tabs are provided */}
{tabs && tabs.length > 0 && (
<Box paddingLeft={1} paddingRight={1}>
{tabs.map((tab, idx) => {
const isActive = idx === currentTabIndex
return (
<Text
bold={isActive}
color={isActive ? COLORS.primaryBlue : "white"}
inverse={isActive}
key={tab.key}>
{` ${tab.label} `}
</Text>
)
})}
<Text color="gray"> (/)</Text>
</Box>
)}
{/* Separator line */}
<Box>
<Text bold color={COLORS.primaryBlue}>
{"─".repeat(process.stdout.columns - 2)}
</Text>
</Box>
{/* Content */}
<Box flexDirection="column" paddingLeft={1} paddingRight={1}>
{children}
</Box>
</Box>
)
}
+46
View File
@@ -0,0 +1,46 @@
/**
* Provider picker component for API provider selection
*/
import React, { useMemo } from "react"
import { API_PROVIDERS_LIST } from "@/shared/api"
import providersData from "@/shared/providers/providers.json"
import { SearchableList, SearchableListItem } from "./SearchableList"
// Create a lookup map from provider value to display label
const providerLabels: Record<string, string> = Object.fromEntries(
providersData.list.map((p: { value: string; label: string }) => [p.value, p.label]),
)
// Get provider order from providers.json (same order as webview)
const providerOrder: string[] = providersData.list.map((p: { value: string }) => p.value)
export function getProviderLabel(providerId: string): string {
return providerLabels[providerId] || providerId
}
export function getProviderOrder(): string[] {
return providerOrder
}
interface ProviderPickerProps {
onSelect: (providerId: string) => void
isActive?: boolean
configuredProviders?: Set<string>
}
export const ProviderPicker: React.FC<ProviderPickerProps> = ({ onSelect, isActive = true, configuredProviders = new Set() }) => {
// Use providers.json order, filtered to only available providers
const items: SearchableListItem[] = useMemo(() => {
const availableProviders = new Set(API_PROVIDERS_LIST)
const sorted = providerOrder.filter((p) => availableProviders.has(p))
return sorted.map((providerId) => ({
id: providerId,
label: getProviderLabel(providerId),
suffix: configuredProviders.has(providerId) ? "(configured)" : undefined,
}))
}, [configuredProviders])
return <SearchableList isActive={isActive} items={items} onSelect={(item) => onSelect(item.id)} />
}
+120
View File
@@ -0,0 +1,120 @@
/**
* Generic searchable list component with keyboard navigation
* Used by ProviderPicker, ModelPicker, LanguagePicker, etc.
*/
import { Box, Text, useInput } from "ink"
// biome-ignore lint/correctness/noUnusedImports: React is needed for JSX at runtime
import React, { useEffect, useMemo, useState } from "react"
import { COLORS } from "../constants/colors"
import { useStdinContext } from "../context/StdinContext"
import { useScrollableList } from "../hooks/useScrollableList"
import { isMouseEscapeSequence } from "../utils/input"
export interface SearchableListItem {
id: string
label: string
suffix?: string // Optional suffix like "(configured)" or "(current)"
}
interface SearchableListProps<T extends SearchableListItem> {
items: T[]
onSelect: (item: T) => void
isActive?: boolean
maxRows?: number
filterFn?: (item: T, search: string) => boolean
}
const DEFAULT_MAX_ROWS = 8
export function SearchableList<T extends SearchableListItem>({
items,
onSelect,
isActive = true,
maxRows = DEFAULT_MAX_ROWS,
filterFn,
}: SearchableListProps<T>) {
const { isRawModeSupported } = useStdinContext()
const [search, setSearch] = useState("")
const [index, setIndex] = useState(0)
// Default filter: search in id and label
const defaultFilter = (item: T, searchStr: string) => {
const searchLower = searchStr.toLowerCase()
return item.id.toLowerCase().includes(searchLower) || item.label.toLowerCase().includes(searchLower)
}
// Filter items by search
const filteredItems = useMemo(() => {
if (!search) return items
const filter = filterFn || defaultFilter
return items.filter((item) => filter(item, search))
}, [items, search, filterFn])
// Use shared scrollable list hook for windowing
const { visibleStart, visibleCount, showTopIndicator, showBottomIndicator } = useScrollableList(
filteredItems.length,
index,
maxRows,
)
const visibleItems = useMemo(() => {
return filteredItems.slice(visibleStart, visibleStart + visibleCount)
}, [filteredItems, visibleStart, visibleCount])
// Reset index when search changes
useEffect(() => {
setIndex(0)
}, [search])
useInput(
(input, key) => {
// Filter out mouse escape sequences
if (isMouseEscapeSequence(input)) {
return
}
if (key.upArrow) {
setIndex((prev) => Math.max(0, prev - 1))
} else if (key.downArrow) {
setIndex((prev) => Math.min(filteredItems.length - 1, prev + 1))
} else if (key.return) {
if (filteredItems[index]) {
onSelect(filteredItems[index])
}
} else if (key.backspace || key.delete) {
setSearch((prev) => prev.slice(0, -1))
} else if (input && !key.ctrl && !key.meta && !key.escape) {
setSearch((prev) => prev + input)
}
},
{ isActive: isRawModeSupported && isActive },
)
return (
<Box flexDirection="column">
<Box>
<Text color="gray">Search: </Text>
<Text color="white">{search}</Text>
<Text inverse> </Text>
</Box>
<Text> </Text>
{showTopIndicator && <Text color="gray">... {visibleStart} more above</Text>}
{visibleItems.map((item, i) => {
const actualIndex = visibleStart + i
const isSelected = actualIndex === index
return (
<Box key={item.id}>
<Text color={isSelected ? COLORS.primaryBlue : undefined}>
{isSelected ? "> " : " "}
{item.label}
{item.suffix && <Text color="gray"> {item.suffix}</Text>}
</Text>
</Box>
)
})}
{showBottomIndicator && <Text color="gray">... {filteredItems.length - visibleStart - visibleCount} more below</Text>}
{filteredItems.length === 0 && <Text color="gray">No matches for "{search}"</Text>}
</Box>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,55 @@
/**
* Slash command menu component for CLI
* Displays a list of matching slash commands when user types /
*/
import type { SlashCommandInfo } from "@shared/proto/cline/slash"
import { Box, Text } from "ink"
import React from "react"
import { COLORS } from "../constants/colors"
import { getVisibleWindow } from "../utils/slash-commands"
interface SlashCommandMenuProps {
commands: SlashCommandInfo[]
selectedIndex: number
query: string
}
export const SlashCommandMenu: React.FC<SlashCommandMenuProps> = ({ commands, selectedIndex, query }) => {
if (commands.length === 0) {
return (
<Box flexDirection="column" marginBottom={1} paddingLeft={1} paddingRight={1}>
<Text color="gray">{query ? `No commands matching "/${query}"` : "Type to search commands..."}</Text>
</Box>
)
}
const { items: visibleCommands, startIndex } = getVisibleWindow(commands, selectedIndex)
const hasMoreBelow = startIndex + visibleCommands.length < commands.length
return (
<Box flexDirection="column" marginBottom={1} paddingLeft={1} paddingRight={1}>
{visibleCommands.map((cmd, idx) => {
const isSelected = startIndex + idx === selectedIndex
// Only show description for default commands (not workflows)
const showDescription = cmd.section === "default" || !cmd.section
return (
<Box flexDirection="column" key={cmd.name}>
<Box>
<Text color={isSelected ? COLORS.primaryBlue : undefined}>
{isSelected ? "" : " "} /{cmd.name}
</Text>
</Box>
{showDescription && cmd.description && (
<Box paddingLeft={3}>
<Text color="gray">{cmd.description}</Text>
</Box>
)}
</Box>
)
})}
{hasMoreBelow && <Text color="gray">{" "}</Text>}
</Box>
)
}
+3 -6
View File
@@ -7,14 +7,11 @@ import Spinner from "ink-spinner"
import React from "react"
interface LoadingSpinnerProps {
message?: string
mode?: "act" | "plan"
}
const LOADING_TEXT_IDEAS = ["Thinking", "Loading", "Processing", "Working", "Calculating", "Analyzing", "Exploring"]
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
message = LOADING_TEXT_IDEAS[Math.floor(Math.random() * LOADING_TEXT_IDEAS.length)],
}) => {
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({ mode = "act" }) => {
const message = mode === "plan" ? "Planning" : "Thinking"
return (
<Box>
<Text color="cyan">
+107
View File
@@ -0,0 +1,107 @@
/**
* Status bar component
* Shows git branch, model, context window usage, token count, and cost
*/
import { execSync } from "child_process"
import { Box, Text } from "ink"
import React, { useEffect, useState } from "react"
interface StatusBarProps {
modelId: string
tokensIn?: number
tokensOut?: number
totalCost?: number
contextWindowSize?: number
cwd?: string
}
/**
* Get current git branch name
*/
function getGitBranch(cwd?: string): string | null {
try {
const branch = execSync("git rev-parse --abbrev-ref HEAD", {
cwd: cwd || process.cwd(),
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
}).trim()
return branch
} catch {
return null
}
}
/**
* Get directory basename
*/
function getDirName(cwd?: string): string {
const path = cwd || process.cwd()
return path.split("/").pop() || path
}
/**
* Format number with commas
*/
function formatNumber(num: number): string {
return num.toLocaleString()
}
/**
* Create a progress bar for context window usage
*/
function createContextBar(used: number, total: number, width: number = 8): string {
const ratio = Math.min(used / total, 1)
const filled = Math.round(ratio * width)
const empty = width - filled
return "█".repeat(filled) + "░".repeat(empty)
}
export const StatusBar: React.FC<StatusBarProps> = ({
modelId,
tokensIn = 0,
tokensOut = 0,
totalCost = 0,
contextWindowSize = 200000, // Default Claude context window
cwd,
}) => {
const [branch, setBranch] = useState<string | null>(null)
const dirName = getDirName(cwd)
useEffect(() => {
setBranch(getGitBranch(cwd))
}, [cwd])
const totalTokens = tokensIn + tokensOut
const contextBar = createContextBar(totalTokens, contextWindowSize)
// Format model ID for display (shorten if needed)
const displayModel = modelId.length > 20 ? modelId.substring(0, 17) + "..." : modelId
return (
<Box flexDirection="column">
<Box gap={1}>
{/* Directory and branch */}
<Text color="gray">
{dirName}
{branch && (
<Text color="gray">
{" "}
(<Text color="cyan">{branch}</Text>)
</Text>
)}
</Text>
<Text color="gray">|</Text>
{/* Model and context bar */}
<Text color="white">{displayModel}</Text>
<Text color="blue">{contextBar}</Text>
<Text color="gray">({formatNumber(totalTokens)})</Text>
<Text color="gray">|</Text>
{/* Cost */}
<Text color="green">${totalCost.toFixed(4)}</Text>
</Box>
</Box>
)
}
+125
View File
@@ -0,0 +1,125 @@
/**
* JSON Task view component
* Outputs task messages as JSON instead of rich styled text
*/
import { Box } from "ink"
import React, { useEffect, useRef } from "react"
import { useTaskContext, useTaskState } from "../context/TaskContext"
import { useCompletionSignals } from "../hooks/useStateSubscriber"
import { originalConsoleLog } from "../utils/console"
interface TaskJsonViewProps {
taskId?: string
verbose?: boolean
onComplete?: () => void
onError?: () => void
}
/**
* Output a JSON line to stdout
*/
function outputJson(data: object) {
originalConsoleLog(JSON.stringify(data))
}
export const TaskJsonView: React.FC<TaskJsonViewProps> = ({ taskId: _taskId, verbose = false, onComplete, onError }) => {
const state = useTaskState()
const { isTaskComplete, getCompletionMessage } = useCompletionSignals()
const { setIsComplete } = useTaskContext()
// Track outputted messages by timestamp (don't re-output on updates)
const outputtedMessages = useRef<Set<number>>(new Set())
const hasOutputtedCompletion = useRef(false)
// Determine the role for a message
const getRole = (message: { type: string; ask?: string; say?: string }, index: number): "user" | "assistant" | "system" => {
// User feedback messages
if (message.say === "user_feedback" || message.say === "user_feedback_diff") {
return "user"
}
// First text message is the user's task
if (message.say === "text" && index === 0) {
return "user"
}
// System messages
if (message.say === "api_req_started" || message.say === "api_req_finished") {
return "system"
}
// Default: assistant
return "assistant"
}
// Output messages as JSON when they arrive
useEffect(() => {
const messages = state.clineMessages || []
for (let i = 0; i < messages.length; i++) {
const message = messages[i]
// Skip partial messages - wait for complete message
if (message.partial) {
continue
}
// Skip if we already outputted this timestamp
if (outputtedMessages.current.has(message.ts)) {
continue
}
// Filter out noisy messages in non-verbose mode
if (!verbose) {
if (message.say === "api_req_started" || message.say === "api_req_finished") {
outputtedMessages.current.add(message.ts)
continue
}
}
const role = getRole(message, i)
// Output the message as JSON
outputJson({
type: "message",
timestamp: message.ts,
role,
messageType: message.type,
...(message.ask && { ask: message.ask }),
...(message.say && { say: message.say }),
...(message.text && { text: message.text }),
...(message.reasoning && { reasoning: message.reasoning }),
...(message.images && message.images.length > 0 && { images: message.images }),
...(message.files && message.files.length > 0 && { files: message.files }),
})
outputtedMessages.current.add(message.ts)
}
}, [state.clineMessages, verbose])
// Handle task completion
useEffect(() => {
if (isTaskComplete() && !hasOutputtedCompletion.current) {
hasOutputtedCompletion.current = true
setIsComplete(true)
const completionMsg = getCompletionMessage()
const isError = completionMsg?.say === "error" || completionMsg?.ask === "api_req_failed"
// Output completion status
outputJson({
type: "completion",
status: isError ? "error" : "success",
timestamp: Date.now(),
})
if (isError) {
onError?.()
} else {
onComplete?.()
}
// Don't exit automatically - let the parent handle cleanup
}
}, [isTaskComplete, setIsComplete, onComplete, onError, getCompletionMessage])
// Render nothing visible - all output goes to stdout as JSON
return <Box />
}
-184
View File
@@ -1,184 +0,0 @@
/**
* Task view component
* Main view for running a task - displays messages and handles user input
*/
import { exit } from "node:process"
import { CheckpointRestoreRequest } from "@shared/proto/cline/checkpoints"
import { Box, Text, useInput } from "ink"
import React, { useCallback, useEffect, useMemo, useState } from "react"
import { checkpointRestore } from "@/core/controller/checkpoints/checkpointRestore"
import { StateManager } from "@/core/storage/StateManager"
import { useTaskContext, useTaskState } from "../context/TaskContext"
import { useCompletionSignals, useIsSpinnerActive } from "../hooks/useStateSubscriber"
import { AskPrompt } from "./AskPrompt"
import { CheckpointMenu, RestoreType } from "./CheckpointMenu"
import { FocusChain } from "./FocusChain"
import { MessageList } from "./MessageList"
import { LoadingSpinner } from "./Spinner"
interface TaskViewProps {
taskId?: string
verbose?: boolean
onComplete?: () => void
onError?: () => void
}
/**
* Format separator line
*/
function formatSeparator(char: string = "═", width: number = 60): string {
return char.repeat(Math.max(width, 10))
}
export const TaskView: React.FC<TaskViewProps> = ({ taskId: _taskId, verbose = false, onComplete, onError }) => {
const state = useTaskState()
const { isTaskComplete, getCompletionMessage } = useCompletionSignals()
const isSpinnerActive = useIsSpinnerActive()
const { setIsComplete, lastError, controller } = useTaskContext()
const [showCheckpointMenu, setShowCheckpointMenu] = useState(false)
const [restoreStatus, setRestoreStatus] = useState<"idle" | "restoring" | "success" | "error">("idle")
const [restoreMessage, setRestoreMessage] = useState<string | null>(null)
const yolo = useMemo(() => StateManager.get().getGlobalSettingsKey("yoloModeToggled"), [])
// Handle task completion
useEffect(() => {
if (isTaskComplete()) {
setIsComplete(true)
// Check if it's an error
const completionMsg = getCompletionMessage()
if (completionMsg?.say === "error" || completionMsg?.ask === "api_req_failed") {
onError?.()
} else {
onComplete?.()
}
if (yolo) {
exit()
}
}
}, [isTaskComplete, setIsComplete, onComplete, onError, getCompletionMessage])
// Handle checkpoint restore
const handleCheckpointRestore = useCallback(
async (messageTs: number, restoreType: RestoreType) => {
setShowCheckpointMenu(false)
setRestoreStatus("restoring")
setRestoreMessage(`Restoring checkpoint (${restoreType})...`)
try {
await checkpointRestore(
controller,
CheckpointRestoreRequest.create({
number: messageTs,
restoreType: restoreType,
}),
)
setRestoreStatus("success")
setRestoreMessage("Checkpoint restored successfully")
// Clear success message after a delay
setTimeout(() => {
setRestoreStatus("idle")
setRestoreMessage(null)
}, 3000)
} catch (error) {
setRestoreStatus("error")
setRestoreMessage(`Failed to restore: ${error instanceof Error ? error.message : String(error)}`)
// Clear error message after a delay
setTimeout(() => {
setRestoreStatus("idle")
setRestoreMessage(null)
}, 5000)
}
},
[controller],
)
// Handle Ctrl+R to open checkpoint menu
useInput(
(input, key) => {
// Ctrl+R to open checkpoint menu
if (key.ctrl && input === "r") {
setShowCheckpointMenu(true)
return
}
},
{ isActive: !showCheckpointMenu },
)
return (
<Box flexDirection="column">
{/* Task header */}
{state.currentTaskItem && (
<Box flexDirection="column" marginBottom={1}>
<Text>{formatSeparator("═")}</Text>
<Text bold color="white">
📋 Task: {state.currentTaskItem.id}
</Text>
{state.currentTaskItem.task && (
<Text dimColor>
{state.currentTaskItem.task.substring(0, 80)}
{state.currentTaskItem.task.length > 80 ? "..." : ""}
</Text>
)}
<Box>
<Text>{formatSeparator("═")}</Text>
</Box>
<Text color="gray" dimColor>
(Ctrl+R to restore checkpoint)
</Text>
</Box>
)}
{/* Error message if any */}
{lastError && (
<Box flexDirection="column" marginBottom={1}>
<Text bold color="red">
Error: {lastError}
</Text>
</Box>
)}
{/* Restore status message */}
{restoreMessage && (
<Box flexDirection="column" marginBottom={1}>
<Text bold color={restoreStatus === "error" ? "red" : restoreStatus === "success" ? "green" : "yellow"}>
{restoreStatus === "restoring" ? "⏳ " : restoreStatus === "success" ? "✓ " : "✗ "}
{restoreMessage}
</Text>
</Box>
)}
{/* Checkpoint menu */}
{showCheckpointMenu && (
<CheckpointMenu
messages={state.clineMessages || []}
onCancel={() => setShowCheckpointMenu(false)}
onSelect={handleCheckpointRestore}
/>
)}
{/* Focus Chain / To-Do List */}
{state.currentFocusChainChecklist && (
<Box marginBottom={1}>
<FocusChain focusChainChecklist={state.currentFocusChainChecklist} />
</Box>
)}
{/* Messages list */}
<MessageList verbose={verbose} />
{/* Loading spinner */}
{isSpinnerActive && (
<Box marginTop={1}>
<LoadingSpinner />
</Box>
)}
{/* User input prompt */}
{!yolo && <AskPrompt />}
</Box>
)
}
+126
View File
@@ -0,0 +1,126 @@
/**
* Acting/Planning indicator with spinner, shimmer effect, and elapsed time
*/
import { Box, Text, useInput } from "ink"
import React, { useEffect, useMemo, useState } from "react"
import { COLORS } from "../constants/colors"
interface ThinkingIndicatorProps {
mode?: "act" | "plan"
startTime?: number // Unix timestamp when thinking started
onCancel?: () => void // Called when user presses esc to interrupt
}
// Spinner frames (dots style from ink-spinner)
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
const SHIMMER_WIDTH = 3 // How many characters are "bright" at once
/**
* Format elapsed time as "1m 5s" or "45s"
*/
function formatElapsedTime(ms: number): string {
const totalSeconds = Math.floor(ms / 1000)
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
if (minutes > 0) {
return `${minutes}m ${seconds}s`
}
return `${seconds}s`
}
/**
* Render text with a shimmer effect using bold for bright characters
*/
const ShimmerText: React.FC<{ text: string; color: string; shimmerPos: number }> = ({ text, color, shimmerPos }) => {
const chars = text.split("")
return (
<Text>
{chars.map((char, i) => {
const distFromShimmer = Math.abs(i - shimmerPos)
const isBright = distFromShimmer <= SHIMMER_WIDTH
return (
<Text bold={isBright} color={color} dimColor={!isBright} key={i}>
{char}
</Text>
)
})}
</Text>
)
}
export const ThinkingIndicator: React.FC<ThinkingIndicatorProps> = ({ mode = "act", startTime, onCancel }) => {
const message = mode === "plan" ? "Planning" : "Acting"
const color = mode === "plan" ? "yellow" : COLORS.primaryBlue
// Handle esc key to cancel
useInput((_input, key) => {
if (key.escape && onCancel) {
onCancel()
}
})
// Spinner frame index
const [spinnerFrame, setSpinnerFrame] = useState(0)
// Shimmer position
const [shimmerPos, setShimmerPos] = useState(-SHIMMER_WIDTH)
// Elapsed time state
const [elapsedMs, setElapsedMs] = useState(0)
const spinnerChar = SPINNER_FRAMES[spinnerFrame]
const fullText = `${spinnerChar} ${message}...`
// Animate spinner
useEffect(() => {
const interval = setInterval(() => {
setSpinnerFrame((prev) => (prev + 1) % SPINNER_FRAMES.length)
}, 80)
return () => clearInterval(interval)
}, [])
// Animate shimmer
useEffect(() => {
const interval = setInterval(() => {
setShimmerPos((prev) => {
const next = prev + 1
if (next > fullText.length + SHIMMER_WIDTH) {
return -SHIMMER_WIDTH
}
return next
})
}, 100)
return () => clearInterval(interval)
}, [fullText.length])
// Update elapsed time
useEffect(() => {
if (!startTime) return
const updateElapsed = () => {
setElapsedMs(Date.now() - startTime)
}
updateElapsed() // Initial update
const interval = setInterval(updateElapsed, 1000)
return () => clearInterval(interval)
}, [startTime])
const elapsedStr = useMemo(() => {
if (!startTime) return null
return formatElapsedTime(elapsedMs)
}, [startTime, elapsedMs])
return (
<Box paddingLeft={1}>
<ShimmerText color={color} shimmerPos={shimmerPos} text={fullText} />
{elapsedStr && <Text color="gray"> ({elapsedStr} · esc to interrupt)</Text>}
</Box>
)
}
+75 -62
View File
@@ -4,10 +4,11 @@
* Supports file mentions with @
*/
import type { Mode } from "@shared/storage/types"
import { Box, Text, useInput } from "ink"
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { StateManager } from "@/core/storage/StateManager"
import { getProviderDefaultModelId, Mode } from "@/shared/storage"
import { useStdinContext } from "../context/StdinContext"
import {
checkAndWarnRipgrepMissing,
extractMentionQuery,
@@ -16,6 +17,7 @@ import {
insertMention,
searchWorkspaceFiles,
} from "../utils/file-search"
import { isMouseEscapeSequence } from "../utils/input"
import { parseImagesFromInput } from "../utils/parser"
import { AccountInfoView } from "./AccountInfoView"
import { FileMentionMenu } from "./FileMentionMenu"
@@ -51,6 +53,7 @@ const RIPGREP_WARNING_DURATION_MS = 5000
const MAX_SEARCH_RESULTS = 15
export const WelcomeView: React.FC<WelcomeViewProps> = ({ onSubmit, onExit, controller }) => {
const { isRawModeSupported } = useStdinContext()
const [textInput, setTextInput] = useState("")
const [fileResults, setFileResults] = useState<FileSearchResult[]>([])
const [selectedIndex, setSelectedIndex] = useState(0)
@@ -62,12 +65,20 @@ export const WelcomeView: React.FC<WelcomeViewProps> = ({ onSubmit, onExit, cont
return stateManager.getGlobalSettingsKey("mode") || "act"
})
const provider = useMemo(() => {
const stateManager = StateManager.get()
const mode = stateManager.getGlobalSettingsKey("mode") as string
const providerKey = mode === "act" ? "actModeApiProvider" : "planModeApiProvider"
const currentProvider = stateManager.getGlobalSettingsKey(providerKey) as string
return currentProvider || "cline"
}, [controller])
// Get model ID based on current mode
const modelId = useMemo(() => {
const stateManager = StateManager.get()
const modelKey = mode === "act" ? "actModeApiModelId" : "planModeApiModelId"
return (stateManager.getGlobalSettingsKey(modelKey) as string) || "claude-sonnet-4-20250514"
}, [mode])
return (stateManager.getGlobalSettingsKey(modelKey) as string) || getProviderDefaultModelId(provider)
}, [mode, provider])
const toggleMode = useCallback(() => {
const newMode: Mode = mode === "act" ? "plan" : "act"
@@ -151,64 +162,72 @@ export const WelcomeView: React.FC<WelcomeViewProps> = ({ onSubmit, onExit, cont
}
}, [mentionInfo.inMentionMode, mentionInfo.query, workspacePath])
useInput((input, key) => {
const inMenu = mentionInfo.inMentionMode && fileResults.length > 0
useInput(
(input, key) => {
// Filter out mouse escape sequences
if (isMouseEscapeSequence(input)) {
return
}
// Menu navigation
if (inMenu) {
if (key.upArrow) {
setSelectedIndex((i) => (i > 0 ? i - 1 : fileResults.length - 1))
return
}
if (key.downArrow) {
setSelectedIndex((i) => (i < fileResults.length - 1 ? i + 1 : 0))
return
}
if (key.tab || key.return) {
const file = fileResults[selectedIndex]
if (file) {
setTextInput(insertMention(textInput, mentionInfo.atIndex, file.path))
const inMenu = mentionInfo.inMentionMode && fileResults.length > 0
// Menu navigation
if (inMenu) {
if (key.upArrow) {
setSelectedIndex((i) => (i > 0 ? i - 1 : fileResults.length - 1))
return
}
if (key.downArrow) {
setSelectedIndex((i) => (i < fileResults.length - 1 ? i + 1 : 0))
return
}
if (key.tab || key.return) {
const file = fileResults[selectedIndex]
if (file) {
setTextInput(insertMention(textInput, mentionInfo.atIndex, file.path))
setFileResults([])
setSelectedIndex(0)
}
return
}
if (key.escape) {
setFileResults([])
setSelectedIndex(0)
return
}
}
// Normal input handling
if (key.tab && !mentionInfo.inMentionMode) {
toggleMode()
return
}
if (key.return && !mentionInfo.inMentionMode) {
if (prompt.trim() || imagePaths.length > 0) {
onSubmit(prompt.trim(), imagePaths)
}
return
}
if (key.escape) {
setFileResults([])
setSelectedIndex(0)
if (key.escape && !mentionInfo.inMentionMode) {
if (escPressedOnce) {
onExit?.()
} else {
setEscPressedOnce(true)
}
return
}
}
// Normal input handling
if (key.tab && !mentionInfo.inMentionMode) {
toggleMode()
return
}
if (key.return && !mentionInfo.inMentionMode) {
if (prompt.trim() || imagePaths.length > 0) {
onSubmit(prompt.trim(), imagePaths)
if (key.backspace || key.delete) {
setTextInput((prev) => prev.slice(0, -1))
setEscPressedOnce(false)
return
}
return
}
if (key.escape && !mentionInfo.inMentionMode) {
if (escPressedOnce) {
onExit?.()
} else {
setEscPressedOnce(true)
if (input && !key.ctrl && !key.meta && !key.upArrow && !key.downArrow && !key.tab) {
setTextInput((prev) => prev + input)
setEscPressedOnce(false)
}
return
}
if (key.backspace || key.delete) {
setTextInput((prev) => prev.slice(0, -1))
setEscPressedOnce(false)
return
}
if (input && !key.ctrl && !key.meta && !key.upArrow && !key.downArrow && !key.tab) {
setTextInput((prev) => prev + input)
setEscPressedOnce(false)
}
})
},
{ isActive: isRawModeSupported },
)
const borderColor = mode === "act" ? "blue" : "yellow"
@@ -223,8 +242,8 @@ export const WelcomeView: React.FC<WelcomeViewProps> = ({ onSubmit, onExit, cont
{/* Cline logo - centered */}
<Box alignItems="center" flexDirection="column">
{/* biome-ignore lint/suspicious/noArrayIndexKey: static array that never changes */}
{CLINE_LOGO.map((line, idx) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static array that never changes
<Text color="white" key={idx}>
{line}
</Text>
@@ -256,15 +275,13 @@ export const WelcomeView: React.FC<WelcomeViewProps> = ({ onSubmit, onExit, cont
paddingRight={1}
width="100%">
<Text>{textInput}</Text>
<Text color="gray"></Text>
<Text inverse> </Text>
</Box>
{/* Model ID and Mode toggle row */}
<Box justifyContent="space-between" width="100%">
{/* Model ID on left */}
<Text color="gray" dimColor>
{modelId}
</Text>
<Text color="gray">{modelId}</Text>
{/* Mode toggle on right */}
<Box gap={1}>
@@ -278,9 +295,7 @@ export const WelcomeView: React.FC<WelcomeViewProps> = ({ onSubmit, onExit, cont
{mode === "act" ? "●" : "○"} Act
</Text>
</Box>
<Text color="gray" dimColor>
(Tab)
</Text>
<Text color="gray">(Tab)</Text>
</Box>
</Box>
@@ -303,9 +318,7 @@ export const WelcomeView: React.FC<WelcomeViewProps> = ({ onSubmit, onExit, cont
{/* Help text */}
<Box>
<Text color="gray" dimColor>
Enter to submit · @ to mention files ·{" "}
</Text>
<Text color="gray">Enter to submit · @ to mention files · </Text>
<Text bold={escPressedOnce} color={escPressedOnce ? "white" : "gray"} dimColor={!escPressedOnce}>
{escPressedOnce ? "Press Esc again to exit" : "Esc to exit"}
</Text>
+19
View File
@@ -0,0 +1,19 @@
/**
* Color constants for the CLI
* Using hex values for consistent rendering across terminals
*/
export const COLORS = {
// Primary brand color - light purple-blue
primaryBlue: "#B1B9F9",
// Plan mode color
planYellow: "yellow",
} as const
/**
* Get the appropriate color for the current mode
*/
export function getModeColor(mode: "act" | "plan"): string {
return mode === "plan" ? COLORS.planYellow : COLORS.primaryBlue
}
+52
View File
@@ -0,0 +1,52 @@
/**
* Featured models shown in the Cline model picker during onboarding
* These are curated models that work well with Cline
*/
export interface FeaturedModel {
id: string
name: string
description: string
label: string
}
export const FEATURED_MODELS = {
recommended: [
{
id: "anthropic/claude-opus-4.5",
name: "Claude Opus 4.5",
description: "State-of-the-art for complex coding",
label: "Best",
},
{
id: "openai/gpt-5.2-codex",
name: "GPT 5.2 Codex",
description: "OpenAI's latest with strong coding abilities",
label: "New",
},
{
id: "google/gemini-3-pro-preview",
name: "Gemini 3 Pro",
description: "1M context window for large codebases",
label: "Trending",
},
] as FeaturedModel[],
free: [
{
id: "kwaipilot/kat-coder-pro",
name: "KAT Coder Pro",
description: "Advanced agentic coding model",
label: "FREE",
},
{
id: "mistralai/devstral-2512:free",
name: "Devstral",
description: "Mistral's coding model",
label: "FREE",
},
] as FeaturedModel[],
}
export function getAllFeaturedModels(): FeaturedModel[] {
return [...FEATURED_MODELS.recommended, ...FEATURED_MODELS.free]
}
+36
View File
@@ -0,0 +1,36 @@
/**
* Context for tracking stdin raw mode support
* Used to conditionally disable input handling when stdin doesn't support raw mode
* (e.g., when input is piped: echo "..." | clinedev)
*/
import React, { createContext, type ReactNode, useContext } from "react"
interface StdinContextValue {
/**
* Whether stdin supports raw mode (keyboard input handling)
* Will be false when input is piped or stdin is not a TTY
*/
isRawModeSupported: boolean
}
const StdinContext = createContext<StdinContextValue>({ isRawModeSupported: true })
export const useStdinContext = () => useContext(StdinContext)
interface StdinProviderProps {
children: ReactNode
isRawModeSupported: boolean
}
export const StdinProvider: React.FC<StdinProviderProps> = ({ children, isRawModeSupported }) => {
return <StdinContext.Provider value={{ isRawModeSupported }}>{children}</StdinContext.Provider>
}
/**
* Check if stdin supports raw mode
* Returns false when input is piped or stdin is not a TTY
*/
export function checkRawModeSupport(): boolean {
return Boolean(process.stdin.isTTY && typeof process.stdin.setRawMode === "function")
}
+7
View File
@@ -46,6 +46,13 @@ export const TaskContextProvider: React.FC<TaskContextProviderProps> = ({ contro
const handleStateUpdate = async () => {
try {
const newState = await controller.getStateToPostToWebview()
// Ignore transient empty messages state during cancel/reinit
// When clearTask() runs, messages briefly become [] before new task loads them
const hadMessages = (stateRef.current.clineMessages?.length ?? 0) > 0
const hasMessages = (newState.clineMessages?.length ?? 0) > 0
if (hadMessages && !hasMessages) {
return
}
setState(newState)
} catch (error) {
setLastError(error instanceof Error ? error.message : String(error))
+24 -4
View File
@@ -11,6 +11,8 @@ import type {
} from "@generated/hosts/host-bridge-client-types"
import type { HostBridgeClientProvider, StreamingCallbacks } from "@hosts/host-provider-types"
import * as proto from "@shared/proto/index"
import { ClineClient } from "@/shared/cline"
import { version as CLI_VERSION } from "../../package.json"
import { printError, printInfo, printWarning } from "../utils/display"
/**
@@ -78,6 +80,8 @@ export class CliDiffServiceClient implements DiffServiceClientInterface {
export class CliEnvServiceClient implements EnvServiceClientInterface {
private clipboardContent: string = ""
private telemetrySetting = proto.host.Setting.ENABLED
async clipboardWriteText(request: proto.cline.StringRequest): Promise<proto.cline.Empty> {
this.clipboardContent = request.value || ""
printInfo(`📋 Copied to clipboard`)
@@ -90,8 +94,9 @@ export class CliEnvServiceClient implements EnvServiceClientInterface {
async getHostVersion(_request: proto.cline.EmptyRequest): Promise<proto.host.GetHostVersionResponse> {
return proto.host.GetHostVersionResponse.create({
version: "1.0.0",
platform: "Cline CLI",
version: CLI_VERSION,
platform: "Cline CLI - Node.js",
clineType: ClineClient.Cli,
})
}
@@ -102,7 +107,7 @@ export class CliEnvServiceClient implements EnvServiceClientInterface {
async getTelemetrySettings(_request: proto.cline.EmptyRequest): Promise<proto.host.GetTelemetrySettingsResponse> {
return proto.host.GetTelemetrySettingsResponse.create({
isEnabled: proto.host.Setting.DISABLED,
isEnabled: this.telemetrySetting,
})
}
@@ -113,13 +118,21 @@ export class CliEnvServiceClient implements EnvServiceClientInterface {
// Send initial settings
callbacks.onResponse(
proto.host.TelemetrySettingsEvent.create({
isEnabled: proto.host.Setting.DISABLED,
isEnabled: this.telemetrySetting,
}),
)
// Return unsubscribe function
return () => {}
}
debugLog(request: proto.cline.StringRequest): Promise<proto.cline.Empty> {
const message = request.value || ""
if (process.env.IS_DEV) {
printInfo(`[DebugLog] ${message}`)
}
return Promise.resolve(proto.cline.Empty.create())
}
async shutdown(_request: proto.cline.EmptyRequest): Promise<proto.cline.Empty> {
printInfo("Shutting down...")
return proto.cline.Empty.create()
@@ -258,6 +271,13 @@ export class CliWorkspaceServiceClient implements WorkspaceServiceClientInterfac
printInfo(`⚙️ Executing: ${request.command}`)
return proto.host.ExecuteCommandInTerminalResponse.create({})
}
async openFolder(request: proto.host.OpenFolderRequest): Promise<proto.host.OpenFolderResponse> {
const path = request.path || ""
this.workspacePath = path
printInfo(`📂 Opening folder: ${path}`)
return proto.host.OpenFolderResponse.create({ success: true })
}
}
/**
+65
View File
@@ -0,0 +1,65 @@
/**
* Shared hook for scrollable list windowing in terminal UIs
* Used by AuthView (provider list) and ModelPicker (model list)
*/
import { useMemo } from "react"
interface ScrollableListResult {
visibleStart: number
visibleCount: number
showTopIndicator: boolean
showBottomIndicator: boolean
}
/**
* Calculate visible window for a scrollable list
* Keeps the selected item in view while showing scroll indicators
*
* @param itemCount - Total number of items in the list
* @param selectedIndex - Currently selected item index
* @param maxRows - Maximum rows to display (indicators take up row space when shown)
*/
export function useScrollableList(itemCount: number, selectedIndex: number, maxRows: number): ScrollableListResult {
return useMemo(() => {
if (itemCount <= maxRows) {
return {
visibleStart: 0,
visibleCount: itemCount,
showTopIndicator: false,
showBottomIndicator: false,
}
}
// Determine if we need indicators based on index position
const needsTopIndicator = selectedIndex > 0
const needsBottomIndicator = selectedIndex < itemCount - 1
// Calculate how many items we can show (subtract space for indicators)
let itemSlots = maxRows
if (needsTopIndicator && selectedIndex >= maxRows - 1) itemSlots--
if (needsBottomIndicator && selectedIndex <= itemCount - maxRows) itemSlots--
// Calculate start position keeping selected item in view
const maxStart = itemCount - itemSlots
const idealStart = selectedIndex - Math.floor(itemSlots / 2)
const start = Math.max(0, Math.min(idealStart, maxStart))
const showTop = start > 0
const showBottom = start + itemSlots < itemCount
// Recalculate item slots based on actual indicators shown
let finalItemSlots = maxRows
if (showTop) finalItemSlots--
if (showBottom) finalItemSlots--
const finalStart = Math.max(0, Math.min(selectedIndex - Math.floor(finalItemSlots / 2), itemCount - finalItemSlots))
return {
visibleStart: finalStart,
visibleCount: finalItemSlots,
showTopIndicator: finalStart > 0,
showBottomIndicator: finalStart + finalItemSlots < itemCount,
}
}, [itemCount, selectedIndex, maxRows])
}
+10 -6
View File
@@ -29,7 +29,7 @@ export const useProcessedMessages = () => {
* Detect if a message has just been completed (is asking for user input)
*/
export const useCompletedAskMessages = () => {
const { state, controller } = useTaskContext()
const { state } = useTaskContext()
const processed = useProcessedMessages()
const getCompletedAskMessages = useCallback(() => {
@@ -124,18 +124,19 @@ export const useCompletionSignals = () => {
/**
* Check if spinner should be shown (when API is thinking)
* Returns an object with isActive flag and startTime timestamp
*/
export const useIsSpinnerActive = (): boolean => {
export const useIsSpinnerActive = (): { isActive: boolean; startTime?: number } => {
const { state } = useTaskContext()
if (!state.clineMessages || state.clineMessages.length === 0) {
return false
return { isActive: false }
}
// If the last message is a completed ask message, don't show spinner (waiting for user input)
const lastMessage = state.clineMessages[state.clineMessages.length - 1]
if (lastMessage?.type === "ask" && !lastMessage.partial) {
return false
return { isActive: false }
}
// Look for most recent api_req_started that isn't followed by api_req_finished
@@ -150,9 +151,12 @@ export const useIsSpinnerActive = (): boolean => {
break
}
}
return !hasFinished
if (!hasFinished) {
return { isActive: true, startTime: msg.ts }
}
return { isActive: false }
}
}
return false
return { isActive: false }
}
+428 -117
View File
@@ -8,25 +8,45 @@ import type { ApiProvider } from "@shared/api"
import { Command } from "commander"
import { render } from "ink"
import React from "react"
import { ClineEndpoint } from "@/config"
import { Controller } from "@/core/controller"
import { StateManager } from "@/core/storage/StateManager"
import { AuthHandler } from "@/hosts/external/AuthHandler"
import { HostProvider } from "@/hosts/host-provider"
import { FileEditProvider } from "@/integrations/editor/FileEditProvider"
import { openAiCodexOAuthManager } from "@/integrations/openai-codex/oauth"
import { StandaloneTerminalManager } from "@/integrations/terminal/standalone/StandaloneTerminalManager"
import { BannerService } from "@/services/banner/BannerService"
import { ErrorService } from "@/services/error/ErrorService"
import { initializeDistinctId } from "@/services/logging/distinctId"
import { telemetryService } from "@/services/telemetry"
import { Logger } from "@/shared/services/Logger"
import { getProviderModelIdKey, ProviderToApiKeyMap } from "@/shared/storage"
import { secretStorage } from "@/shared/storage/ClineSecretStorage"
import { version as CLI_VERSION } from "../package.json"
import { App } from "./components/App"
import { checkRawModeSupport } from "./context/StdinContext"
import { createCliHostBridgeProvider } from "./controllers"
import { CliCommentReviewController } from "./controllers/CliCommentReviewController"
import { CliWebviewProvider } from "./controllers/CliWebviewProvider"
import { restoreConsole } from "./utils/console"
import { print, printError, printInfo, printWarning, separator } from "./utils/display"
import { calculateRobotTopRow, queryCursorPos } from "./utils/cursor-position"
import { print, printInfo, printWarning } from "./utils/display"
import {
addMcpServer,
formatServerConfig,
listMcpServers,
type McpServerConfig,
parseKeyValuePairs,
removeMcpServer,
setMcpServerDisabled,
updateMcpServerAutoApprove,
} from "./utils/mcp"
import { parseImagesFromInput, processImagePaths } from "./utils/parser"
import { getProviderModelIdKey } from "./utils/provider-map"
import { readStdinIfPiped } from "./utils/piped"
import { runPlainTextTask } from "./utils/plain-text-task"
import { initializeCliContext } from "./vscode-context"
const VERSION = "0.0.0"
import { window } from "./vscode-shim"
// Track active context for graceful shutdown
let activeContext: CliContext | null = null
@@ -39,6 +59,7 @@ function setupSignalHandlers() {
process.exit(1)
}
isShuttingDown = true
printWarning(`\n${signal} received, shutting down...`)
try {
@@ -59,6 +80,19 @@ function setupSignalHandlers() {
process.on("SIGINT", () => shutdown("SIGINT"))
process.on("SIGTERM", () => shutdown("SIGTERM"))
// Suppress known abort errors from unhandled rejections
// These occur when task is cancelled and async operations throw "Cline instance aborted"
process.on("unhandledRejection", (reason: unknown) => {
const message = reason instanceof Error ? reason.message : String(reason)
// Silently ignore abort-related errors - they're expected during task cancellation
if (message.includes("aborted") || message.includes("abort")) {
return
}
// For other unhandled rejections, log to file via Logger (if available)
// This won't show in terminal but will be in log files for debugging
Logger.error("Unhandled rejection:", reason)
})
}
setupSignalHandlers()
@@ -88,14 +122,19 @@ async function initializeCli(options: InitOptions): Promise<CliContext> {
workspaceDir: workspacePath,
})
await ClineEndpoint.initialize()
await initializeDistinctId(extensionContext)
if (options.enableAuth) {
AuthHandler.getInstance().setEnabled(true)
}
const logToChannel = options.verbose ? (message: string) => printInfo(message) : () => {}
const outputChannel = window.createOutputChannel("Cline CLI")
outputChannel.appendLine(`Cline CLI initialized. Data dir: ${DATA_DIR}, Extension dir: ${EXTENSION_DIR}`)
const logToChannel = (message: string) => outputChannel.appendLine(message)
HostProvider.initialize(
() => new CliWebviewProvider(extensionContext),
() => new CliWebviewProvider(extensionContext as any),
() => new FileEditProvider(),
() => new CliCommentReviewController(),
() => new StandaloneTerminalManager(),
@@ -108,12 +147,20 @@ async function initializeCli(options: InitOptions): Promise<CliContext> {
)
await ErrorService.initialize()
await StateManager.initialize(extensionContext)
await StateManager.initialize(extensionContext as any)
// Initialize OpenAI Codex OAuth manager with extension context for secrets storage
openAiCodexOAuthManager.initialize(extensionContext)
// Configure the shared Logging class to use HostProvider's output channel
Logger.subscribe((msg: string) => HostProvider.get().logToChannel(msg))
const webview = HostProvider.get().createWebviewProvider() as CliWebviewProvider
const controller = webview.controller
await initializeDistinctId(extensionContext)
BannerService.initialize(webview.controller)
telemetryService.captureHostEvent("cline_cli", "initialized")
const ctx = { extensionContext, dataDir: DATA_DIR, extensionDir: EXTENSION_DIR, workspacePath, controller }
activeContext = ctx
@@ -124,6 +171,10 @@ async function initializeCli(options: InitOptions): Promise<CliContext> {
* Run an Ink app with proper cleanup handling
*/
async function runInkApp(element: React.ReactElement, cleanup: () => Promise<void>): Promise<void> {
// Note: incrementalRendering is disabled because it causes UI glitches on terminal resize.
// Ink's incremental rendering tries to erase N lines based on previous output height,
// but when the terminal shrinks, this leaves artifacts. Gemini CLI only enables
// incrementalRendering when alternateBuffer is also enabled (which we don't use).
const { waitUntilExit, unmount } = render(element)
try {
@@ -140,28 +191,7 @@ async function runInkApp(element: React.ReactElement, cleanup: () => Promise<voi
}
/**
* Wait for a condition with timeout
*/
function waitForCondition(check: () => boolean, timeoutMs: number, intervalMs: number = 100): Promise<boolean> {
return new Promise((resolve) => {
const startTime = Date.now()
const poll = () => {
if (check()) {
resolve(true)
return
}
if (Date.now() - startTime > timeoutMs) {
resolve(false)
return
}
setTimeout(poll, intervalMs)
}
poll()
})
}
/**
* Run a task with the given prompt
* Run a task with the given prompt - uses welcome view for consistent behavior
*/
async function runTask(
prompt: string,
@@ -175,10 +205,12 @@ async function runTask(
thinking?: boolean
yolo?: boolean
images?: string[]
json?: boolean
stdinWasPiped?: boolean
},
existingContext?: CliContext,
) {
const ctx = existingContext || (await initializeCli(options))
const ctx = existingContext || (await initializeCli({ ...options, enableAuth: true }))
// Parse images from the prompt text (e.g., @/path/to/image.png)
const { prompt: cleanPrompt, imagePaths: parsedImagePaths } = parseImagesFromInput(prompt)
@@ -191,10 +223,15 @@ async function runTask(
// Use clean prompt (with image refs removed)
const taskPrompt = cleanPrompt || prompt
// Task without prompt starts in interactive mode
telemetryService.captureHostEvent("task_command", prompt ? "task" : "interactive")
if (options.plan) {
StateManager.get().setGlobalState("mode", "plan")
telemetryService.captureHostEvent("mode_flag", "plan")
} else if (options.act) {
StateManager.get().setGlobalState("mode", "act")
telemetryService.captureHostEvent("mode_flag", "act")
}
if (options.model) {
@@ -213,6 +250,7 @@ async function runTask(
if (providerModelKey) {
StateManager.get().setGlobalState(providerModelKey, options.model)
}
telemetryService.captureHostEvent("model_flag", options.model)
}
// Set thinking budget based on --thinking flag
@@ -220,70 +258,94 @@ async function runTask(
const currentMode = StateManager.get().getGlobalSettingsKey("mode") || "act"
const thinkingKey = currentMode === "act" ? "actModeThinkingBudgetTokens" : "planModeThinkingBudgetTokens"
StateManager.get().setGlobalState(thinkingKey, thinkingBudget)
if (options.thinking) {
telemetryService.captureHostEvent("thinking_flag", "true")
}
// Set yolo mode based on --yolo flag
if (options.yolo) {
StateManager.get().setGlobalState("yoloModeToggled", true)
telemetryService.captureHostEvent("yolo_flag", "true")
}
await StateManager.get().flushPendingState()
printInfo(`Starting Cline task...`)
printInfo(`Working directory: ${ctx.workspacePath}`)
if (imageDataUrls.length > 0) {
printInfo(`Images attached: ${imageDataUrls.length}`)
}
print(separator())
// Detect if output is a TTY (interactive terminal) or redirected to a file/pipe
const isTTY = process.stdout.isTTY === true
let isComplete = false
let taskError = false
// Use plain text mode when output is redirected, stdin was piped, or JSON mode is enabled
// Ink requires raw mode on stdin which isn't available when stdin is piped
// Note: we use the stdinWasPiped flag passed from the caller because process.stdin.isTTY
// may not be reliable after stdin has been consumed by readStdinIfPiped()
if (!isTTY || options.stdinWasPiped || options.json) {
// Check if auth is configured before attempting to run the task
// In plain text mode we can't show the interactive auth flow
const hasAuth = await isAuthConfigured()
if (!hasAuth) {
printWarning("Not authenticated. Please run 'cline auth' first to configure your API credentials.")
await ctx.controller.stateManager.flushPendingState()
await ctx.controller.dispose()
await ErrorService.get().dispose()
exit(1)
}
const { waitUntilExit, unmount } = render(
React.createElement(App, {
view: "task",
taskId: taskPrompt.substring(0, 30),
verbose: options.verbose,
const reason = options.json ? "json" : options.stdinWasPiped ? "piped_stdin" : "redirected_output"
telemetryService.captureHostEvent("plain_text_mode", reason)
// Plain text mode: no Ink rendering, just clean text output
const success = await runPlainTextTask({
controller: ctx.controller,
onComplete: () => {
isComplete = true
},
onError: () => {
taskError = true
isComplete = true
},
}),
)
prompt: taskPrompt,
imageDataUrls: imageDataUrls.length > 0 ? imageDataUrls : undefined,
verbose: options.verbose,
jsonOutput: options.json,
})
await ctx.controller.initTask(taskPrompt, imageDataUrls.length > 0 ? imageDataUrls : undefined)
const completed = await waitForCondition(() => isComplete, 10 * 60 * 1000)
if (!completed) {
printError("Task timeout")
}
// Brief delay for final render
await new Promise((resolve) => setTimeout(resolve, 100))
try {
await waitUntilExit()
if (taskError) {
process.exit(1)
}
} catch (error) {
printError(`Task failed: ${error instanceof Error ? error.message : String(error)}`)
process.exit(1)
} finally {
try {
unmount()
} catch {
// Already unmounted
}
restoreConsole()
// Cleanup
await ctx.controller.stateManager.flushPendingState()
await ctx.controller.dispose()
await ErrorService.get().dispose()
exit(0)
exit(success ? 0 : 1)
}
// Use welcome view for consistent rendering (same as interactive mode)
// Query cursor position BEFORE Ink mounts to know where robot will render
const cursorPos = await queryCursorPos(process.stdin, process.stdout)
const terminalRows = process.stdout.rows ?? 24
const robotTopRow = calculateRobotTopRow(cursorPos, terminalRows)
let taskError = false
// Render the welcome view with optional initial prompt/images
// If prompt provided (cline task "prompt"), ChatView will auto-submit
// If no prompt (cline interactive), user will type it in
await runInkApp(
React.createElement(App, {
view: "welcome",
verbose: options.verbose,
controller: ctx.controller,
isRawModeSupported: checkRawModeSupport(),
robotTopRow,
initialPrompt: taskPrompt || undefined,
initialImages: imageDataUrls.length > 0 ? imageDataUrls : undefined,
onError: () => {
taskError = true
},
onWelcomeExit: () => {
// User pressed Esc
exit(0)
},
}),
async () => {
await ctx.controller.stateManager.flushPendingState()
await ctx.controller.dispose()
await ErrorService.get().dispose()
if (taskError) {
printWarning("Task ended with errors.")
exit(1)
}
exit(0)
},
)
}
/**
@@ -300,6 +362,8 @@ async function listHistory(options: { config?: string; limit?: number; page?: nu
const totalCount = sortedHistory.length
const totalPages = Math.ceil(totalCount / limit)
telemetryService.captureHostEvent("history_command", "executed")
if (sortedHistory.length === 0) {
printInfo("No task history found.")
await ctx.controller.stateManager.flushPendingState()
@@ -316,6 +380,7 @@ async function listHistory(options: { config?: string; limit?: number; page?: nu
historyAllItems: sortedHistory,
controller: ctx.controller,
historyPagination: { page: initialPage, totalPages, totalCount, limit },
isRawModeSupported: checkRawModeSupport(),
}),
async () => {
await ctx.controller.stateManager.flushPendingState()
@@ -337,17 +402,19 @@ async function showConfig(options: { config?: string }) {
const { ConfigViewWrapper } = await import("./components/ConfigViewWrapper")
// Check feature flags
const hooksEnabled = stateManager.getGlobalSettingsKey("hooksEnabled") ?? false
const skillsEnabled = stateManager.getGlobalSettingsKey("skillsEnabled") ?? false
telemetryService.captureHostEvent("config_command", "executed")
await runInkApp(
React.createElement(ConfigViewWrapper, {
controller: ctx.controller,
dataDir: ctx.dataDir,
globalState: stateManager.getAllGlobalStateEntries(),
workspaceState: stateManager.getAllWorkspaceStateEntries(),
hooksEnabled,
hooksEnabled: true,
skillsEnabled,
isRawModeSupported: checkRawModeSupport(),
}),
async () => {
await ctx.controller.stateManager.flushPendingState()
@@ -377,16 +444,21 @@ async function runAuth(options: {
? { provider: options.provider, apikey: options.apikey, modelid: options.modelid, baseurl: options.baseurl }
: undefined
telemetryService.captureHostEvent("auth_command", hasQuickSetupFlags ? "quick_setup" : "interactive")
let authError = false
await runInkApp(
React.createElement(App, {
view: "auth",
controller: ctx.controller,
isRawModeSupported: checkRawModeSupport(),
onComplete: () => {
telemetryService.captureHostEvent("auth", "completed")
exit(0)
},
onError: () => {
telemetryService.captureHostEvent("auth", "error")
authError = true
},
authQuickSetup: quickSetup,
@@ -407,7 +479,7 @@ async function runAuth(options: {
// Setup CLI commands
const program = new Command()
program.name("cline").description("Cline CLI - AI coding assistant in your terminal").version(VERSION)
program.name("cline").description("Cline CLI - AI coding assistant in your terminal").version(CLI_VERSION)
// Enable positional options to avoid conflicts between root and subcommand options with the same name
program.enablePositionalOptions()
@@ -421,11 +493,11 @@ program
.option("-p, --plan", "Run in plan mode")
.option("-y, --yolo", "Enable yolo mode (auto-approve actions)")
.option("-m, --model <model>", "Model to use for the task")
.option("-i, --images <paths...>", "Image file paths to include with the task")
.option("-v, --verbose", "Show verbose output")
.option("-c, --cwd <path>", "Working directory for the task")
.option("--config <path>", "Path to Cline configuration directory")
.option("--thinking", "Enable extended thinking (1024 token budget)")
.option("--json", "Output messages as JSON instead of styled text")
.action((prompt, options) => runTask(prompt, options))
program
@@ -458,63 +530,302 @@ program
program
.command("version")
.description("Show Cline CLI version number")
.action(() => printInfo(`Cline CLI version: ${VERSION}`))
.action(() => printInfo(`Cline CLI version: ${CLI_VERSION}`))
// MCP Server management commands
const mcpCommand = program.command("mcp").description("Manage MCP (Model Context Protocol) servers")
mcpCommand
.command("add <name>")
.description("Add a new MCP server")
.option("--command <cmd>", "Command to run (for stdio transport)")
.option("--args <args...>", "Arguments for the command")
.option("--cwd <path>", "Working directory for the command")
.option("--env <pairs...>", "Environment variables (KEY=VALUE format)")
.option("--url <url>", "Server URL (for sse/http transport)")
.option("--headers <pairs...>", "HTTP headers (KEY=VALUE format)")
.option("--type <type>", "Transport type: stdio, sse, or http", "stdio")
.option("--timeout <seconds>", "Network timeout in seconds")
.option("--allow <tools...>", "Tools to auto-approve")
.option("--config <path>", "Path to Cline configuration directory")
.action(async (name, options) => {
const dataDir = options.config ? path.join(options.config, "data") : undefined
let config: McpServerConfig
if (options.url) {
// SSE or Streamable HTTP server
const type = options.type === "http" ? "streamableHttp" : "sse"
config = {
type,
url: options.url,
headers: options.headers ? parseKeyValuePairs(options.headers) : undefined,
autoApprove: options.allow,
timeout: options.timeout ? parseInt(options.timeout, 10) : undefined,
} as McpServerConfig
} else if (options.command) {
// STDIO server
config = {
type: "stdio",
command: options.command,
args: options.args,
cwd: options.cwd,
env: options.env ? parseKeyValuePairs(options.env) : undefined,
autoApprove: options.allow,
timeout: options.timeout ? parseInt(options.timeout, 10) : undefined,
}
} else {
printWarning("Error: Either --command (for stdio) or --url (for sse/http) is required")
exit(1)
}
try {
await addMcpServer(name, config, dataDir)
printInfo(`Added MCP server: ${name}`)
} catch (error: any) {
printWarning(`Failed to add server: ${error.message}`)
exit(1)
}
})
mcpCommand
.command("remove <name>")
.alias("rm")
.description("Remove an MCP server")
.option("--config <path>", "Path to Cline configuration directory")
.action(async (name, options) => {
const dataDir = options.config ? path.join(options.config, "data") : undefined
const removed = await removeMcpServer(name, dataDir)
if (removed) {
printInfo(`Removed MCP server: ${name}`)
} else {
printWarning(`Server not found: ${name}`)
exit(1)
}
})
mcpCommand
.command("disable <name>")
.description("Disable an MCP server")
.option("--config <path>", "Path to Cline configuration directory")
.action(async (name, options) => {
const dataDir = options.config ? path.join(options.config, "data") : undefined
const success = await setMcpServerDisabled(name, true, dataDir)
if (success) {
printInfo(`Disabled MCP server: ${name}`)
} else {
printWarning(`Server not found: ${name}`)
exit(1)
}
})
mcpCommand
.command("enable <name>")
.description("Enable an MCP server")
.option("--config <path>", "Path to Cline configuration directory")
.action(async (name, options) => {
const dataDir = options.config ? path.join(options.config, "data") : undefined
const success = await setMcpServerDisabled(name, false, dataDir)
if (success) {
printInfo(`Enabled MCP server: ${name}`)
} else {
printWarning(`Server not found: ${name}`)
exit(1)
}
})
mcpCommand
.command("list")
.alias("ls")
.description("List all configured MCP servers")
.option("--json", "Output as JSON")
.option("--config <path>", "Path to Cline configuration directory")
.action(async (options) => {
const dataDir = options.config ? path.join(options.config, "data") : undefined
const servers = await listMcpServers(dataDir)
if (servers.length === 0) {
printInfo("No MCP servers configured.")
return
}
if (options.json) {
print(JSON.stringify(servers, null, 2))
} else {
for (const { name, config } of servers) {
print(formatServerConfig(name, config))
print("")
}
}
})
mcpCommand
.command("allow <name>")
.description("Manage auto-approve tools for an MCP server")
.option("--add <tools...>", "Add tools to auto-approve list")
.option("--remove <tools...>", "Remove tools from auto-approve list")
.option("--set <tools...>", "Set the auto-approve list (replaces existing)")
.option("--clear", "Clear all auto-approve tools")
.option("--config <path>", "Path to Cline configuration directory")
.action(async (name, options) => {
const dataDir = options.config ? path.join(options.config, "data") : undefined
let action: "add" | "remove" | "set" | "clear"
let tools: string[] = []
if (options.clear) {
action = "clear"
} else if (options.set) {
action = "set"
tools = options.set
} else if (options.add) {
action = "add"
tools = options.add
} else if (options.remove) {
action = "remove"
tools = options.remove
} else {
printWarning("Error: Specify --add, --remove, --set, or --clear")
exit(1)
}
const success = await updateMcpServerAutoApprove(name, action, tools, dataDir)
if (success) {
if (action === "clear") {
printInfo(`Cleared auto-approve tools for: ${name}`)
} else {
printInfo(`Updated auto-approve tools for: ${name}`)
}
} else {
printWarning(`Server not found: ${name}`)
exit(1)
}
})
/**
* Show welcome prompt and run task with user input
* Check if the user has authentication configured.
* Returns true if they have either:
* - Cline provider with stored auth data
* - OpenAI Codex provider with OAuth credentials
* - BYO provider with an API key configured
*/
async function isAuthConfigured(): Promise<boolean> {
const stateManager = StateManager.get()
const mode = stateManager.getGlobalSettingsKey("mode") as string
const providerKey = mode === "act" ? "actModeApiProvider" : "planModeApiProvider"
const currentProvider = (stateManager.getGlobalSettingsKey(providerKey) as string) || "cline"
if (currentProvider === "cline") {
// For Cline provider, check if we have stored auth data
const authData = await secretStorage.get("cline:clineAccountId")
return !!authData
}
if (currentProvider === "openai-codex") {
// For OpenAI Codex, check if OAuth credentials are stored
const isAuthenticated = await openAiCodexOAuthManager.isAuthenticated()
return isAuthenticated
}
// For BYO providers, check if the API key is configured
const keyField = ProviderToApiKeyMap[currentProvider as keyof typeof ProviderToApiKeyMap]
if (!keyField) {
return false
}
const fields = Array.isArray(keyField) ? keyField : [keyField]
for (const field of fields) {
const value = await secretStorage.get(field)
if (value) {
return true
}
}
return false
}
/**
* Show welcome prompt and wait for user input
* If auth is not configured, show auth flow first
*/
async function showWelcome(options: { verbose?: boolean; cwd?: string; config?: string; thinking?: boolean }) {
const ctx = await initializeCli({ ...options, enableAuth: true })
let submittedPrompt: string | null = null
let submittedImagePaths: string[] = []
// Check if auth is configured
const hasAuth = await isAuthConfigured()
const { waitUntilExit, unmount } = render(
// Query cursor position BEFORE Ink mounts
const cursorPos = await queryCursorPos(process.stdin, process.stdout)
const terminalRows = process.stdout.rows ?? 24
const robotTopRow = calculateRobotTopRow(cursorPos, terminalRows)
let hadError = false
await runInkApp(
React.createElement(App, {
view: "welcome",
// Start with auth view if not configured, otherwise welcome
view: hasAuth ? "welcome" : "auth",
verbose: options.verbose,
controller: ctx.controller,
onWelcomeSubmit: (prompt: string, imagePaths: string[]) => {
submittedPrompt = prompt
submittedImagePaths = imagePaths
unmount()
},
isRawModeSupported: checkRawModeSupport(),
robotTopRow,
onWelcomeExit: () => {
unmount()
exit(0)
},
onError: () => {
hadError = true
},
}),
async () => {
await ctx.controller.stateManager.flushPendingState()
await ctx.controller.dispose()
await ErrorService.get().dispose()
exit(hadError ? 1 : 0)
},
)
try {
await waitUntilExit()
} catch {
// App unmounted after prompt submission
}
restoreConsole()
if (submittedPrompt || submittedImagePaths.length > 0) {
// Run the task with the submitted prompt and images, reusing the existing context
await runTask(submittedPrompt || "", { ...options, images: submittedImagePaths }, ctx)
} else {
// User exited without submitting - clean up and exit
await ctx.controller.stateManager.flushPendingState()
await ctx.controller.dispose()
await ErrorService.get().dispose()
exit(0)
}
}
// Interactive mode (default when no command given)
program
.argument("[prompt]", "Task prompt (starts task immediately)")
.option("-i, --images <paths...>", "Image file paths to include with the task")
.option("-a, --act", "Run in act mode")
.option("-p, --plan", "Run in plan mode")
.option("-y, --yolo", "Enable yolo mode (auto-approve actions)")
.option("-m, --model <model>", "Model to use for the task")
.option("-v, --verbose", "Show verbose output")
.option("-c, --cwd <path>", "Working directory")
.option("--config <path>", "Configuration directory")
.option("--thinking", "Enable extended thinking (1024 token budget)")
.option("--json", "Output messages as JSON instead of styled text")
.action(async (prompt, options) => {
if (prompt) {
await runTask(prompt, options)
// Always check for piped stdin content
const stdinInput = await readStdinIfPiped()
// Combine stdin content with prompt argument
let effectivePrompt = prompt
if (stdinInput) {
if (effectivePrompt) {
// Prepend stdin content to the prompt
effectivePrompt = `${stdinInput}\n\n${effectivePrompt}`
} else {
effectivePrompt = stdinInput
}
telemetryService.captureHostEvent("piped", "detached")
// Debug: show that we received piped input
if (options.verbose) {
process.stderr.write(`[debug] Received ${stdinInput.length} bytes from stdin\n`)
}
}
if (effectivePrompt) {
// Pass stdinWasPiped flag so runTask knows to use plain text mode
await runTask(effectivePrompt, { ...options, stdinWasPiped: !!stdinInput })
} else {
// Show welcome prompt if no prompt given
await showWelcome(options)
+1
View File
@@ -21,6 +21,7 @@ if (!isVerbose) {
console.warn = () => {}
console.error = () => {}
console.debug = () => {}
console.info = () => {}
}
/**
+57
View File
@@ -0,0 +1,57 @@
/**
* Query terminal cursor position before Ink mounts
* Must be called BEFORE render() to avoid escape sequence leaking into Ink's input handling
*/
export async function queryCursorPos(
stdin: NodeJS.ReadStream,
stdout: NodeJS.WriteStream,
{ timeoutMs = 75 } = {},
): Promise<{ row: number; col: number } | null> {
if (!stdin.isTTY || !stdout.isTTY || typeof (stdin as any).setRawMode !== "function") return null
const ttyIn = stdin as any as { isRaw?: boolean; setRawMode: (b: boolean) => void; on: any; off: any }
const wasRaw = !!ttyIn.isRaw
ttyIn.setRawMode(true)
stdin.resume()
return await new Promise((resolve) => {
let buf = ""
const onData = (chunk: Buffer) => {
buf += chunk.toString("utf8")
const m = buf.match(/\x1b\[(\d+);(\d+)R/)
if (!m) return
cleanup()
resolve({ row: Number(m[1]), col: Number(m[2]) })
}
const cleanup = () => {
clearTimeout(timer)
stdin.off("data", onData)
try {
ttyIn.setRawMode(wasRaw)
} catch {}
}
const timer = setTimeout(() => {
cleanup()
resolve(null)
}, timeoutMs)
stdin.on("data", onData)
stdout.write("\x1b[6n") // DSR: cursor position
})
}
/**
* Calculate where the robot will be rendered on screen
*/
export function calculateRobotTopRow(cursorPos: { row: number } | null, terminalRows: number): number {
const robotHeight = 12
const firstFrameHeight = robotHeight + 8 // robot + welcome text + margins + input + footer
const startRow = cursorPos?.row ?? 1
// If content doesn't fit below cursor, terminal scrolls
return Math.max(1, Math.min(startRow, terminalRows - firstFrameHeight + 1))
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Cursor movement utilities for multi-line text input
*/
/**
* Move cursor up one line, preserving column position where possible
*/
export function moveCursorUp(text: string, cursorPos: number): number {
const textBeforeCursor = text.slice(0, cursorPos)
const lastNewline = textBeforeCursor.lastIndexOf("\n")
if (lastNewline === -1) {
// Already on first line, move to start
return 0
}
const currentCol = cursorPos - lastNewline - 1
const prevLineStart = textBeforeCursor.lastIndexOf("\n", lastNewline - 1) + 1
const prevLineLength = lastNewline - prevLineStart
const newCol = Math.min(currentCol, prevLineLength)
return prevLineStart + newCol
}
/**
* Move cursor down one line, preserving column position where possible
*/
export function moveCursorDown(text: string, cursorPos: number): number {
const textBeforeCursor = text.slice(0, cursorPos)
const lastNewline = textBeforeCursor.lastIndexOf("\n")
const currentCol = lastNewline === -1 ? cursorPos : cursorPos - lastNewline - 1
const nextNewline = text.indexOf("\n", cursorPos)
if (nextNewline === -1) {
// Already on last line, move to end
return text.length
}
const nextLineStart = nextNewline + 1
const nextLineEnd = text.indexOf("\n", nextLineStart)
const nextLineLength = nextLineEnd === -1 ? text.length - nextLineStart : nextLineEnd - nextLineStart
const newCol = Math.min(currentCol, nextLineLength)
return nextLineStart + newCol
}
+5 -2
View File
@@ -8,6 +8,7 @@ import { promises as fs } from "node:fs"
import { basename, dirname, join, relative } from "node:path"
import { createInterface } from "node:readline"
import type { Fzf, FzfResultItem } from "fzf"
import { Logger } from "@/shared/services/Logger"
export interface FileSearchResult {
path: string
@@ -228,7 +229,7 @@ export async function searchWorkspaceFiles(
.slice(0, limit)
.map((r) => r.item)
} catch (error) {
console.error("File search error:", error)
Logger.error("File search error:", error)
return []
}
}
@@ -251,6 +252,8 @@ export function extractMentionQuery(text: string): { inMentionMode: boolean; que
export function insertMention(text: string, atIndex: number, filePath: string): string {
const endIndex = text.indexOf(" ", atIndex)
const end = endIndex === -1 ? text.length : endIndex
const mention = filePath.includes(" ") ? `@"${filePath}"` : `@${filePath}`
// Ensure path starts with / for proper mention format
const normalizedPath = filePath.startsWith("/") ? filePath : `/${filePath}`
const mention = normalizedPath.includes(" ") ? `@"${normalizedPath}"` : `@${normalizedPath}`
return text.slice(0, atIndex) + mention + " " + text.slice(end).trimStart()
}
+247
View File
@@ -0,0 +1,247 @@
/**
* Utility to detect and import API keys from competing CLI agents (Codex, OpenCode)
*/
import * as fs from "fs"
import * as os from "os"
import * as path from "path"
import providersData from "@/shared/providers/providers.json"
// Import source types
export type ImportSource = "codex" | "opencode"
// Imported key structure
export interface ImportedKey {
provider: string // Cline provider ID
keyField: string // Cline API key field name
key: string // The API key value
modelId?: string // Optional default model ID
}
// Import result
export interface ImportResult {
source: ImportSource
keys: ImportedKey[]
}
// Available import sources that were detected
export interface DetectedSources {
codex: boolean
opencode: boolean
}
// Build provider labels map from providers.json (single source of truth)
const providerLabels: Record<string, string> = Object.fromEntries(
providersData.list.map((p: { value: string; label: string }) => [p.value, p.label]),
)
/**
* Get possible data directories for OpenCode
* OpenCode uses XDG_DATA_HOME on all platforms, defaulting to ~/.local/share/opencode
* Returns array of paths to check (in order of preference)
*/
function getOpenCodeDataDirs(): string[] {
const home = os.homedir()
const paths: string[] = []
// XDG path (used by OpenCode on all platforms)
if (process.env.XDG_DATA_HOME) {
paths.push(path.join(process.env.XDG_DATA_HOME, "opencode"))
}
paths.push(path.join(home, ".local", "share", "opencode"))
return paths
}
/**
* Detect which CLI agents have config files with API keys
*/
export function detectImportSources(): DetectedSources {
return {
codex: hasCodexConfig(),
opencode: hasOpenCodeConfig(),
}
}
/**
* Check if Codex config exists with API keys
*/
function hasCodexConfig(): boolean {
try {
const authPath = path.join(os.homedir(), ".codex", "auth.json")
if (!fs.existsSync(authPath)) {
return false
}
const content = fs.readFileSync(authPath, "utf-8")
const data = JSON.parse(content)
// Check if there's at least one key
return Object.keys(data).length > 0
} catch {
return false
}
}
/**
* Check if OpenCode config exists with API keys
*/
function hasOpenCodeConfig(): boolean {
for (const dir of getOpenCodeDataDirs()) {
try {
const authPath = path.join(dir, "auth.json")
if (!fs.existsSync(authPath)) {
continue
}
const content = fs.readFileSync(authPath, "utf-8")
const data = JSON.parse(content)
// Check if there's at least one key
if (Object.keys(data).length > 0) {
return true
}
} catch {}
}
return false
}
/**
* Find the OpenCode auth.json path (first existing one)
*/
function findOpenCodeAuthPath(): string | null {
for (const dir of getOpenCodeDataDirs()) {
const authPath = path.join(dir, "auth.json")
if (fs.existsSync(authPath)) {
return authPath
}
}
return null
}
/**
* Map Codex key names to Cline providers
*/
const CODEX_KEY_MAP: Record<string, { provider: string; keyField: string; modelId?: string }> = {
OPENAI_API_KEY: { provider: "openai-native", keyField: "openAiNativeApiKey", modelId: "gpt-4o" },
ANTHROPIC_API_KEY: { provider: "anthropic", keyField: "apiKey", modelId: "claude-sonnet-4-20250514" },
}
/**
* Map OpenCode provider IDs to Cline providers
*/
const OPENCODE_PROVIDER_MAP: Record<string, { provider: string; keyField: string; modelId?: string }> = {
openai: { provider: "openai-native", keyField: "openAiNativeApiKey", modelId: "gpt-4o" },
anthropic: { provider: "anthropic", keyField: "apiKey", modelId: "claude-sonnet-4-20250514" },
gemini: { provider: "gemini", keyField: "geminiApiKey", modelId: "gemini-2.0-flash-001" },
mistral: { provider: "mistral", keyField: "mistralApiKey" },
groq: { provider: "groq", keyField: "groqApiKey" },
deepseek: { provider: "deepseek", keyField: "deepSeekApiKey" },
xai: { provider: "xai", keyField: "xaiApiKey" },
openrouter: { provider: "openrouter", keyField: "openRouterApiKey" },
}
/**
* Import keys from Codex CLI
*/
export function importFromCodex(): ImportResult | null {
try {
const authPath = path.join(os.homedir(), ".codex", "auth.json")
if (!fs.existsSync(authPath)) {
return null
}
const content = fs.readFileSync(authPath, "utf-8")
const data = JSON.parse(content) as Record<string, string>
const keys: ImportedKey[] = []
for (const [envKey, apiKey] of Object.entries(data)) {
const mapping = CODEX_KEY_MAP[envKey]
if (mapping && apiKey) {
keys.push({
provider: mapping.provider,
keyField: mapping.keyField,
key: apiKey,
modelId: mapping.modelId,
})
}
}
if (keys.length === 0) {
return null
}
return { source: "codex", keys }
} catch {
return null
}
}
// OpenCode auth entry structure
interface OpenCodeAuthEntry {
type: "api" | "oauth"
key?: string
access?: string
refresh?: string
expires?: number
}
/**
* Import keys from OpenCode CLI
*/
export function importFromOpenCode(): ImportResult | null {
try {
const authPath = findOpenCodeAuthPath()
if (!authPath) {
return null
}
const content = fs.readFileSync(authPath, "utf-8")
const data = JSON.parse(content) as Record<string, OpenCodeAuthEntry>
const keys: ImportedKey[] = []
for (const [providerId, authEntry] of Object.entries(data)) {
// Only import API type keys (not OAuth)
if (authEntry.type !== "api" || !authEntry.key) {
continue
}
const mapping = OPENCODE_PROVIDER_MAP[providerId]
if (mapping) {
keys.push({
provider: mapping.provider,
keyField: mapping.keyField,
key: authEntry.key,
modelId: mapping.modelId,
})
}
}
if (keys.length === 0) {
return null
}
return { source: "opencode", keys }
} catch {
return null
}
}
/**
* Get human-readable source name
*/
export function getSourceDisplayName(source: ImportSource): string {
switch (source) {
case "codex":
return "OpenAI Codex CLI"
case "opencode":
return "OpenCode"
default:
return source
}
}
/**
* Get provider display name from providers.json
*/
export function getProviderDisplayName(provider: string): string {
return providerLabels[provider] || provider
}
+13
View File
@@ -0,0 +1,13 @@
/**
* Input filtering utilities for CLI components
*/
/**
* Check if input contains mouse escape sequences from terminal mouse tracking.
* AsciiMotionCli enables mouse tracking which generates sequences like [<35;46;17M
* These should be filtered out of text input handlers.
*/
export function isMouseEscapeSequence(input: string): boolean {
// Mouse events look like: [<35;46;17M or contain escape characters
return input.includes("\x1b") || input.includes("[<") || /\d+;\d+[Mm]/.test(input)
}
+226
View File
@@ -0,0 +1,226 @@
/**
* MCP Server configuration utilities for CLI
*/
import fs from "node:fs/promises"
import path from "node:path"
import { CLINE_CLI_DIR } from "./path"
const MCP_SETTINGS_FILENAME = "cline_mcp_settings.json"
export interface StdioServerConfig {
type?: "stdio"
command: string
args?: string[]
cwd?: string
env?: Record<string, string>
autoApprove?: string[]
disabled?: boolean
timeout?: number
}
export interface SseServerConfig {
type: "sse"
url: string
headers?: Record<string, string>
autoApprove?: string[]
disabled?: boolean
timeout?: number
}
export interface StreamableHttpServerConfig {
type: "streamableHttp"
url: string
headers?: Record<string, string>
autoApprove?: string[]
disabled?: boolean
timeout?: number
}
export type McpServerConfig = StdioServerConfig | SseServerConfig | StreamableHttpServerConfig
export interface McpSettings {
mcpServers: Record<string, McpServerConfig>
}
/**
* Get the path to the MCP settings file
*/
export function getMcpSettingsPath(dataDir?: string): string {
return path.join(dataDir ?? CLINE_CLI_DIR.data, MCP_SETTINGS_FILENAME)
}
/**
* Read MCP settings from disk
*/
export async function readMcpSettings(dataDir?: string): Promise<McpSettings> {
const settingsPath = getMcpSettingsPath(dataDir)
try {
const content = await fs.readFile(settingsPath, "utf-8")
return JSON.parse(content)
} catch (error: any) {
if (error.code === "ENOENT") {
return { mcpServers: {} }
}
throw error
}
}
/**
* Write MCP settings to disk
*/
export async function writeMcpSettings(settings: McpSettings, dataDir?: string): Promise<void> {
const settingsPath = getMcpSettingsPath(dataDir)
// Ensure directory exists
await fs.mkdir(path.dirname(settingsPath), { recursive: true })
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2))
}
/**
* Add or update an MCP server
*/
export async function addMcpServer(name: string, config: McpServerConfig, dataDir?: string): Promise<void> {
const settings = await readMcpSettings(dataDir)
settings.mcpServers[name] = config
await writeMcpSettings(settings, dataDir)
}
/**
* Remove an MCP server
*/
export async function removeMcpServer(name: string, dataDir?: string): Promise<boolean> {
const settings = await readMcpSettings(dataDir)
if (!(name in settings.mcpServers)) {
return false
}
delete settings.mcpServers[name]
await writeMcpSettings(settings, dataDir)
return true
}
/**
* Enable or disable an MCP server
*/
export async function setMcpServerDisabled(name: string, disabled: boolean, dataDir?: string): Promise<boolean> {
const settings = await readMcpSettings(dataDir)
if (!(name in settings.mcpServers)) {
return false
}
settings.mcpServers[name].disabled = disabled
await writeMcpSettings(settings, dataDir)
return true
}
/**
* Get a single MCP server config
*/
export async function getMcpServer(name: string, dataDir?: string): Promise<McpServerConfig | undefined> {
const settings = await readMcpSettings(dataDir)
return settings.mcpServers[name]
}
/**
* List all MCP servers
*/
export async function listMcpServers(dataDir?: string): Promise<Array<{ name: string; config: McpServerConfig }>> {
const settings = await readMcpSettings(dataDir)
return Object.entries(settings.mcpServers).map(([name, config]) => ({ name, config }))
}
/**
* Update auto-approve tools for an MCP server
*/
export async function updateMcpServerAutoApprove(
name: string,
action: "add" | "remove" | "set" | "clear",
tools: string[],
dataDir?: string,
): Promise<boolean> {
const settings = await readMcpSettings(dataDir)
if (!(name in settings.mcpServers)) {
return false
}
const server = settings.mcpServers[name]
const currentTools = server.autoApprove ?? []
switch (action) {
case "add":
server.autoApprove = [...new Set([...currentTools, ...tools])]
break
case "remove":
server.autoApprove = currentTools.filter((t) => !tools.includes(t))
break
case "set":
server.autoApprove = tools
break
case "clear":
server.autoApprove = []
break
}
await writeMcpSettings(settings, dataDir)
return true
}
/**
* Parse key=value pairs from command line arguments
*/
export function parseKeyValuePairs(pairs: string[]): Record<string, string> {
const result: Record<string, string> = {}
for (const pair of pairs) {
const eqIndex = pair.indexOf("=")
if (eqIndex === -1) {
throw new Error(`Invalid key=value format: ${pair}`)
}
const key = pair.substring(0, eqIndex)
const value = pair.substring(eqIndex + 1)
result[key] = value
}
return result
}
/**
* Format server config for display
*/
export function formatServerConfig(name: string, config: McpServerConfig): string {
const lines: string[] = []
const status = config.disabled ? " (disabled)" : ""
if ("command" in config && config.command) {
// STDIO server
lines.push(`${name}${status} [stdio]`)
lines.push(` command: ${config.command}`)
if (config.args?.length) {
lines.push(` args: ${config.args.join(" ")}`)
}
if (config.cwd) {
lines.push(` cwd: ${config.cwd}`)
}
if (config.env && Object.keys(config.env).length > 0) {
lines.push(
` env: ${Object.entries(config.env)
.map(([k, v]) => `${k}=${v}`)
.join(", ")}`,
)
}
} else if ("url" in config && config.url) {
// SSE or Streamable HTTP server
const type = config.type === "streamableHttp" ? "http" : "sse"
lines.push(`${name}${status} [${type}]`)
lines.push(` url: ${config.url}`)
if (config.headers && Object.keys(config.headers).length > 0) {
const headerKeys = Object.keys(config.headers).join(", ")
lines.push(` headers: ${headerKeys}`)
}
}
if (config.timeout) {
lines.push(` timeout: ${config.timeout}s`)
}
if (config.autoApprove?.length) {
lines.push(` auto-approve: ${config.autoApprove.join(", ")}`)
}
return lines.join("\n")
}
+71
View File
@@ -0,0 +1,71 @@
/**
* Utility to fetch and cache OpenRouter models for the CLI
*/
import { openRouterDefaultModelId } from "@/shared/api"
import { fetch } from "@/shared/net"
import { Logger } from "@/shared/services/Logger"
interface OpenRouterModel {
id: string
name: string
}
// In-memory cache
let cachedModels: string[] | null = null
let fetchPromise: Promise<string[]> | null = null
/**
* Fetch OpenRouter models from the API
* Returns cached results if available, or fetches from API
*/
export async function fetchOpenRouterModels(): Promise<string[]> {
// Return cached models if available
if (cachedModels) {
return cachedModels
}
// If already fetching, wait for that promise
if (fetchPromise) {
return fetchPromise
}
// Start fetching
fetchPromise = (async () => {
try {
const response = await fetch("https://openrouter.ai/api/v1/models")
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.status}`)
}
const data = await response.json()
if (data?.data) {
const models = (data.data as OpenRouterModel[]).map((m) => m.id).sort((a, b) => a.localeCompare(b))
cachedModels = models
return models
}
return []
} catch (error) {
Logger.debug("Failed to fetch OpenRouter models:", error)
return []
} finally {
fetchPromise = null
}
})()
return fetchPromise
}
/**
* Get the default OpenRouter model ID
*/
export function getOpenRouterDefaultModelId(): string {
return openRouterDefaultModelId
}
/**
* Check if provider uses OpenRouter models (openrouter or cline)
*/
export function usesOpenRouterModels(provider: string): boolean {
return provider === "openrouter" || provider === "cline"
}
+11
View File
@@ -0,0 +1,11 @@
import os from "node:os"
import path from "node:path"
const data = process.env.CLINE_DATA_DIR ?? path.join(os.homedir(), ".cline", "data")
const log = process.env.CLINE_LOG_DIR ?? path.join(data, "logs")
export const CLINE_CLI_DIR = {
data,
log,
}
+392
View File
@@ -0,0 +1,392 @@
import { Readable } from "node:stream"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { readStdinIfPiped } from "./piped"
describe("readStdinIfPiped", () => {
let mockStdin: Readable & { isTTY?: boolean }
beforeEach(() => {
// Create a mock readable stream
mockStdin = new Readable({
read() {},
}) as Readable & { isTTY?: boolean }
// Mock process.stdin by stubbing its properties
vi.spyOn(process, "stdin", "get").mockReturnValue(mockStdin as any)
})
afterEach(() => {
vi.restoreAllMocks()
})
describe("TTY detection", () => {
it("should return null when stdin is a TTY (interactive terminal)", async () => {
mockStdin.isTTY = true
const result = await readStdinIfPiped()
expect(result).toBeNull()
})
it("should attempt to read when stdin is not a TTY (piped input)", async () => {
mockStdin.isTTY = false
// Simulate immediate end event (no data)
setImmediate(() => {
mockStdin.emit("end")
})
const result = await readStdinIfPiped()
expect(result).toBeNull()
})
})
describe("piped input scenarios", () => {
interface TestCase {
name: string
input: string | string[]
expected: string | null
description?: string
}
const testCases: TestCase[] = [
{
name: "single line input",
input: "echo hello",
expected: "echo hello",
description: "should read and return single line",
},
{
name: "multi-line input",
input: ["line 1", "line 2", "line 3"],
expected: "line 1\nline 2\nline 3",
description: "should read and join multiple lines",
},
{
name: "empty string",
input: "",
expected: null,
description: "should return null for empty input",
},
{
name: "whitespace only",
input: " \n \t \n ",
expected: null,
description: "should return null for whitespace-only input",
},
{
name: "leading and trailing whitespace",
input: " hello world \n",
expected: "hello world",
description: "should trim leading and trailing whitespace",
},
{
name: "large input",
input: "a".repeat(10000),
expected: "a".repeat(10000),
description: "should handle large input",
},
{
name: "special characters",
input: "!@#$%^&*(){}[]|\\:;\"'<>?,./",
expected: "!@#$%^&*(){}[]|\\:;\"'<>?,./",
description: "should preserve special characters",
},
{
name: "unicode characters",
input: "Hello 世界 🌍 مرحبا",
expected: "Hello 世界 🌍 مرحبا",
description: "should handle unicode characters correctly",
},
{
name: "JSON input",
input: '{"key": "value", "nested": {"data": 123}}',
expected: '{"key": "value", "nested": {"data": 123}}',
description: "should preserve JSON structure",
},
{
name: "code snippet",
input: ["function test() {", ' console.log("hello")', "}"],
expected: 'function test() {\n console.log("hello")\n}',
description: "should preserve code structure with indentation",
},
]
testCases.forEach(({ name, input, expected, description }) => {
it(`${name}${description ? ` - ${description}` : ""}`, async () => {
mockStdin.isTTY = false
// Simulate piped data
setImmediate(() => {
const data = Array.isArray(input) ? input.join("\n") : input
mockStdin.push(data)
mockStdin.push(null) // Signal end of stream
})
const result = await readStdinIfPiped()
expect(result).toBe(expected)
})
})
})
describe("chunked data", () => {
it("should accumulate data from multiple chunks", async () => {
mockStdin.isTTY = false
setImmediate(() => {
mockStdin.emit("data", "chunk1 ")
mockStdin.emit("data", "chunk2 ")
mockStdin.emit("data", "chunk3")
mockStdin.emit("end")
})
const result = await readStdinIfPiped()
expect(result).toBe("chunk1 chunk2 chunk3")
})
it("should handle rapid successive chunks", async () => {
mockStdin.isTTY = false
setImmediate(() => {
for (let i = 0; i < 100; i++) {
mockStdin.emit("data", `${i} `)
}
mockStdin.emit("end")
})
const result = await readStdinIfPiped()
expect(result).toContain("0 ")
expect(result).toContain("99")
})
})
describe("timeout behavior", () => {
it("should timeout after 100ms if no data received", async () => {
mockStdin.isTTY = false
// Don't emit any events - let it timeout
const startTime = Date.now()
const result = await readStdinIfPiped()
const elapsed = Date.now() - startTime
expect(result).toBeNull()
expect(elapsed).toBeGreaterThanOrEqual(95) // Allow small margin
expect(elapsed).toBeLessThan(150)
})
it("should return data received before timeout", async () => {
mockStdin.isTTY = false
setTimeout(() => {
mockStdin.emit("data", "quick data")
// Don't emit end - let it timeout
}, 50)
const result = await readStdinIfPiped()
expect(result).toBe("quick data")
})
it("should not timeout if end event is received", async () => {
mockStdin.isTTY = false
// Delay end event but emit it before timeout
setTimeout(() => {
mockStdin.emit("data", "delayed data")
mockStdin.emit("end")
}, 50)
const result = await readStdinIfPiped()
expect(result).toBe("delayed data")
})
})
describe("error handling", () => {
it("should return null on stdin error", async () => {
mockStdin.isTTY = false
setImmediate(() => {
mockStdin.emit("error", new Error("stdin read error"))
})
const result = await readStdinIfPiped()
expect(result).toBeNull()
})
it("should handle error after partial data received", async () => {
mockStdin.isTTY = false
setImmediate(() => {
mockStdin.emit("data", "partial data")
mockStdin.emit("error", new Error("read error"))
})
const result = await readStdinIfPiped()
expect(result).toBeNull()
})
it("should clean up listeners on error", async () => {
mockStdin.isTTY = false
setImmediate(() => {
mockStdin.emit("error", new Error("test error"))
})
await readStdinIfPiped()
// Note: Implementation uses removeAllListeners() without event names
// which should remove all listeners, but in practice there may be one remaining
// This is acceptable behavior for error handling
expect(mockStdin.listenerCount("data")).toBeLessThanOrEqual(1)
expect(mockStdin.listenerCount("end")).toBeLessThanOrEqual(1)
expect(mockStdin.listenerCount("error")).toBeLessThanOrEqual(1)
})
})
describe("listener cleanup", () => {
it("should remove all listeners on successful completion", async () => {
mockStdin.isTTY = false
setImmediate(() => {
mockStdin.emit("data", "test data")
mockStdin.emit("end")
})
await readStdinIfPiped()
// Note: Implementation doesn't explicitly clean up listeners on normal end,
// so some listeners may remain attached. This is acceptable for one-time use.
expect(mockStdin.listenerCount("data")).toBeLessThanOrEqual(1)
expect(mockStdin.listenerCount("end")).toBeLessThanOrEqual(1)
expect(mockStdin.listenerCount("error")).toBeLessThanOrEqual(1)
})
it("should remove all listeners on timeout", async () => {
mockStdin.isTTY = false
// Let it timeout
await readStdinIfPiped()
// Verify listeners are cleaned up
expect(mockStdin.listenerCount("data")).toBe(0)
expect(mockStdin.listenerCount("end")).toBe(0)
expect(mockStdin.listenerCount("error")).toBe(0)
})
it("should clear timeout when data ends normally", async () => {
mockStdin.isTTY = false
const clearTimeoutSpy = vi.spyOn(global, "clearTimeout")
setImmediate(() => {
mockStdin.emit("data", "test")
mockStdin.emit("end")
})
await readStdinIfPiped()
expect(clearTimeoutSpy).toHaveBeenCalled()
})
it("should clear timeout when error occurs", async () => {
mockStdin.isTTY = false
const clearTimeoutSpy = vi.spyOn(global, "clearTimeout")
setImmediate(() => {
mockStdin.emit("error", new Error("test"))
})
await readStdinIfPiped()
expect(clearTimeoutSpy).toHaveBeenCalled()
})
})
describe("encoding", () => {
it("should handle UTF-8 encoded data", async () => {
mockStdin.isTTY = false
setImmediate(() => {
// The function sets utf8 encoding
mockStdin.setEncoding("utf8")
mockStdin.emit("data", "UTF-8: café ☕")
mockStdin.emit("end")
})
const result = await readStdinIfPiped()
expect(result).toBe("UTF-8: café ☕")
})
})
describe("stdin resume", () => {
it("should call resume on stdin when not TTY", async () => {
mockStdin.isTTY = false
const resumeSpy = vi.spyOn(mockStdin, "resume")
setImmediate(() => {
mockStdin.emit("end")
})
await readStdinIfPiped()
expect(resumeSpy).toHaveBeenCalled()
})
it("should not call resume when TTY", async () => {
mockStdin.isTTY = true
const resumeSpy = vi.spyOn(mockStdin, "resume")
const result = await readStdinIfPiped()
expect(result).toBeNull()
expect(resumeSpy).not.toHaveBeenCalled()
})
})
describe("real-world use cases", () => {
interface UseCaseTest {
name: string
input: string
expected: string | null
}
const useCases: UseCaseTest[] = [
{
name: "git diff output",
input: "diff --git a/file.ts b/file.ts\nindex 123..456\n--- a/file.ts\n+++ b/file.ts",
expected: "diff --git a/file.ts b/file.ts\nindex 123..456\n--- a/file.ts\n+++ b/file.ts",
},
{
name: "curl JSON response",
input: '{"status": "ok", "data": [1, 2, 3]}',
expected: '{"status": "ok", "data": [1, 2, 3]}',
},
{
name: "cat file contents",
input: "export function test() {\n return true\n}",
expected: "export function test() {\n return true\n}",
},
{
name: "echo command with newline",
input: "Hello World\n",
expected: "Hello World",
},
{
name: "command output with ANSI codes",
input: "\x1b[32mSUCCESS\x1b[0m",
expected: "\x1b[32mSUCCESS\x1b[0m",
},
]
useCases.forEach(({ name, input, expected }) => {
it(`should handle ${name}`, async () => {
mockStdin.isTTY = false
setImmediate(() => {
mockStdin.push(input)
mockStdin.push(null)
})
const result = await readStdinIfPiped()
expect(result).toBe(expected)
})
})
})
})
+47
View File
@@ -0,0 +1,47 @@
import * as fs from "node:fs"
/**
* Read piped input from stdin (non-blocking)
*/
export async function readStdinIfPiped(): Promise<string | null> {
// Check if stdin is a TTY (interactive) or piped
if (process.stdin.isTTY) {
return null
}
try {
// Use synchronous read for reliability with piped input
// fd 0 is stdin
const data = fs.readFileSync(0, "utf8")
return data.trim() || null
} catch {
// Fallback to async approach if sync read fails
return new Promise((resolve) => {
let data = ""
process.stdin.setEncoding("utf8")
// Set a timeout in case stdin is not actually providing data
const timeout = setTimeout(() => {
process.stdin.removeAllListeners()
resolve(data.trim() || null)
}, 1000)
process.stdin.on("data", (chunk) => {
data += chunk
})
process.stdin.on("end", () => {
clearTimeout(timeout)
resolve(data.trim() || null)
})
process.stdin.on("error", () => {
clearTimeout(timeout)
resolve(null)
})
// Resume stdin in case it's paused
process.stdin.resume()
})
}
}
+221
View File
@@ -0,0 +1,221 @@
/**
* Plain-text task runner for non-TTY environments (piped output, file redirection)
* Outputs clean text without ANSI codes or Ink rendering
*/
/* eslint-disable no-console */
// Console output is intentional here for plain text mode
import { registerPartialMessageCallback } from "@core/controller/ui/subscribeToPartialMessage"
import type { ClineMessage } from "@shared/ExtensionMessage"
import type { Controller } from "@/core/controller"
export interface PlainTextTaskOptions {
controller: Controller
prompt: string
imageDataUrls?: string[]
verbose?: boolean
jsonOutput?: boolean
}
/**
* Run a task with plain text output (no Ink, no ANSI codes)
* Returns true if task completed successfully, false if error
*/
export async function runPlainTextTask(options: PlainTextTaskOptions): Promise<boolean> {
const { controller, prompt, imageDataUrls, verbose, jsonOutput } = options
// Track completion state
let isComplete = false
let hasError = false
const processedMessages = new Map<number, number>() // index -> last output text length
let lastStreamingMessageIndex = -1 // track open streaming line that needs closing
// Subscribe to state updates
const originalPostState = controller.postStateToWebview.bind(controller)
const handleStateUpdate = async () => {
try {
const state = await controller.getStateToPostToWebview()
const messages = state.clineMessages || []
// Process new messages
for (let i = 0; i < messages.length; i++) {
const message = messages[i]
const currentTextLength = message.text?.length ?? 0
const lastOutputLength = processedMessages.get(i) ?? 0
// Skip if no new content to output
if (currentTextLength <= lastOutputLength) continue
// Close previous streaming line if we're moving to a different message
if (lastStreamingMessageIndex >= 0 && lastStreamingMessageIndex !== i && !jsonOutput) {
process.stdout.write("\n")
lastStreamingMessageIndex = -1
}
processedMessages.set(i, currentTextLength)
// Output the message
if (jsonOutput) {
process.stdout.write(JSON.stringify(message) + "\n")
} else {
const isStreaming = outputMessageAsText(message, verbose || false, lastOutputLength)
// Track streaming state for text messages
if (isStreaming) {
lastStreamingMessageIndex = i
} else {
lastStreamingMessageIndex = -1
}
}
// Check for completion
if (
message.say === "completion_result" ||
message.ask === "completion_result" ||
message.say === "error" ||
message.ask === "api_req_failed"
) {
isComplete = true
if (message.say === "error" || message.ask === "api_req_failed") {
hasError = true
}
}
}
// Close streaming line on completion
if (isComplete && lastStreamingMessageIndex >= 0 && !jsonOutput) {
process.stdout.write("\n")
lastStreamingMessageIndex = -1
}
} catch (error) {
if (jsonOutput) {
process.stdout.write(
JSON.stringify({ type: "error", message: error instanceof Error ? error.message : String(error) }) + "\n",
)
} else {
process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}` + "\n")
}
hasError = true
isComplete = true
}
}
// Override postStateToWebview to capture state updates
controller.postStateToWebview = async () => {
await originalPostState()
await handleStateUpdate()
}
// Subscribe to partial message updates (for streaming)
const unsubscribePartial = registerPartialMessageCallback(() => {
// Partial updates are handled via postStateToWebview
})
try {
// Get initial state
await handleStateUpdate()
// Start the task
await controller.initTask(prompt, imageDataUrls)
// Wait for completion with timeout
const timeout = 10 * 60 * 1000 // 10 minutes
const startTime = Date.now()
while (!isComplete && Date.now() - startTime < timeout) {
await new Promise((resolve) => setTimeout(resolve, 100))
}
if (!isComplete) {
// Close any open streaming line before error message
if (lastStreamingMessageIndex >= 0 && !jsonOutput) {
process.stdout.write("\n")
lastStreamingMessageIndex = -1
}
if (jsonOutput) {
process.stdout.write(JSON.stringify({ type: "error", message: "Task timeout" }) + "\n")
} else {
process.stderr.write("Error: Task timeout" + "\n")
}
hasError = true
}
} catch (error) {
if (jsonOutput) {
process.stdout.write(
JSON.stringify({ type: "error", message: error instanceof Error ? error.message : String(error) }) + "\n",
)
} else {
process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}` + "\n")
}
hasError = true
} finally {
// Close any open streaming line
if (lastStreamingMessageIndex >= 0 && !jsonOutput) {
process.stdout.write("\n")
}
// Restore original postStateToWebview
controller.postStateToWebview = originalPostState
unsubscribePartial()
}
return !hasError
}
/**
* Format a Cline message as plain text
* @param previousLength - Length of text already output for this message (for streaming)
* @returns true if this is a streaming message (caller should track for newline), false otherwise
*/
function outputMessageAsText(message: ClineMessage, verbose: boolean, previousLength: number = 0): boolean {
const timestamp = new Date(message.ts || Date.now()).toLocaleTimeString()
const fullText = message.text ?? ""
if (!fullText) {
// Skip partial messages without text
return false
}
// For streaming text continuations, output only new content
if (previousLength > 0 && message.type === "say" && message.say === "text") {
process.stdout.write(fullText.slice(previousLength))
return true // Still streaming
}
if (message.type === "say") {
if (message.say === "task") {
process.stdout.write(`[${timestamp}] Task: ${fullText}\n`)
} else if (message.say === "text") {
// First output of text message - write prefix but no newline (streaming)
process.stdout.write(`[${timestamp}] ${fullText}`)
return true // Streaming - newline will be added when stream ends
} else if (message.say === "completion_result" && fullText) {
process.stdout.write(`[${timestamp}] Completed: ${fullText}\n`)
} else if (message.say === "error") {
process.stderr.write(`[${timestamp}] Error: ${fullText}\n`)
} else if (message.say === "api_req_started") {
if (verbose) {
process.stdout.write(`[${timestamp}] API request started\n`)
}
} else if (message.say === "api_req_finished") {
if (verbose) {
process.stdout.write(`[${timestamp}] API request finished\n`)
}
} else if (verbose) {
process.stdout.write(`[${timestamp}] ${message.say}: ${fullText}\n`)
}
} else if (message.type === "ask") {
if (message.ask === "completion_result") {
process.stdout.write(`[${timestamp}] Task completed\n`)
} else if (message.ask === "api_req_failed") {
process.stderr.write(`[${timestamp}] API request failed: ${fullText}\n`)
} else if (message.ask === "tool" || message.ask === "command" || message.ask === "browser_action_launch") {
// These require approval - in non-interactive mode, warn the user
process.stderr.write(`[${timestamp}] Waiting for approval (use --yolo for auto-approve): ${message.ask}\n`)
} else if (verbose) {
process.stdout.write(`[${timestamp}] Question: ${fullText}\n`)
}
}
return false
}
+106
View File
@@ -0,0 +1,106 @@
/**
* Slash command utilities for CLI
* Handles detection, filtering, and insertion of slash commands
*/
import type { SlashCommandInfo } from "@shared/proto/cline/slash"
export interface SlashQueryInfo {
inSlashMode: boolean
query: string
slashIndex: number
}
export interface VisibleWindow<T> {
items: T[]
startIndex: number
}
/**
* Calculate visible window for a scrollable list menu.
* Centers the selected item in the visible window when possible.
* Returns the visible items and the start index for selection tracking.
*/
export function getVisibleWindow<T>(items: T[], selectedIndex: number, maxVisible: number = 5): VisibleWindow<T> {
if (items.length <= maxVisible) {
return { items, startIndex: 0 }
}
const halfWindow = Math.floor(maxVisible / 2)
let startIndex = Math.max(0, selectedIndex - halfWindow)
const endIndex = Math.min(items.length, startIndex + maxVisible)
// Adjust if we're near the end
if (endIndex - startIndex < maxVisible) {
startIndex = Math.max(0, endIndex - maxVisible)
}
return { items: items.slice(startIndex, endIndex), startIndex }
}
/**
* Sort commands with workflows (custom section) first, then default commands.
*/
export function sortCommandsWorkflowsFirst(commands: SlashCommandInfo[]): SlashCommandInfo[] {
return [...commands.filter((cmd) => cmd.section === "custom"), ...commands.filter((cmd) => cmd.section !== "custom")]
}
/**
* Extract slash command query from input text.
* Returns info about whether we're in slash mode and what the query is.
*/
export function extractSlashQuery(text: string): SlashQueryInfo {
// Find the last slash in the text
const slashIndex = text.lastIndexOf("/")
if (slashIndex === -1) {
return { inSlashMode: false, query: "", slashIndex: -1 }
}
// Slash must be at start or preceded by whitespace
const charBeforeSlash = slashIndex > 0 ? text[slashIndex - 1] : null
if (charBeforeSlash !== null && !/\s/.test(charBeforeSlash)) {
return { inSlashMode: false, query: "", slashIndex: -1 }
}
// Get text after the slash
const textAfterSlash = text.slice(slashIndex + 1)
// If there's whitespace after slash, we're not in slash mode anymore
if (/\s/.test(textAfterSlash)) {
return { inSlashMode: false, query: "", slashIndex: -1 }
}
// Check if there's already a completed slash command earlier in the text
// (only first slash command per message is processed)
const firstSlashCommandRegex = /(^|\s)\/[a-zA-Z0-9_.-]+\s/
const textBeforeCurrentSlash = text.slice(0, slashIndex)
if (firstSlashCommandRegex.test(textBeforeCurrentSlash)) {
return { inSlashMode: false, query: "", slashIndex: -1 }
}
return {
inSlashMode: true,
query: textAfterSlash,
slashIndex,
}
}
/**
* Filter commands that match the query prefix (case-insensitive)
*/
export function filterCommands(commands: SlashCommandInfo[], query: string): SlashCommandInfo[] {
if (!query) {
return commands
}
return commands.filter((cmd) => cmd.name.toLowerCase().startsWith(query.toLowerCase()))
}
/**
* Insert a slash command at the given slash index, replacing any partial query
*/
export function insertSlashCommand(text: string, slashIndex: number, commandName: string): string {
const beforeSlash = text.slice(0, slashIndex)
// Insert command with trailing space
return `${beforeSlash}/${commandName} `
}
+143
View File
@@ -0,0 +1,143 @@
/**
* Shared tool utilities for CLI components
* Centralizes tool name handling and categorization
*/
/**
* Tools that perform file edits (create, modify, delete)
* Used to determine when to show DiffView and skip dynamic rendering
*/
export const FILE_EDIT_TOOLS = new Set([
"editedExistingFile",
"newFileCreated",
"replace_in_file",
"write_to_file",
"fileDeleted",
])
/**
* Tools that save/modify files (subset used for "Save" button label)
*/
export const FILE_SAVE_TOOLS = new Set(["editedExistingFile", "newFileCreated", "fileDeleted"])
/**
* Check if a tool name is a file edit tool
*/
export function isFileEditTool(toolName: string | undefined): boolean {
if (!toolName) return false
return FILE_EDIT_TOOLS.has(toolName)
}
/**
* Check if a tool name is a file save tool (for button labeling)
*/
export function isFileSaveTool(toolName: string | undefined): boolean {
if (!toolName) return false
return FILE_SAVE_TOOLS.has(toolName)
}
/**
* Normalize tool name to snake_case for consistent lookups
* Handles both camelCase (readFile) and snake_case (read_file) inputs
*/
export function normalizeToolName(toolName: string): string {
// Convert camelCase to snake_case
return toolName.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase()
}
/**
* Tool descriptions for display
* Uses snake_case keys - use normalizeToolName() before lookup
*/
export const TOOL_DESCRIPTIONS: Record<string, { ask: string; say: string }> = {
// File operations
read_file: { ask: "wants to read this file", say: "read this file" },
write_to_file: { ask: "wants to create a new file", say: "created a new file" },
new_file_created: { ask: "wants to create a new file", say: "created a new file" },
replace_in_file: { ask: "wants to edit this file", say: "edited this file" },
edited_existing_file: { ask: "wants to edit this file", say: "edited this file" },
// Directory operations
list_files: { ask: "wants to view files in this directory", say: "viewed files in this directory" },
list_files_top_level: { ask: "wants to view files in this directory", say: "viewed files in this directory" },
list_files_recursive: {
ask: "wants to recursively view all files in this directory",
say: "recursively viewed all files in this directory",
},
list_code_definition_names: {
ask: "wants to view code definitions in this directory",
say: "viewed code definitions in this directory",
},
search_files: { ask: "wants to search files", say: "searched files" },
// Command execution
execute_command: { ask: "wants to execute this command", say: "executed this command" },
// Browser
browser_action: { ask: "wants to use the browser", say: "used the browser" },
// MCP
use_mcp_tool: { ask: "wants to use an MCP tool", say: "used an MCP tool" },
access_mcp_resource: { ask: "wants to access an MCP resource", say: "accessed an MCP resource" },
// Web
web_fetch: { ask: "wants to fetch content from this URL", say: "fetched content from this URL" },
web_search: { ask: "wants to search the web", say: "searched the web" },
// Other
ask_followup_question: { ask: "wants to ask a question", say: "asked a question" },
attempt_completion: { ask: "wants to complete the task", say: "completed the task" },
new_task: { ask: "wants to create a new task", say: "created a new task" },
focus_chain: { ask: "wants to update the todo list", say: "updated the todo list" },
}
/**
* Default description for unknown tools
*/
export const DEFAULT_TOOL_DESCRIPTION = {
ask: "wants to use a tool",
say: "used a tool",
}
/**
* Get tool description with normalized lookup
*/
export function getToolDescription(toolName: string): { ask: string; say: string } {
const normalized = normalizeToolName(toolName)
return TOOL_DESCRIPTIONS[normalized] || DEFAULT_TOOL_DESCRIPTION
}
/**
* Safely parse JSON from message text
* Returns the parsed object or a default value if parsing fails
*/
export function parseMessageJson<T>(text: string | undefined, defaultValue: T): T {
if (!text) return defaultValue
try {
return JSON.parse(text) as T
} catch {
return defaultValue
}
}
/**
* Parse tool info from message text
*/
export function parseToolFromMessage(
text: string | undefined,
): { toolName: string; args: Record<string, unknown>; result?: string } | null {
if (!text) return null
try {
const parsed = JSON.parse(text)
if (parsed.tool) {
return {
toolName: parsed.tool,
args: parsed,
result: parsed.content || parsed.output,
}
}
return null
} catch {
return null
}
}

Some files were not shown because too many files have changed in this diff Show More