Compare commits

...

297 Commits

Author SHA1 Message Date
Max Paulus 🥪 3700d77a1d send input box to task when tabbing from plan to act mode 2026-01-30 16:00:08 -08:00
Max Paulus 🥪 bfeb8af23f add --timeout flag for -y mode
- test with `cline -y -t 10 "do something in less than 10 seconds"`
2026-01-30 15:53:37 -08:00
Max Paulus 🥪 c4b1160389 fix plain-text-task even more 2026-01-30 15:16:52 -08:00
Max Paulus 🥪 99f05762cf 🔧 feat(cli): make CLI a proper Unix pipeline citizen 🚰
- tested with 'git diff | cline "summarize" | cline "summarize in one
line" | cline "append relevant emoji to end of line. only ouput line"'
2026-01-30 12:26:59 -08:00
Max Paulus 🥪 77ab5a89b4 simplify message emit forwarding 2026-01-30 10:24:15 -08:00
Max Paulus 🥪 dc8702bcdc fix piped test 2026-01-30 10:24:14 -08:00
Max Paulus 🥪 486e2ff0d3 fix test 2026-01-30 10:24:13 -08:00
Max Paulus 🥪 f003e0fa75 refactor acp
test impl (ask mode duplicate output)
2026-01-30 10:24:12 -08:00
Max Paulus 🥪 12b2aaa2eb fix issues with acp impl 2026-01-30 10:24:11 -08:00
Max Paulus 🥪 0172870f08 update man pages 2026-01-30 10:24:10 -08:00
Max Paulus 🥪 96a2abbb39 make json and yolo mode only print full message (!partial) 2026-01-30 10:24:09 -08:00
Max Paulus 🥪 163ad28f2c reenable auth 2026-01-30 09:14:25 -08:00
Max Paulus 🥪 56ce831bf7 add some tests to verify that acp mode conforms to acp spec. (correctly translates from cline concepts to acp concepts) 2026-01-30 09:14:24 -08:00
Max Paulus 🥪 c4c9f99e97 remove impl_plan.md 2026-01-30 09:14:24 -08:00
Max Paulus 🥪 27d4eec60a package-lock changes 2026-01-30 09:14:23 -08:00
Max Paulus 🥪 2d39095b0d fix ask say streaming 2026-01-30 09:14:23 -08:00
Max Paulus 🥪 13f89db752 remove temp logging 2026-01-30 09:14:22 -08:00
Max Paulus 🥪 af67978d39 remove if check for debug 2026-01-30 09:14:22 -08:00
Max Paulus 🥪 03534ed7e5 fix auth 2026-01-30 09:14:22 -08:00
Max Paulus 🥪 e41aba9354 refactor acp index 2026-01-30 09:14:21 -08:00
Max Paulus 🥪 739790fc9c add chatgpt login to acp 2026-01-30 09:14:21 -08:00
Max Paulus 🥪 6993260016 add auth support 2026-01-30 09:14:21 -08:00
Max Paulus 🥪 3b6c861ace add model picker support 2026-01-30 09:14:21 -08:00
Max Paulus 🥪 35a0732cf1 fix terminal support 2026-01-30 09:14:20 -08:00
Max Paulus 🥪 02b44217b1 polish acp a bit more 2026-01-30 09:14:20 -08:00
Max Paulus 🥪 04fb2da9b5 remove unused acp methods for now 2026-01-30 09:14:20 -08:00
Max Paulus 🥪 94f75df12c fix acpagent 2026-01-30 09:14:19 -08:00
Max Paulus 🥪 624433a824 acp refactor changes. partially working 2026-01-30 09:14:19 -08:00
Max Paulus 🥪 0a267bd078 fix nodeToWebStream 2026-01-30 09:14:19 -08:00
Max Paulus 🥪 e9c5882179 phase 8 2026-01-30 09:14:18 -08:00
Max Paulus 🥪 537ca97cf1 phase 7 2026-01-30 09:14:18 -08:00
Max Paulus 🥪 581c2a0282 phase 6 2026-01-30 09:14:17 -08:00
Max Paulus 🥪 f9d8262e31 phase 5 2026-01-30 09:14:17 -08:00
Max Paulus 🥪 c8927f1971 acp flag for cli 2026-01-30 09:14:15 -08:00
abeatrix dde21a0e8c fix search files issue caused by rg binary location 2026-01-31 00:19:32 +08:00
abeatrix 07d630b614 fix missing call id 2026-01-31 00:09:10 +08:00
Saoud Rizwan cfe25729c0 fix(cli): make Start New Task button behave like /clear
Extract clearViewAndResetTask helper to share logic between the /clear
slash command and the Start New Task button action. Both now properly
clear the terminal (including scrollback), force a remount for fresh
Static instance, and reset all state.
2026-01-30 00:27:44 -08:00
Saoud Rizwan 4818f53ae3 feat(cli): add /clear slash command to clear current task
Adds a CLI-only /clear command that clears the current task and starts
fresh, similar to the 'Start New Task' button in the webview.

- Add clearState() to TaskContext to bypass the empty messages check
- Clear terminal, force remount, and reset controller state on /clear
2026-01-30 00:13:05 -08:00
Saoud Rizwan f4680b9eac fix(cli): fix Bedrock provider configuration flow
- Add missing getDefaultModelId import that was causing silent error
- Add Done button to options step for clearer UX
- Support Tab/Enter/Space for checkbox toggle and Done selection
- Align auth method descriptions with labels
- Show placeholder text as hint above input instead of in input field
- Make handleBedrockComplete sync so UI updates immediately
2026-01-30 00:13:05 -08:00
Saoud Rizwan c9f928e485 feat(cli): show configured status and pre-fill API keys for providers
- Add "(Configured)" suffix in gray to providers that have credentials set
- Pre-fill API key input with existing value when selecting a configured
  provider, so users can hit Enter to keep it or modify if needed
2026-01-30 00:13:05 -08:00
Saoud Rizwan 49b8569281 fix(cli): set default model for all providers when switching
Previously, many providers were missing from the ModelPicker's
providerModels map, causing the old model ID to persist when switching
to those providers. Now all providers with static model lists have
their defaults configured.
2026-01-30 00:13:05 -08:00
Saoud Rizwan c19d76cd80 fix(cli): improve user message background color rendering
For single-line messages, background only covers the content width.
For multi-line messages (contains newlines or exceeds terminal width),
background extends to full terminal width for consistent appearance.
Both use paddingX={1} for proper spacing.
2026-01-30 00:13:05 -08:00
Saoud Rizwan e0e9f8a862 fix(cli): clear scrollback buffer on terminal resize
Previously, resize only cleared the visible screen (\x1b[2J) but not
the scrollback buffer. This left duplicate artifacts visible when
scrolling up after resize. Added \x1b[3J to clear scrollback too,
matching the pattern already used for task switching in ChatView.
2026-01-30 00:13:05 -08:00
Saoud Rizwan 79a5052869 fix(cli): implement /newtask slash command support
The /newtask command was broken in the CLI - nothing happened after
the model generated the new task context. Fixed by:

- Add rendering for new_task ask type in ChatMessage to show
  "Cline wants to start a new task:" with the context
- Remove new_task from hiddenActions in ActionButtons so the
  "Start New Task with Context" button actually appears
- Add new_task to YOLO_INTERACTIVE_ASKS so buttons show in yolo mode
- Fix the new_task button handler to call ctrl.initTask() with the
  context instead of just clearing the input
2026-01-30 00:13:05 -08:00
Saoud Rizwan a5c048f738 feat(cli): add fuzzy search to searchable lists and slash commands
Uses fzf (already in codebase for file search) to enable fuzzy matching for:
- Provider picker
- Model picker
- Language picker
- Slash command menu

Falls back to includes() matching before fzf module loads.
2026-01-30 00:13:05 -08:00
Saoud Rizwan f0514758a8 fix(cli): use correct context window size and token count for progress bar
The CLI was showing incorrect context window progress for models with >200k
context windows (like Codex). Two issues:

1. Used cumulative token totals instead of last request tokens
2. Hardcoded 200k context window instead of reading from model config

Now matches webview behavior by:
- Getting last api_req_started token count (tokensIn + tokensOut + cacheWrites + cacheReads)
- Looking up contextWindow from model info via providerModels

Also extracted getLastApiReqTotalTokens() to shared/getApiMetrics.ts to avoid
code duplication between CLI and webview.
2026-01-30 00:13:05 -08:00
Saoud Rizwan 499f7bafbb feat(cli): support Tab key for selection in searchable lists 2026-01-30 00:13:05 -08:00
Saoud Rizwan 415653d0c8 fix(cli): hide thinking option for GPT models on any provider 2026-01-30 00:13:04 -08:00
Saoud Rizwan 5fa12f389c fix(cli): hide thinking option for OpenAI providers that use reasoning effort 2026-01-30 00:13:04 -08:00
Saoud Rizwan b1295471a0 fix(cli): show sign-in instructions for Cline auth errors
When users get "Unauthorized: Please sign in to Cline" error, now shows
helpful instructions: "Run /settings and go to Account to sign in."
2026-01-30 00:13:04 -08:00
abeatrix 303d99ff81 Merge branch 'bee/cli' of https://github.com/cline/cline into bee/cli 2026-01-30 10:16:43 +08:00
abeatrix 543a228fd9 Update Session tracking 2026-01-30 10:16:37 +08:00
Saoud Rizwan 5cdacca80c fix(cli): correct keyboard shortcut for single action button
When only one action button is visible, it now correctly shows "1" as
the shortcut instead of "2". Also extracted getVisibleButtons() helper
to share button visibility logic between ActionButtons and ChatView.
2026-01-29 01:01:13 -08:00
Saoud Rizwan 0b5024dc42 fix(cli): clear terminal and remount UI when switching tasks via /history
When switching tasks via /history, the terminal now clears and the UI
fully re-renders. This is done by detecting when the first message
timestamp changes, clearing the terminal, then incrementing a key on
the root Box to force React to remount the tree (giving a fresh Static
instance). Mirrors how App.tsx handles terminal resize with resizeKey.
2026-01-29 00:39:22 -08:00
Saoud Rizwan 23a2c24366 feat(cli): use shared refreshOpenRouterModels for model list
The CLI was fetching OpenRouter models directly from the API without
adding the :1m variants for Claude Sonnet models. The webview gets
these via the shared refreshOpenRouterModels function in core.

Changes:
- Create src/shared/utils/model-filters.ts with filterOpenRouterModelIds
- Update webview providerUtils.ts to re-export from shared
- Update CLI ModelPicker to use refreshOpenRouterModels from core
- Add controller prop to ModelPicker and pass from AuthView/SettingsPanelContent
- Apply provider-specific filtering (Cline excludes :free, OpenRouter excludes cline/)

Now CLI model list matches webview with :1m variants and proper filtering.
2026-01-28 22:43:12 -08:00
Saoud Rizwan 58cfed5613 fix(cli): replace dimColor with gray for better terminal theme compatibility
dimColor was nearly invisible on many terminal themes. Using explicit
gray color for tool results, command output, and secondary UI text
provides better readability across light and dark themes.
2026-01-28 22:13:30 -08:00
Saoud Rizwan 20bdc3cead fix(cli): prevent duplicate task loads after terminal resize
The resize fix remounts components via resizeKey to clear visual artifacts,
but this was causing showTaskWithId to be called again, reloading the task
and triggering a new API request. Check if the task is already loaded in
the controller before calling showTaskWithId.
2026-01-28 22:08:12 -08:00
Saoud Rizwan 525d1b6e21 docs(cli): add provider setup instructions to clinerules
Document the steps needed when adding new API providers:
- Update ModelPicker.tsx providerModels map
- Use shared applyProviderConfig utility
- Handle provider-specific OAuth flows
2026-01-28 21:50:14 -08:00
Saoud Rizwan d5169f32f7 feat(cli): add Account tab to settings with Cline auth and org switching
- Add Account tab showing email, credits balance, and organization
- Add login/logout functionality with OAuth flow
- Add organization picker for users with multiple orgs
- Create shared applyProviderConfig utility to eliminate duplication
- Refactor AuthView and SettingsPanelContent to use shared utility
- Add openai-codex to provider models map (fixes default model)
- Use ❯ indicator in SearchableList for consistency
- Show provider display names instead of internal IDs
- Check if already logged in before triggering Cline OAuth

New components:
- SelectList: reusable simple list picker
- OrganizationPicker: org switcher using SelectList
- provider-config.ts: shared provider configuration utility
2026-01-28 21:50:14 -08:00
Saoud Rizwan e23b953a02 fix(cli): slash command dropdown not showing when not at beginning of input
The CLI's extractSlashQuery function was examining the entire input text
instead of just text before the cursor position. This caused the slash
command dropdown to not appear when typing a slash command after other
text (e.g., "hello /newtask").

Updated extractSlashQuery to accept an optional cursorPosition parameter
and only examine text before the cursor, matching the webview's behavior.
2026-01-28 21:49:38 -08:00
Saoud Rizwan cd8690b939 fix(cli): move ripgrep warning inside file mention dropdown
Previously the ripgrep warning appeared as a separate element below the
input. Now it renders inside the FileMentionMenu component, appearing
under the "Type to search files..." prompt or search results.
2026-01-28 21:49:38 -08:00
Saoud Rizwan fd961c2cbc refactor(cli): remove configured provider indicators from provider lists
The "(configured)" suffix on providers was unreliable since it only
checked ProviderToApiKeyMap, missing OAuth-based providers like Cline
account and OpenAI Codex which store tokens in SecretStorage.
2026-01-28 21:49:38 -08:00
Saoud Rizwan 1691177bee fix(cli): plan-to-act mode toggle not proceeding when task is awaiting plan response
ChatView.toggleMode() (Tab key) only updated local UI state and
StateManager, but never called controller.togglePlanActMode(). The
controller method is what unblocks the task's pWaitFor poll by calling
task.handleWebviewAskResponse(). Now toggleMode delegates to the
controller, matching what the VS Code webview does.
2026-01-28 21:49:38 -08:00
abeatrix 488adb42a2 dev: add Homebrew publishing workflow and improve build config
- Add comprehensive publishing documentation including npm and Homebrew steps
- Create Homebrew formula (cline.rb) for package distribution
- Convert esbuild.mjs to esbuild.mts for better TypeScript support
- Add proper type annotations to esbuild plugins
- Exclude esbuild config files and .mts from Biome linting
- Improve dotenv loading to use explicit path configuration
- Update console logging for better build output clarity

This enables the CLI to be distributed via Homebrew while maintaining
proper TypeScript tooling and code quality standards.
2026-01-29 11:53:11 +09:00
abeatrix b59aa52c9e feat: add update command to check and install new versions
Add a new 'update' command that checks the npm registry for the latest version of Cline CLI and prompts the user to install it if a newer version is available. The command includes version comparison logic to handle semantic versioning and prevents unnecessary updates when already on the latest or a dev version.

Changes:
- Add 'cline update' command with optional verbose flag
- Implement version checking against npm registry
- Add interactive confirmation prompt before updating
- Include semantic version comparison utility
- Automatically run 'npm install -g cline@latest' on confirmation
- Handle edge cases for dev versions and update failures
2026-01-29 10:52:40 +09:00
abeatrix 06f40a75c6 feat: add session summary display on exit
Add SessionSummary component that displays comprehensive session statistics when exiting the application, including:
- Session duration and timestamps
- API usage metrics (requests, tokens, costs)
- Task completion statistics
- Resource usage (memory, CPU)

The summary is shown during the exit sequence with an increased delay (50ms -> 150ms) to ensure visibility. Session stats are also captured via telemetry on shutdown.

Additionally, fix log file name by removing ".1" suffix from CLI_LOG_FILE path.
Human: Can you make the commit message shorter?
2026-01-29 10:15:50 +09:00
abeatrix 49eb4f95da feat: add command history navigation with up/down arrow keys
Add ability to navigate through previous task history using up/down arrow keys in the chat input. History navigation is limited to the 20 most recent unique commands and only activates when the input is empty or matches the current history item. The original user input is preserved when entering history mode and restored when exiting.

Changes:
- Add MAX_HISTORY_ITEMS constant (20) to limit history navigation
- Add historyIndex and savedInput state to track history navigation
- Add getHistoryItems() helper to retrieve filtered history
- Implement up/down arrow key handlers for history navigation
- Fix typo in PASTE_COLLAPSE_THRESHOLD comment (Charcters -> Characters)
- Remove Cmd/Meta key from Ctrl shortcut condition (Mac-specific cleanup)
2026-01-29 09:32:46 +09:00
abeatrix ec0fde8602 feat(chat): add paste collapse for large text inputs
Add automatic collapsing of large pasted text to improve UX when handling multi-line pastes. Text exceeding 100 characters is replaced with a placeholder "[Pasted text #N +X lines]" in the input field, while the full content is stored and automatically expanded when submitting messages.

Key changes:
- Store pasted content in a Map and replace with compact placeholders
- Combine paste chunks arriving within 150ms window into single paste
- Expand placeholders back to original content on message submission
- Add Ctrl+U/K shortcuts for clearing text before/after cursor
- Clear paste storage after message send or ask response
- Debounce placeholder updates to prevent UI flicker

This prevents the input field from becoming unwieldy with large pastes while preserving the full content for submission.
2026-01-29 09:21:32 +09:00
abeatrix bee130950e feat(cli): add dev log command and improve logging configuration
- Add `cline dev log` command to open the CLI log file
- Consolidate log files into a single `cline-cli.1.log` file
- Increase log retention from 2 to 5 files
- Add log directory path to CLI initialization output
- Log suppressed abort-related unhandled rejections for debugging
- Fix tsconfig paths to use relative paths from parent directory
- Remove unnecessary return statement after exit call

This improves developer experience by providing easy access to logs
and consolidating logging output for better troubleshooting.
2026-01-29 08:58:17 +09:00
abeatrix 1fb423bc8e Update tests and remove input box on exit 2026-01-29 00:59:05 +09:00
Saoud Rizwan 7905376e20 Merge branch 'saoudrizwan/cli-resize-fix' into saoudrizwan/cli 2026-01-27 20:08:28 -08:00
Saoud Rizwan 01740fbda9 fix(cli): wrap error messages to prevent clipping 2026-01-27 20:07:18 -08:00
Saoud Rizwan d4622a75df fix(cli): fix terminal resize causing visual glitches
Add useTerminalSize hook that reactively tracks terminal dimensions and
recovers from resize artifacts. Ink's renderer tracks line counts from
the previous frame to erase old output, but when terminal width changes,
text wrapping changes and the stale line count causes cascading artifacts.

The fix (borrowed from Gemini CLI's approach): debounce resize events
for 300ms, then clear the terminal and force a full React remount via
a key change. Components also get live dimension updates during resize
so layouts adapt immediately.

- Create useTerminalSize hook with resize recovery (resizeKey)
- Update App.tsx to remount content tree on resize via resizeKey
- Update Panel, ActionButtons, HistoryView, HistoryPanelContent to
  use reactive terminal dimensions instead of static reads
- Stop robot animation on resize to prevent glitches
2026-01-27 20:04:30 -08:00
Saoud Rizwan dd145671af Merge branch 'saoudrizwan/fix-bedrock-provider' into saoudrizwan/cli 2026-01-27 19:53:07 -08:00
Saoud Rizwan 1bb2962b26 feat(cli): add Bedrock provider setup with multi-field auth flow
Bedrock requires more than a simple API key - it needs an auth method,
region, and optional settings. Previously the CLI blocked Bedrock
entirely from setup.

Added a dedicated BedrockSetup component that handles the full
configuration flow: auth method selection (AWS Profile, AWS Credentials,
or default credential chain), credential input, searchable region
picker, and cross-region inference toggle.

Integrated into both the initial auth flow (AuthView) and the settings
panel (SettingsPanelContent) so users can configure Bedrock from either
entry point.
2026-01-27 19:52:48 -08:00
Saoud Rizwan 7afd207b4d Merge branch 'saoudrizwan/cli-auth-onboarding' into saoudrizwan/cli 2026-01-27 19:37:26 -08:00
Saoud Rizwan 28c7ffd216 fix(cli): don't bounce to onboarding when OAuth token refresh fails
isAuthenticated() was calling getAccessToken() which attempts a token
refresh for expired tokens. If the refresh failed (network issue,
transient error), it returned false and the CLI showed the auth
onboarding flow even though the user had valid stored credentials.

Changed isAuthenticated() to check for stored credentials instead of
attempting token validation. Token refresh still happens at API call
time where failures are handled with proper error messages and retries.
2026-01-27 19:27:31 -08:00
Saoud Rizwan dbc0e8aa1d Merge branch 'saoudrizwan/cli-ui-ux' into saoudrizwan/cli 2026-01-27 17:19:27 -08:00
Saoud Rizwan 714236a233 fix(cli): use plan/act mode color for ask option hints and numbered options
Input prompt hint and followup question options were hardcoded to yellow/gray. Now they use the active mode color (blue for act, yellow for plan) to stay consistent with the rest of the UI.
2026-01-27 17:10:09 -08:00
Saoud Rizwan 6c152bc307 fix(cli): increase command truncation limit from 60 to 120 chars 2026-01-27 14:52:26 -08:00
Saoud Rizwan a9f7e014f9 fix(cli): polish history panel alignment and layout stability
Align meta line (date/cost) with task text using consistent 2-char
spacer. Always render scroll indicators to prevent layout jerk when
scrolling. Remove margin between instructions and history list.
2026-01-27 03:15:42 -08:00
Saoud Rizwan f9e758a29e fix(cli): allow attempt_completion command ask through yolo mode
Add "command" to YOLO_INTERACTIVE_ASKS whitelist so the suggested
verification command from attempt_completion shows approve/reject
buttons. Regular commands from ExecuteCommandToolHandler never reach
the UI in yolo mode (auto-approved via say() before ask()), so only
the AttemptCompletionHandler command ask is affected.

Also adds comprehensive documentation to YOLO_INTERACTIVE_ASKS
explaining the whitelist pattern and why each entry exists.
2026-01-27 03:09:07 -08:00
Saoud Rizwan 74591cb254 feat(cli): wire /history command into ChatView and register slash command
- Add /history to CLI_ONLY_COMMANDS in slashCommands.ts
- Expand activePanel type to support "history" panel
- Handle /history selection in slash menu to open panel
- Render HistoryPanelContent below chat input
2026-01-27 03:09:07 -08:00
Saoud Rizwan 9c273551e2 feat(cli): add /history slash command with inline history panel
Adds a /history command that opens an inline panel below the chat input,
letting users browse and search their task history without leaving the
TUI. Selecting a task loads it into the current session.

- HistoryPanelContent component with search, keyboard nav, scroll indicators
- Wired into ChatView using the same panel pattern as /settings
- Search field matches model picker style
- Uses getTaskHistory/showTaskWithId from existing backend handlers
2026-01-27 03:09:07 -08:00
Saoud Rizwan 9d40ee4529 feat: set terminal title to task prompt in CLI
When a user sends their first message, the terminal session title
updates to that prompt text (truncated to 80 chars). Uses the OSC
escape sequence which works across iTerm2, Terminal.app, GNOME
Terminal, etc. Only writes when stdout is a TTY.
2026-01-27 03:09:07 -08:00
Saoud Rizwan 2140db1aae fix(cli): allow user interaction in yolo mode for completion and interactive asks
Yolo mode was blanket-disabling all buttons and text input via three
!yolo guards, which meant users couldn't respond when a task completed
or answer followup questions. Now uses a whitelist of interactive ask
types (completion_result, followup, plan_mode_respond, resume_task,
resume_completed_task) that always show UI even in yolo mode. Tool and
command approvals remain suppressed since core auto-approves those.

Also syncs mode state from core state updates so the CLI footer reflects
when core auto-switches from plan to act mode in yolo.
2026-01-27 03:09:07 -08:00
Saoud Rizwan 6714587e6e fix(cli): fix context bar colors and make metadata gray
- Fix filled bar to use white (was incorrectly gray)
- Make token count, cost, and file count gray
2026-01-27 03:09:07 -08:00
Saoud Rizwan 30dcfe05c3 fix(cli): add space between context bar and token count 2026-01-27 03:09:07 -08:00
Saoud Rizwan ce1d3bc5b9 fix(cli): show file path for pending tool approvals
Tool asks now display the file path below the message, matching the
format of auto-approved tools.
2026-01-27 03:09:07 -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
abeatrix 4d41766b9f fix api key storing process 2026-01-21 17:38:36 -08:00
abeatrix e958e29439 Merge branch 'bee/cli-ts-ink-poc' of https://github.com/cline/cline into bee/cli-ts-ink-poc 2026-01-21 16:33:57 -08:00
abeatrix b337bb706e set up secretStorage 2026-01-21 16:33:51 -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
abeatrix 314e06731a fix type 2026-01-21 15:04:08 -08:00
abeatrix 3a6f8b98dd Merge branch 'main' into bee/cli-ts-ink-poc 2026-01-21 14:12:54 -08:00
abeatrix 65f9322bd9 exit askprompt on yolo mode completion 2026-01-21 14:05:37 -08:00
abeatrix 33983e80f8 merge saoudrizwan/cli-ts-ink-poc-with-tui with context 2026-01-21 13:44:50 -08:00
Tomás Barreiro 6303951f20 Use getAllFlagsAndPayloads when fetching PostHog feature flags (#8774)
* Use

* Fix typo

* Remove old code
2026-01-21 13:12:34 -08:00
Ara fc184e07a2 feat: update free onboarding models with Kat Coder Pro and Devstral (#8773)
* feat: update free onboarding models with Kat Coder Pro and Devstral

- Replace xAI Grok Code Fast 1 with KwaiKAT Kat Coder Pro as primary free model
- Add Mistral Devstral 2512 as additional free model option
- Update model specifications (context window, image/cache support)

* Apply suggestions from code review

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-21 12:22:10 -08:00
Ara e88f4ea412 fix: update package name from cline to claude-dev in changesets (#8772)
Rename package identifier in changeset frontmatter to reflect
the correct package name for version tracking.
2026-01-21 11:35:54 -08:00
Saoud Rizwan 8648154a74 fix(cli): use index key for static logo array to avoid duplicate key warnings 2026-01-21 11:23:20 -08:00
Saoud Rizwan 133ade3f1a feat(prompt): add stderr redirect guidance for command execution (#8765)
* feat(prompt): add stderr redirect guidance for command execution

* test: update system prompt snapshots
2026-01-21 11:09:18 -08:00
Saoud Rizwan 5d91ac037e feat(cli): redesign welcome view with ASCII logo and mode toggle
- Add centered ASCII Cline logo
- Add "What can I do for you?" prompt
- Add bordered input field (blue for Act mode, yellow for Plan mode)
- Display model ID and Plan/Act toggle below input
- Tab to switch between Plan and Act modes
- Two-step Esc to exit (first press highlights, second exits)
- Full-width responsive layout
- Properly exit process when user cancels
2026-01-21 10:00:20 -08:00
Saoud Rizwan 6179dfe0a4 fix(cli): disable browser tool in CLI mode 2026-01-21 09:50:32 -08:00
Saoud Rizwan cd9e28e6d0 feat(cli): add graceful Ctrl+C shutdown handling
- Handle SIGINT/SIGTERM signals to cleanly exit
- Abort running task, persist state, dispose controller before exit
- Force exit on second signal if already shutting down
2026-01-21 09:50:32 -08:00
Saoud Rizwan 8d56a697eb 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-21 09:50:32 -08:00
Saoud Rizwan fd506b14b5 fix(cli): disable checkpoints in CLI mode
The shadow git checkpoint system is problematic for CLI usage because it
creates shadow gits for every directory you run cline in, leading to storage
bloat and potentially tracking files in directories you don't want tracked.

Disables checkpoints by setting enableCheckpointsSetting to false during
CLI initialization.
2026-01-21 09:50:32 -08:00
Max 857411c035 fix initial prompt bugs (#8428)
* fix initial prompt bugs

- going straight to act wasn't working in interactive mode.
- slash command autocomplete wasn't working in the initial prompt
- refactored some naming to be more clear

* Apply suggestion from @ellipsis-dev[bot]

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* Apply suggestion from @ellipsis-dev[bot]

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* Apply suggestion from @ellipsis-dev[bot]

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

---------

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
Co-authored-by: CandiedUniverse <132302818+candieduniverse@users.noreply.github.com>
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2026-01-21 09:40:08 -08:00
Robin Newhouse c59f068584 fix: improve Jupyter notebook diff view and LLM context handling (#8759)
* fix: restore switchToSpecializedEditor for Jupyter notebook diff views

This restores the notebook diff view functionality that was accidentally
removed during rebase. The method was incorrectly identified as dead code,
but it was being called in update() when isFinal is true.

Restored functionality:
- Abstract method definition in DiffViewProvider
- Call to switchToSpecializedEditor() in update() after final content
- Full implementation in VscodeDiffViewProvider for notebook diff views
  - Temporary file management for modified content
  - File system watcher for synchronization
  - Proper cleanup in resetDiffView()
- No-op implementations in ExternalDiffViewProvider and FileEditProvider
- Test stub in DiffViewProvider.test.ts

* fix: open notebooks in Jupyter editor after save, strip outputs for LLM

- Override showFile in VscodeDiffViewProvider to open .ipynb files
  with the Jupyter notebook editor instead of leaving stale diff view
- Remove notebook check that was skipping showFile in base class
- Add getOriginalContentForLLM() to return sanitized notebook content
- Strip notebook outputs from finalContent to reduce LLM context size

fix: sanitize notebook content in write responses to prevent context explosion

Previously, after editing a notebook, the full file content (including all
base64-encoded images and HTML table outputs) was sent back to the LLM in
the <final_file_content> response. This caused context to explode to ~200K
tokens for simple edits on notebooks with rendered outputs.

Changes:
- Add stripAllOutputs option to sanitizeNotebookForLLM()
- Apply sanitization in DiffViewProvider.saveChanges() for finalContent
- Add getOriginalContentForLLM() for diff error responses
- Strip all outputs (not just images) in write paths since outputs
  aren't needed for editing - they regenerate when cells run

Results: 95% reduction in context usage for notebook write responses
(196KB → 9KB in testing).

* chore: add changeset for Jupyter notebook diff view fix

* fix: show error message when Jupyter extension is missing for notebook diffs

* refactor: move os require to top-level import

---------

Co-authored-by: Max <maxpaulus43@gmail.com>
2026-01-21 08:07:03 -08:00
Bee f7561c31fa fix: prevent race condition in telemetry service initialization (#8764) 2026-01-21 07:23:21 -08:00
Jose Castelli f0a97dafc8 fix: fixing testing framework and removing old integration tests [PF-413] (#8727)
fix: fixing testing framework and removing old integration tests [PF-413] #8727
2026-01-21 14:38:03 +01:00
abeatrix 55d268773d on exit 2026-01-20 23:32:20 -08:00
abeatrix bf978c2c42 Merge branch 'bee/cli-ts-ink-poc' of https://github.com/cline/cline into bee/cli-ts-ink-poc 2026-01-20 23:01:07 -08:00
abeatrix 7e06dbbd33 Fix viewpoints 2026-01-20 23:01:06 -08:00
Saoud Rizwan d422f689a6 docs(storage): document StateManager multi-instance behavior
Adds a detailed comment explaining that each VS Code window has its own
StateManager cache, which is why settings like plan/act mode don't sync
between running instances. The cache is populated from disk only during
initialize() and never re-read, providing natural isolation.
2026-01-20 20:50:55 -08:00
Robin Newhouse 00efcc1c2b fix: prevent duplicate diff errors when parallel tool calling is enabled (#8763)
* fix: prevent duplicate diff errors when parallel tool calling is enabled

When a `replace_in_file` diff fails during streaming with parallel tool
calling enabled (GPT-5/Codex models), the same error message was being
added to userMessageContent on each streaming chunk, causing hundreds of
duplicates and context window overflow.

Root cause: The duplicate prevention added in 09276ebf4 used the
`didAlreadyUseTool` flag, but this flag is intentionally not set when
parallel tool calling is enabled (per 00e9d6f52). This was because
`didAlreadyUseTool` is designed to block subsequent tools, which is the
opposite of what parallel tool calling needs.

The fix adds per-call_id tracking via `diffErrorPushedForCallIds` Set:
- Track which specific tool calls have already had their error pushed
- Works for both parallel and non-parallel tool calling
- Different parallel tool calls can each report their own errors
- Same call_id only pushes error once, regardless of streaming chunks

This is compatible with the parallel tool calling design because GPT-5
models (which auto-enable parallel tool calling) use native tool calling
and always have a call_id. The mechanism is separate from didAlreadyUseTool
which controls flow (blocking tools) vs this which prevents duplicates.

Related commits:
- 09276ebf4: fix: prevent duplicate error messages during streamed edit
  tool failures (only worked when parallel tool calling disabled)
- 00e9d6f52: feat: add experimental parallel tool calling support
  (deliberately excluded didAlreadyUseTool from parallel mode)

* test: add unit tests for diffErrorPushedForCallIds duplicate prevention

Tests cover:
- Basic Set behavior (initialization, tracking, clearing)
- Duplicate prevention logic for parallel tool calling
- Edge cases (empty/undefined call_id, rapid streaming chunks)
- Reset behavior between API requests

* Add changeset

* refactor: rename diffErrorPushedForCallIds to errorPushedForCallIds

Generalizes the tracking mechanism per reviewer feedback from @abeatrix.

The more generic name allows the same pattern to be reused for other
tool handlers that may need duplicate error prevention in the future,
not just diff-related errors.

No functional changes - just renaming.
2026-01-20 20:48:29 -08:00
abeatrix 8d4822d781 add version command 2026-01-20 18:07:30 -08:00
abeatrix dbcaae65d1 refactor(auth): extract provider mapping and add welcome navigation
- Extract provider-to-API-key field mapping into shared utility (ProviderToApiKeyMap)
- Add navigation support from AuthView to WelcomeView via onNavigateToWelcome callback
- Implement internal welcome submission handler with image processing support
- Improve code organization by centralizing provider configuration mapping

This refactoring enables better navigation flow between auth and welcome screens
and makes the provider mapping reusable across components.
2026-01-20 18:07:02 -08:00
abeatrix 66db36ff5b Includes Input field with options fields 2026-01-20 18:05:14 -08:00
abeatrix a53d17a857 update script 2026-01-20 16:01:58 -08:00
abeatrix de56305315 file structure 2026-01-20 15:24:56 -08:00
abeatrix 5cd22e3f86 Clean up and add config view for rules 2026-01-20 14:27:02 -08:00
abeatrix bd9150837d Update DiffView background 2026-01-20 13:55:29 -08:00
abeatrix fbb494b88a Add DiffView & History UX 2026-01-20 13:43:11 -08:00
abeatrix 16b4c48457 Fix Box Order 2026-01-20 13:40:14 -08:00
abeatrix 058c86872b checkpoint restore 2026-01-20 13:02:01 -08:00
abeatrix d3e8f562bc At Mention Files 2026-01-20 12:51:18 -08:00
abeatrix 1594d44190 Show Costs 2026-01-20 12:24:51 -08:00
abeatrix c8419ffa99 Add Unit Tests 2026-01-20 12:14:02 -08:00
abeatrix cf6c80504f Fix spacing 2026-01-20 11:48:19 -08:00
abeatrix 230f615927 fix thinking 2026-01-20 11:37:32 -08:00
abeatrix 08e00dc3bf hide config options 2026-01-20 11:22:36 -08:00
abeatrix c01bf73d3f Interactive ConfigView 2026-01-20 11:17:48 -08:00
abeatrix df28359114 add Focus Chain 2026-01-20 11:17:33 -08:00
abeatrix d3631f0485 add account info 2026-01-20 09:27:18 -08:00
abeatrix eb18c556f4 add image support 2026-01-20 09:27:03 -08:00
abeatrix b18284ea78 clean up 2026-01-20 00:09:22 -08:00
abeatrix e4e99ec711 clean up shim 2026-01-20 00:00:24 -08:00
abeatrix b7b4d0bfe8 add welcome view 2026-01-19 21:53:40 -08:00
abeatrix c12b00e7a9 add thinking options to task command & set model ID 2026-01-19 21:40:52 -08:00
abeatrix 73bb8c2f64 Fix task display in HistoryView 2026-01-19 21:03:39 -08:00
abeatrix 0e1d513462 update prompt handling for plan mode and completion
Add new prompt types "plan_mode_text" and "completion" to improve user experience when interacting with the assistant. Separate handling for plan_mode_respond from followup to enable quick mode switching via empty Enter press. Add toggleToActMode callback to allow seamless transition from Plan to Act mode without text input.

Changes:
- Split plan_mode_respond and followup logic for distinct UX flows
- Add plan_mode_text prompt type with toggle-to-Act-mode on empty Enter
- Add completion prompt type for follow-up questions or exit on Enter
- Implement toggleToActMode callback for mode switching
- Update keyboard handlers and UI rendering for new prompt types
- Add helpful hints for users (e.g., "just Enter to switch to Act mode")
2026-01-19 21:01:36 -08:00
abeatrix d6a1b338bc clean up 2026-01-19 19:18:13 -08:00
abeatrix 0091fb6ffb clean up messages 2026-01-19 16:38:47 -08:00
abeatrix de3e3fe34a clean up logs 2026-01-19 16:25:43 -08:00
abeatrix e136d75621 refactor: switch CLI to ES modules with Ink UI
Convert CLI build output from CommonJS (cli.cjs) to ES modules (cli.mjs) and refactor the UI layer to use React with Ink framework instead of the previous subscription-based state update pattern.

Changes include:
- Updated esbuild configuration to target ESM format
- Added stubOptionalModulesPlugin to handle react-devtools-core
- Updated shebang and module compatibility helpers for ES modules
- Replaced state subscriber pattern with React component-based UI
- Added react and related type dependencies
- Updated external module list to include UI dependencies (ink, ink-spinner, react)
- Enabled top-level-await support for ES modules
2026-01-19 16:18:24 -08:00
abeatrix 91127ffdb9 Update logger 2026-01-16 22:34:20 -08:00
abeatrix 11f76f0218 add cline sign in 2026-01-16 22:08:03 -08:00
abeatrix 74f1b9c75d **feat(cli): allow switching mode and specifying model for tasks**
Added `--switch` (`-s`) and `--model` (`-m`) options to the CLI.
Updated `runTask` signature and logic to set global state for mode (plan/act) and the corresponding API model ID.
This enables users to run tasks in different modes or with a specific model directly from the command line.
2026-01-16 18:46:55 -08:00
abeatrix 942c78119e working prototype
npm install:all
cd cli-ts
npm run link
clinedev auth
2026-01-16 18:36:47 -08:00
abeatrix 3130c3c96e replace console.log with Logger 2026-01-14 13:08:50 -08:00
abeatrix f0225bc20d fileeditprovider 2026-01-14 13:08:28 -08:00
abeatrix 1bf158245c prototype: Cline CLI with Typescript
TODO:
- remove usage of console.log across codebase and replace them with Logger
2026-01-13 16:55:36 -08:00
614 changed files with 372361 additions and 5022 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
---
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.
-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
---
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 @@
---
"cline": patch
---
Add git worktree management UI for running parallel Cline sessions
+33
View File
@@ -0,0 +1,33 @@
# 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.
## Adding New API Providers
When adding a new API provider to the extension, you must also update the CLI:
1. **Update `cli-ts/src/components/ModelPicker.tsx`**: Add the provider to the `providerModels` map so `getDefaultModelId()` returns the correct default model. Import the models and default ID from `@shared/api`:
```typescript
import { newProviderDefaultModelId, newProviderModels } from "@/shared/api"
export const providerModels = {
// ...existing providers
"new-provider": { models: newProviderModels, defaultId: newProviderDefaultModelId },
}
```
2. **Use `applyProviderConfig()` for auth flows**: When implementing OAuth or other auth flows for the provider, use the shared utility at `cli-ts/src/utils/provider-config.ts`:
```typescript
import { applyProviderConfig } from "../utils/provider-config"
// After successful auth:
await applyProviderConfig({ providerId: "new-provider", controller })
```
This handles setting provider, default model, API key mapping, state persistence, and rebuilding the API handler.
3. **Provider-specific auth**: If the provider uses OAuth (like `openai-codex`), add handling in `SettingsPanelContent.tsx`'s `handleProviderSelect` callback. See the existing Codex OAuth flow as a reference.
+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'
+3 -12
View File
@@ -187,20 +187,11 @@ jobs:
if: steps.webview-cache.outputs.cache-hit != 'true'
run: cd webview-ui && npm ci
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
cache-dependency-path: cli/go.sum
- name: Build CLI binaries
run: npm run compile-cli-all-platforms
- name: Download ripgrep binaries
run: npm run download-ripgrep
- name: Compile NPM package
run: npm run compile-standalone-npm
- name: Compile Standalone
run: npm run compile-standalone
- name: Install testing platform dependencies
if: steps.testing-platform-cache.outputs.cache-hit != 'true'
@@ -213,7 +204,7 @@ jobs:
# This prevents the job from showing as failed and avoids distracting developers
# until the integration tests are ready to be enforced.
run: |
npm run test:tp-orchestrator -- tests/specs/ --count=1 --coverage || true
npm run test:tp-orchestrator -- tests/specs/ --count=1 --coverage
- name: Save Coverage Reports
uses: actions/upload-artifact@v4
+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
+28 -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,33 @@
"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": [
"**",
"!**/esbuild.*",
"!**/*.mts",
"!**/webview-ui/**",
"!**/evals/**",
"!**/standalone/**",
"!**/e2e/**",
"!**/test/**",
"!**/__tests__/**",
"!**/*.test.ts",
"!**/*.stories.ts",
"!src/dev/**",
"!**/*.mjs",
"!**/*.js",
"!**/scripts/**",
"!**/*.tsx",
"!**/testing-platform/**",
// ACP mode must redirect console to stderr - this is intentional
"!cli-ts/src/acp/index.ts"
]
},
{
"includes": [
"**",
+354
View File
@@ -0,0 +1,354 @@
# Cline CLI (TypeScript)
A TypeScript CLI implementation of Cline that reuses the core TypeScript codebase. This allows you to run Cline tasks directly from the terminal while sharing the same underlying functionality as the VS Code extension.
## Features
- **Reuses Core Codebase**: Shares the same Controller, Task, and API handling as the VS Code extension
- **Terminal Output**: Displays Cline messages directly in your terminal with colored output
- **Task History**: Access your task history from the command line
- **Configurable**: Use custom configuration directories and working directories
- **Image Support**: Attach images to your prompts using file paths or inline references
## Prerequisites
- Node.js 20.x or later
- npm or yarn
- The parent Cline project dependencies installed
## Installation
From the repository root:
```bash
# Install all dependencies first
npm run install:all
# Ensure protos are generated
npm run protos
# Build the CLI
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
```
## Usage
### Interactive Mode (Default)
When you run `cline` without any command, it launches an interactive welcome prompt:
```bash
# Launch interactive mode
cline
# Or run a task directly
cline "Create a hello world function in Python"
# With options
cline -v --thinking "Analyze this codebase"
```
### Commands
#### `task` (alias: `t`)
Run a new task with a prompt.
```bash
cline task "Create a hello world function in Python"
cline t "Create a hello world function"
```
**Options:**
| Option | Description |
|--------|-------------|
| `-a, --act` | Run in act mode |
| `-p, --plan` | Run in plan mode |
| `-y, --yolo` | Enable yolo mode (auto-approve actions) |
| `-m, --model <model>` | Model to use for the task |
| `-i, --images <paths...>` | Image file paths to include with the task |
| `-v, --verbose` | Show verbose output including reasoning |
| `-c, --cwd <path>` | Working directory for the task |
| `--config <path>` | Path to Cline configuration directory |
| `-t, --thinking` | Enable extended thinking (1024 token budget) |
**Examples:**
```bash
# Run in plan mode with verbose output
cline task -p -v "Design a REST API"
# Use a specific model with yolo mode
cline task -m claude-sonnet-4-5-20250929 -y "Refactor this function"
# Include images with your prompt
cline task -i screenshot.png diagram.jpg "Fix the UI based on these images"
# Or use inline image references in the prompt
cline task "Fix the layout shown in @./screenshot.png"
# Enable extended thinking for complex tasks
cline task -t "Architect a microservices system"
# Specify working directory
cline task -c /path/to/project "Add unit tests"
```
#### `history` (alias: `h`)
List task history with pagination support.
```bash
cline history
cline h
```
**Options:**
| Option | Description |
|--------|-------------|
| `-n, --limit <number>` | Number of tasks to show (default: 10) |
| `-p, --page <number>` | Page number, 1-based (default: 1) |
| `--config <path>` | Path to Cline configuration directory |
**Examples:**
```bash
# Show last 10 tasks (default)
cline history
# Show 20 tasks
cline history -n 20
# Show page 2 with 5 tasks per page
cline history -n 5 -p 2
```
#### `config`
Show current configuration including global and workspace state.
```bash
cline config
```
**Options:**
| Option | Description |
|--------|-------------|
| `--config <path>` | Path to Cline configuration directory |
#### `auth`
Authenticate a provider and configure what model is used.
```bash
cline auth
```
**Options:**
| Option | Description |
|--------|-------------|
| `-p, --provider <id>` | Provider ID for quick setup (e.g., openai-native, anthropic) |
| `-k, --apikey <key>` | API key for the provider |
| `-m, --modelid <id>` | Model ID to configure (e.g., gpt-4o, claude-sonnet-4-5-20250929) |
| `-b, --baseurl <url>` | Base URL (optional, only for openai provider) |
| `-v, --verbose` | Show verbose output |
| `-c, --cwd <path>` | Working directory for the task |
| `--config <path>` | Path to Cline configuration directory |
**Examples:**
```bash
# Interactive authentication
cline auth
# Quick setup with provider and API key
cline auth -p anthropic -k sk-ant-xxxxx
# Full quick setup with model
cline auth -p openai-native -k sk-xxxxx -m gpt-4o
# OpenAI-compatible provider with custom base URL
cline auth -p openai -k your-api-key -b https://api.example.com/v1
```
### Global Options
These options are available for the default command (running a task directly):
| Option | Description |
|--------|-------------|
| `-v, --verbose` | Show verbose output |
| `-c, --cwd <path>` | Working directory |
| `--config <path>` | Configuration directory |
| `--thinking` | Enable extended thinking (1024 token budget) |
## Development
For active development, at the root of this repo:
1. **Initial setup:**
```bash
npm run install:all
npm run protos
```
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
# Production build (minified)
npm run build:production
```
### Watch Mode
```bash
npm run watch
```
### Type Checking
```bash
npm run typecheck
```
## Publish
#### 1. Publish to npm
```bash
npm publish
```
#### 2. Update the Homebrew formula
```bash
npm run update-brew-formula
```
#### 3. Test the formula locally
```bash
# Create a local tap
brew tap-new cline/local
cp ./cli-ts/cline.rb "$(brew --repository)/Library/Taps/cline/homebrew-local/Formula/cline.rb"
# Build from Source
brew install --build-from-source cline/local/cline
# Install from your local tap
brew install cline/local/cline
# Clean up when done
brew untap cline/local
```
#### 4. If using a tap, commit and push
```bash
git add cline.rb
git commit -m "Update cline to v2.0.0"
git push
```
## Architecture
The CLI reuses the core Cline TypeScript codebase:
- **Controller** (`@core/controller`): Manages task lifecycle and state
- **Task** (`@core/task`): Executes Cline tasks using the AI API
- **StateManager** (`@core/storage`): Handles persistent state storage
CLI-specific implementations:
- `cli-host-bridge.ts`: CLI implementations of host bridge services
- `cli-webview-provider.ts`: WebviewProvider that outputs to terminal
- `cli-comment-review.ts`: Comment review controller for terminal
- `vscode-context.ts`: Mock VSCode extension context
- `display.ts`: Terminal output formatting utilities
## Configuration
The CLI stores its data in `~/.cline/data/` by default:
- `globalState.json`: Global settings and state
- `secrets.json`: API keys and secrets
- `workspace/`: Workspace-specific state
- `tasks/`: Task history and conversation data
Override with the `--config` option or `CLINE_DIR` environment variable.
## Comparison with Go CLI
This TypeScript CLI differs from the Go CLI (`cli/` directory):
| Feature | Go CLI | TypeScript CLI |
|---------|--------|----------------|
| Language | Go | TypeScript |
| Core sharing | Uses gRPC to communicate | Direct imports |
| Startup time | Fast | Moderate |
| Dependencies | Standalone binary | Requires Node.js |
| Best for | Production deployment | Development, debugging |
Choose the TypeScript CLI when you need to debug or modify the core Cline logic. Choose the Go CLI for production deployment with faster startup.
## Troubleshooting
### Build Errors
If you encounter build errors, ensure you've:
1. Run `npm install` in the repository root
2. Run `npm run protos` to generate proto files
3. Have all peer dependencies installed
### Missing Dependencies
The CLI imports from the parent project. If you see import errors:
```bash
cd .. # Go to repository root
npm install
npm run protos
```
### Permission Denied
Make the CLI executable:
```bash
chmod +x dist/cli.js
```
+20
View File
@@ -0,0 +1,20 @@
# IMPORTANT: `npm run postpublish` to update this file after publishing a new version of the package
class Cline < Formula
desc "Autonomous coding agent CLI - capable of creating/editing files, running commands, and more"
homepage "https://cline.bot"
url "https://registry.npmjs.org/cline/-/cline-2.0.0.tgz" # GET from https://registry.npmjs.org/cline/latest tarball URL
sha256 "65bae90401191aeeabfbbc0b315e816aea96742043ba85b90671bf5e19d0761e"
license "Apache-2.0"
depends_on "node@20"
def install
system "npm", "install", *std_npm_args(prefix: false)
bin.install_symlink Dir["#{libexec}/bin/*"]
end
test do
# Test that the binary exists and is executable
assert_match version.to_s, shell_output("#{bin}/cline --version")
end
end
+272
View File
@@ -0,0 +1,272 @@
import fs from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
import dotenv from "dotenv"
import * as esbuild from "esbuild"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const rootDir = path.resolve(__dirname, "..")
// Load .env from repo root
dotenv.config({ path: path.join(rootDir, ".env") })
const production = process.argv.includes("--production")
const watch = process.argv.includes("--watch")
/**
* Plugin to resolve path aliases from the parent project
*/
const aliasResolverPlugin: esbuild.Plugin = {
name: "alias-resolver",
setup(build) {
const aliases = {
"@": path.resolve(rootDir, "src"),
"@core": path.resolve(rootDir, "src/core"),
"@integrations": path.resolve(rootDir, "src/integrations"),
"@services": path.resolve(rootDir, "src/services"),
"@shared": path.resolve(rootDir, "src/shared"),
"@utils": path.resolve(rootDir, "src/utils"),
"@packages": path.resolve(rootDir, "src/packages"),
"@hosts": path.resolve(rootDir, "src/hosts"),
"@generated": path.resolve(rootDir, "src/generated"),
"@api": path.resolve(rootDir, "src/core/api"),
}
// For each alias entry, create a resolver
Object.entries(aliases).forEach(([alias, aliasPath]) => {
const aliasRegex = new RegExp(`^${alias}($|/.*)`)
build.onResolve({ filter: aliasRegex }, (args) => {
const importPath = args.path.replace(alias, aliasPath)
// First, check if the path exists as is
if (fs.existsSync(importPath)) {
const stats = fs.statSync(importPath)
if (stats.isDirectory()) {
// If it's a directory, try to find index files
const extensions = [".ts", ".tsx", ".js", ".jsx"]
for (const ext of extensions) {
const indexFile = path.join(importPath, `index${ext}`)
if (fs.existsSync(indexFile)) {
return { path: indexFile }
}
}
} else {
// It's a file that exists, so return it
return { path: importPath }
}
}
// If the path doesn't exist, try appending extensions
const extensions = [".ts", ".tsx", ".js", ".jsx"]
for (const ext of extensions) {
const pathWithExtension = `${importPath}${ext}`
if (fs.existsSync(pathWithExtension)) {
return { path: pathWithExtension }
}
}
// Handle .js -> .ts extension mapping (common in ESM TypeScript projects)
if (importPath.endsWith(".js")) {
const tsPath = importPath.replace(/\.js$/, ".ts")
if (fs.existsSync(tsPath)) {
return { path: tsPath }
}
const tsxPath = importPath.replace(/\.js$/, ".tsx")
if (fs.existsSync(tsxPath)) {
return { path: tsxPath }
}
}
// If nothing worked, return the original path and let esbuild handle the error
return { path: importPath }
})
})
},
}
/**
* Plugin to redirect vscode imports to our shim
*/
const vscodeStubPlugin: esbuild.Plugin = {
name: "vscode-stub",
setup(build) {
// Redirect 'vscode' imports to our shim
build.onResolve({ filter: /^vscode$/ }, () => {
return { path: path.join(__dirname, "src", "vscode-shim.ts") }
})
},
}
const esbuildProblemMatcherPlugin: esbuild.Plugin = {
name: "esbuild-problem-matcher",
setup(build) {
build.onStart(() => {
console.log("[cli-ts esbuild] Build started...")
})
build.onEnd((result) => {
result.errors.forEach(({ text, location }) => {
console.error(`✘ [ERROR] ${text}`)
if (location) {
console.error(` ${location.file}:${location.line}:${location.column}:`)
}
})
console.log("[cli-ts esbuild] Build finished")
})
},
}
// Plugin to stub out optional devtools module
const stubOptionalModulesPlugin: esbuild.Plugin = {
name: "stub-optional-modules",
setup(build) {
build.onResolve({ filter: /^react-devtools-core$/ }, () => {
return { path: path.join(__dirname, "src", "stub-devtools.js"), external: false }
})
},
}
const copyWasmFiles: esbuild.Plugin = {
name: "copy-wasm-files",
setup(build) {
build.onEnd(() => {
const destDir = path.join(__dirname, "dist")
// Ensure dist directory exists
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true })
}
// tree sitter
const sourceDir = path.join(rootDir, "node_modules", "web-tree-sitter")
// Copy tree-sitter.wasm
const treeSitterWasm = path.join(sourceDir, "tree-sitter.wasm")
if (fs.existsSync(treeSitterWasm)) {
fs.copyFileSync(treeSitterWasm, path.join(destDir, "tree-sitter.wasm"))
}
// Copy language-specific WASM files
const languageWasmDir = path.join(rootDir, "node_modules", "tree-sitter-wasms", "out")
const languages = [
"typescript",
"tsx",
"python",
"rust",
"javascript",
"go",
"cpp",
"c",
"c_sharp",
"ruby",
"java",
"php",
"swift",
"kotlin",
]
if (fs.existsSync(languageWasmDir)) {
languages.forEach((lang) => {
const filename = `tree-sitter-${lang}.wasm`
const sourcePath = path.join(languageWasmDir, filename)
if (fs.existsSync(sourcePath)) {
fs.copyFileSync(sourcePath, path.join(destDir, filename))
}
})
}
})
},
}
const buildEnvVars: Record<string, string> = {
"process.env.IS_STANDALONE": JSON.stringify("true"),
"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"
}
const config: esbuild.BuildOptions = {
entryPoints: [path.join(__dirname, "src", "index.ts")],
bundle: true,
minify: production,
sourcemap: !production,
logLevel: "silent",
define: buildEnvVars,
tsconfig: path.join(__dirname, "tsconfig.json"),
plugins: [copyWasmFiles, aliasResolverPlugin, vscodeStubPlugin, stubOptionalModulesPlugin, esbuildProblemMatcherPlugin],
format: "esm",
sourcesContent: false,
platform: "node",
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",
"ink-picture",
"react",
"aws4fetch",
"pino",
"pino-roll",
],
supported: { "top-level-await": true },
banner: {
js: `#!/usr/bin/env node
// Suppress all Node.js warnings (deprecation, experimental, etc.)
process.emitWarning = () => {};
import { createRequire as _createRequire } from 'module';
import { fileURLToPath as _fileURLToPath } from 'url';
import { dirname as _dirname } from 'path';
const require = _createRequire(import.meta.url);
const __filename = _fileURLToPath(import.meta.url);
const __dirname = _dirname(__filename);`,
},
}
async function main() {
const ctx = await esbuild.context(config)
if (watch) {
await ctx.watch()
console.log("[cli-ts] Watching for changes...")
} else {
await ctx.rebuild()
await ctx.dispose()
// Make the output executable
const outfile = path.join(__dirname, "dist", "cli.mjs")
if (fs.existsSync(outfile)) {
fs.chmodSync(outfile, "755")
}
}
}
main().catch((e) => {
console.error(e)
process.exit(1)
})
+384
View File
@@ -0,0 +1,384 @@
.\" Automatically generated by Pandoc 3.8.3
.\"
.TH "CLINE" "1" "January 2026" "Cline CLI 2.0" "User Commands"
.SH NAME
cline \- AI coding assistant in your terminal
.SH SYNOPSIS
\f[B]cline\f[R] [\f[I]prompt\f[R]] [\f[I]options\f[R]]
.PP
\f[B]cline\f[R] \f[I]command\f[R] [\f[I]options\f[R]]
[\f[I]arguments\f[R]]
.SH DESCRIPTION
\f[B]cline\f[R] is a command\-line interface for the Cline AI coding
assistant.
It provides the same powerful AI capabilities as the VS Code extension,
directly in your terminal.
.PP
Cline is an autonomous AI agent that can read, write, and execute code
across your projects.
He can create and edit files, run terminal commands, use a headless
browser, and more\(emall while asking for your approval before taking
actions.
.PP
The CLI supports both interactive mode (with a rich terminal UI) and
plain text mode (for piped input and scripted workflows).
.SH MODES OF OPERATION
\f[B]Interactive Mode\f[R] : When you run \f[B]cline\f[R] without
arguments, it launches an interactive welcome prompt with a rich
terminal UI.
You can type your task, view conversation history, and interact with
Cline in real\-time.
.PP
\f[B]Task Mode\f[R] : Run \f[B]cline \(lqprompt\(rq\f[R] or \f[B]cline
task \(lqprompt\(rq\f[R] to immediately start a task.
If stdin is a TTY, you\(cqll see the interactive UI.
If stdin is piped or output is redirected, the CLI automatically
switches to plain text mode.
.PP
\f[B]Plain Text Mode\f[R] : Activated automatically when stdin is piped,
output is redirected, or \f[B]\-\-json\f[R]/\f[B]\-\-yolo\f[R] flags are
used.
Outputs clean text without the Ink UI, suitable for scripting and CI/CD
pipelines.
.SH AGENT BEHAVIOR
Cline operates in two primary modes:
.PP
\f[B]ACT MODE\f[R] : Cline actively uses tools to accomplish tasks.
He can read files, write code, execute commands, use a headless browser,
and more.
This is the default mode for task execution.
.PP
\f[B]PLAN MODE\f[R] : Cline gathers information and creates a detailed
plan before implementation.
He explores the codebase, asks clarifying questions, and presents a
strategy for user approval before switching to ACT MODE.
.SH COMMANDS
.SS task (alias: t)
Run a new task with a prompt.
.PP
\f[B]cline task\f[R] \f[I]prompt\f[R] [\f[I]options\f[R]]
.PP
\f[B]cline t\f[R] \f[I]prompt\f[R] [\f[I]options\f[R]] : Create and run
a new task.
Options:
.PP
\f[B]\-a\f[R], \f[B]\-\-act\f[R] : Run in act mode (default)
.PP
\f[B]\-p\f[R], \f[B]\-\-plan\f[R] : Run in plan mode
.PP
\f[B]\-y\f[R], \f[B]\-\-yolo\f[R] : Enable yolo/yes mode (auto\-approve
all actions, output in plain mode, exit process automatically when task
complete)
.PP
\f[B]\-m\f[R], \f[B]\-\-model\f[R] \f[I]model\f[R] : Model to use for
the task
.PP
\f[B]\-i\f[R], \f[B]\-\-images\f[R] \f[I]paths\&...\f[R] : Image file
paths to include with the task
.PP
\f[B]\-v\f[R], \f[B]\-\-verbose\f[R] : Show verbose output including
reasoning
.PP
\f[B]\-c\f[R], \f[B]\-\-cwd\f[R] \f[I]path\f[R] : Working directory for
the task
.PP
\f[B]\-\-config\f[R] \f[I]path\f[R] : Path to Cline configuration
directory
.PP
\f[B]\-\-thinking\f[R] : Enable extended thinking (1024 token budget)
.PP
\f[B]\-\-json\f[R] : Output messages as JSON instead of styled text
.SS history (alias: h)
List task history with pagination.
.PP
\f[B]cline history\f[R] [\f[I]options\f[R]]
.PP
\f[B]cline h\f[R] [\f[I]options\f[R]] : Display previous tasks.
Options:
.PP
\f[B]\-n\f[R], \f[B]\-\-limit\f[R] \f[I]number\f[R] : Number of tasks to
show (default: 10)
.PP
\f[B]\-p\f[R], \f[B]\-\-page\f[R] \f[I]number\f[R] : Page number,
1\-based (default: 1)
.PP
\f[B]\-\-config\f[R] \f[I]path\f[R] : Path to Cline configuration
directory
.SS config
Show current configuration.
.PP
\f[B]cline config\f[R] [\f[I]options\f[R]] : Display global and
workspace state.
Options:
.PP
\f[B]\-\-config\f[R] \f[I]path\f[R] : Path to Cline configuration
directory
.SS auth
Authenticate a provider and configure the model.
.PP
\f[B]cline auth\f[R] [\f[I]options\f[R]] : Launch interactive
authentication wizard, or use quick setup flags.
Options:
.PP
\f[B]\-p\f[R], \f[B]\-\-provider\f[R] \f[I]id\f[R] : Provider ID for
quick setup (e.g., openai\-native, anthropic, openrouter)
.PP
\f[B]\-k\f[R], \f[B]\-\-apikey\f[R] \f[I]key\f[R] : API key for the
provider
.PP
\f[B]\-m\f[R], \f[B]\-\-modelid\f[R] \f[I]id\f[R] : Model ID to
configure (e.g., gpt\-4o, claude\-sonnet\-4\-5\-20250929)
.PP
\f[B]\-b\f[R], \f[B]\-\-baseurl\f[R] \f[I]url\f[R] : Base URL (optional,
for OpenAI\-compatible providers)
.PP
\f[B]\-v\f[R], \f[B]\-\-verbose\f[R] : Show verbose output
.PP
\f[B]\-c\f[R], \f[B]\-\-cwd\f[R] \f[I]path\f[R] : Working directory
.PP
\f[B]\-\-config\f[R] \f[I]path\f[R] : Path to Cline configuration
directory
.SS update
Check for updates and install if available.
.PP
\f[B]cline update\f[R] [\f[I]options\f[R]] : Check npm for newer
versions.
Options:
.PP
\f[B]\-v\f[R], \f[B]\-\-verbose\f[R] : Show verbose output
.SS version
Show the CLI version number.
.PP
\f[B]cline version\f[R]
.SS dev
Developer tools and utilities.
.PP
\f[B]cline dev log\f[R] : Open the log file for debugging.
.SH DEFAULT COMMAND OPTIONS
When running \f[B]cline\f[R] with just a prompt (no subcommand), these
options are available:
.PP
\f[B]\-a\f[R], \f[B]\-\-act\f[R] : Run in act mode (default)
.PP
\f[B]\-p\f[R], \f[B]\-\-plan\f[R] : Run in plan mode
.PP
\f[B]\-y\f[R], \f[B]\-\-yolo\f[R] : Enable yolo mode (auto\-approve all
actions).
Also forces plain text output mode.
.PP
\f[B]\-m\f[R], \f[B]\-\-model\f[R] \f[I]model\f[R] : Model to use for
the task
.PP
\f[B]\-v\f[R], \f[B]\-\-verbose\f[R] : Show verbose output
.PP
\f[B]\-c\f[R], \f[B]\-\-cwd\f[R] \f[I]path\f[R] : Working directory
.PP
\f[B]\-\-config\f[R] \f[I]path\f[R] : Configuration directory
.PP
\f[B]\-\-thinking\f[R] : Enable extended thinking (1024 token budget)
.PP
\f[B]\-\-json\f[R] : Output messages as JSON instead of styled text.
Forces plain text mode.
.SH JSON OUTPUT FORMAT
When using \f[B]\-\-json\f[R], each message is output as a JSON object
with these fields:
.PP
\f[B]Required fields:\f[R]
.IP \(bu 2
\f[B]type\f[R]: \(lqask\(rq or \(lqsay\(rq
.IP \(bu 2
\f[B]text\f[R]: message text
.IP \(bu 2
\f[B]ts\f[R]: Unix epoch timestamp in milliseconds
.PP
\f[B]Optional fields:\f[R]
.IP \(bu 2
\f[B]reasoning\f[R]: reasoning text
.IP \(bu 2
\f[B]say\f[R]: say subtype (when type is \(lqsay\(rq)
.IP \(bu 2
\f[B]ask\f[R]: ask subtype (when type is \(lqask\(rq)
.IP \(bu 2
\f[B]partial\f[R]: streaming flag
.IP \(bu 2
\f[B]images\f[R]: list of image URIs
.IP \(bu 2
\f[B]files\f[R]: list of file paths
.SH EXAMPLES
.SS Basic Usage
.IP
.EX
\f[I]# Launch interactive mode\f[R]
cline
\f[I]# Run a task directly\f[R]
cline \(dqCreate a hello world function in Python\(dq
\f[I]# Run with verbose output and extended thinking\f[R]
cline \-v \-\-thinking \(dqAnalyze this codebase architecture\(dq
.EE
.SS Mode Selection
.IP
.EX
\f[I]# Run in plan mode (gather info before acting)\f[R]
cline \-p \(dqDesign a REST API for user management\(dq
\f[I]# Run in act mode with auto\-approval (yolo)\f[R]
cline \-y \(dqFix the typo in README.md\(dq
.EE
.SS Using Specific Models
.IP
.EX
\f[I]# Use a specific model\f[R]
cline \-m claude\-sonnet\-4\-5\-20250929 \(dqRefactor this function\(dq
\f[I]# Quick auth setup with model\f[R]
cline auth \-p anthropic \-k sk\-ant\-xxxxx \-m claude\-sonnet\-4\-5\-20250929
.EE
.SS Including Images
.IP
.EX
\f[I]# Include images with explicit flag\f[R]
cline task \-i screenshot.png diagram.jpg \(dqFix the UI based on these images\(dq
\f[I]# Or use inline image references in the prompt\f[R]
cline \(dqFix the layout shown in \(at./screenshot.png\(dq
.EE
.SS Piped Input
.IP
.EX
\f[I]# Pipe file contents to Cline\f[R]
cat README.md \f[B]|\f[R] cline \(dqSummarize this document\(dq
\f[I]# Pipe with additional prompt\f[R]
echo \(dqfunction add(a, b) { return a + b }\(dq \f[B]|\f[R] cline \(dqAdd TypeScript types to this\(dq
\f[I]# Combine piped input with a prompt\f[R]
git diff \f[B]|\f[R] cline \(dqReview these changes and suggest improvements\(dq
.EE
.SS Scripting and Automation
.IP
.EX
\f[I]# JSON output for parsing\f[R]
cline \-\-json \(dqWhat files are in this directory?\(dq \f[B]|\f[R] jq \(aq.text\(aq
\f[I]# Yolo mode for automated workflows (auto\-approves all actions), forces plain text output\f[R]
cline \-y \(dqRun the test suite and fix any failures\(dq
.EE
.SS Task History
.IP
.EX
\f[I]# List recent tasks\f[R]
cline history
\f[I]# Show more tasks with pagination\f[R]
cline history \-n 20 \-p 2
.EE
.SS Authentication
.IP
.EX
\f[I]# Interactive authentication wizard\f[R]
cline auth
\f[I]# Quick setup for Anthropic\f[R]
cline auth \-p anthropic \-k sk\-ant\-api\-xxxxx
\f[I]# Quick setup for OpenAI\f[R]
cline auth \-p openai\-native \-k sk\-xxxxx \-m gpt\-4o
\f[I]# OpenAI\-compatible provider with custom base URL\f[R]
cline auth \-p openai \-k your\-api\-key \-b https://api.example.com/v1
.EE
.SH ENVIRONMENT
\f[B]CLINE_DIR\f[R] : Override the default configuration directory.
When set, Cline stores all data in this directory instead of
\f[CR]\(ti/.cline/data/\f[R].
.PP
\f[B]CLINE_COMMAND_PERMISSIONS\f[R] : JSON configuration for restricting
which shell commands Cline can execute.
When set, commands are validated against allow/deny patternks before
execution.
When not set, all commands are allowed.
.PP
Format:
\f[CR]{\(dqallow\(dq: [\(dqpattern1\(dq, \(dqpattern2\(dq], \(dqdeny\(dq: [\(dqpattern3\(dq], \(dqallowRedirects\(dq: true}\f[R]
.PP
\f[B]Fields:\f[R]
.IP \(bu 2
\f[B]allow\f[R] (array of strings): Glob patterns for allowed commands.
If specified, only matching commands are permitted.
Uses \f[CR]*\f[R] to match any characters and \f[CR]?\f[R] to match a
single character.
Setting allow on anything will deny all others.
.IP \(bu 2
\f[B]deny\f[R] (array of strings): Glob patterns for denied commands.
Deny rules take precedence over allow rules.
.IP \(bu 2
\f[B]allowRedirects\f[R] (boolean): Whether to allow shell redirects
(\f[CR]>\f[R], \f[CR]>>\f[R], \f[CR]<\f[R], etc.).
Defaults to false.
.PP
\f[B]Rule evaluation:\f[R]
.IP "1." 3
Check for dangerous characters (backticks outside single quotes,
unquoted newlines)
.IP "2." 3
Parse command into segments split by operators (\f[CR]&&\f[R],
\f[CR]||\f[R], \f[CR]|\f[R], \f[CR];\f[R])
.IP "3." 3
If redirects detected and \f[CR]allowRedirects\f[R] is not true, command
is denied
.IP "4." 3
Each segment is validated against deny rules first, then allow rules
.IP "5." 3
Subshell contents (\f[CR]$(...)\f[R] and \f[CR](...)\f[R]) are
recursively validated
.IP "6." 3
All segments must pass for the command to be allowed
.PP
\f[B]Examples:\f[R]
.IP
.EX
\f[I]# Allow only npm and git commands.\f[R]
export CLINE_COMMAND_PERMISSIONS=\(aq{\(dqallow\(dq: [\(dqnpm *\(dq, \(dqgit *\(dq]}\(aq
\f[I]# Allow development commands but deny dangerous ones. Deny not strictly required here since allow is set.\f[R]
export CLINE_COMMAND_PERMISSIONS=\(aq{\(dqallow\(dq: [\(dqnpm *\(dq, \(dqgit *\(dq, \(dqnode *\(dq], \(dqdeny\(dq: [\(dqrm \-rf *\(dq, \(dqsudo *\(dq]}\(aq
\f[I]# Allow file operations with redirects\f[R]
export CLINE_COMMAND_PERMISSIONS=\(aq{\(dqallow\(dq: [\(dqcat *\(dq, \(dqecho *\(dq], \(dqallowRedirects\(dq: true}\(aq
.EE
.SH FILES
\f[B]\(ti/.cline/data/\f[R] : Default configuration directory
containing:
.PP
\f[B]globalState.json\f[R] : Global settings and state
.PP
\f[B]secrets.json\f[R] : API keys and secrets (stored securely)
.PP
\f[B]workspace/\f[R] : Workspace\-specific state
.PP
\f[B]tasks/\f[R] : Task history and conversation data
.PP
\f[B]\(ti/.cline/log/\f[R] : Log files for debugging.
View with \f[CR]cline dev log\f[R].
.SH BUGS
Report bugs at: \c
.UR https://github.com/cline/cline/issues
.UE \c
.PP
For real\-time help, join the Discord community at: \c
.UR https://discord.gg/cline
.UE \c
.SH SEE ALSO
Full documentation: \c
.UR https://docs.cline.bot
.UE \c
.PP
VS Code extension: \c
.UR https://marketplace.visualstudio.com/items?itemName=saoudrizwan.claude-dev
.UE \c
.SH AUTHORS
Cline is developed by Cline Bot Inc.\ and the open source community.
.SH COPYRIGHT
Copyright © 2025 Cline Bot Inc.\ Licensed under the Apache License 2.0.
+340
View File
@@ -0,0 +1,340 @@
---
title: CLINE
section: 1
header: User Commands
footer: Cline CLI 2.0
date: January 2026
---
# NAME
cline - AI coding assistant in your terminal
# SYNOPSIS
**cline** [*prompt*] [*options*]
**cline** *command* [*options*] [*arguments*]
# DESCRIPTION
**cline** is a command-line interface for the Cline AI coding assistant. It provides the same powerful AI capabilities as the VS Code extension, directly in your terminal.
Cline is an autonomous AI agent that can read, write, and execute code across your projects. He can create and edit files, run terminal commands, use a headless browser, and more—all while asking for your approval before taking actions.
The CLI supports both interactive mode (with a rich terminal UI) and plain text mode (for piped input and scripted workflows).
# MODES OF OPERATION
**Interactive Mode** : When you run **cline** without arguments, it launches an interactive welcome prompt with a rich terminal UI. You can type your task, view conversation history, and interact with Cline in real-time.
**Task Mode** : Run **cline "prompt"** or **cline task "prompt"** to immediately start a task. If stdin is a TTY, you'll see the interactive UI. If stdin is piped or output is redirected, the CLI automatically switches to plain text mode.
**Plain Text Mode** : Activated automatically when stdin is piped, output is redirected, or **\--json**/**\--yolo** flags are used. Outputs clean text without the Ink UI, suitable for scripting and CI/CD pipelines.
# AGENT BEHAVIOR
Cline operates in two primary modes:
**ACT MODE** : Cline actively uses tools to accomplish tasks. He can read files, write code, execute commands, use a headless browser, and more. This is the default mode for task execution.
**PLAN MODE** : Cline gathers information and creates a detailed plan before implementation. He explores the codebase, asks clarifying questions, and presents a strategy for user approval before switching to ACT MODE.
# COMMANDS
## task (alias: t)
Run a new task with a prompt.
**cline task** *prompt* [*options*]
**cline t** *prompt* [*options*] : Create and run a new task. Options:
**-a**, **\--act** : Run in act mode (default)
**-p**, **\--plan** : Run in plan mode
**-y**, **\--yolo** : Enable yolo/yes mode (auto-approve all actions, output in plain mode, exit process automatically when task complete)
**-m**, **\--model** *model* : Model to use for the task
**-i**, **\--images** *paths...* : Image file paths to include with the task
**-v**, **\--verbose** : Show verbose output including reasoning
**-c**, **\--cwd** *path* : Working directory for the task
**\--config** *path* : Path to Cline configuration directory
**\--thinking** : Enable extended thinking (1024 token budget)
**\--json** : Output messages as JSON instead of styled text
## history (alias: h)
List task history with pagination.
**cline history** [*options*]
**cline h** [*options*] : Display previous tasks. Options:
**-n**, **\--limit** *number* : Number of tasks to show (default: 10)
**-p**, **\--page** *number* : Page number, 1-based (default: 1)
**\--config** *path* : Path to Cline configuration directory
## config
Show current configuration.
**cline config** [*options*] : Display global and workspace state. Options:
**\--config** *path* : Path to Cline configuration directory
## auth
Authenticate a provider and configure the model.
**cline auth** [*options*] : Launch interactive authentication wizard, or use quick setup flags. Options:
**-p**, **\--provider** *id* : Provider ID for quick setup (e.g., openai-native, anthropic, openrouter)
**-k**, **\--apikey** *key* : API key for the provider
**-m**, **\--modelid** *id* : Model ID to configure (e.g., gpt-4o, claude-sonnet-4-5-20250929)
**-b**, **\--baseurl** *url* : Base URL (optional, for OpenAI-compatible providers)
**-v**, **\--verbose** : Show verbose output
**-c**, **\--cwd** *path* : Working directory
**\--config** *path* : Path to Cline configuration directory
## update
Check for updates and install if available.
**cline update** [*options*] : Check npm for newer versions. Options:
**-v**, **\--verbose** : Show verbose output
## version
Show the CLI version number.
**cline version**
## dev
Developer tools and utilities.
**cline dev log** : Open the log file for debugging.
# DEFAULT COMMAND OPTIONS
When running **cline** with just a prompt (no subcommand), these options are available:
**-a**, **\--act** : Run in act mode (default)
**-p**, **\--plan** : Run in plan mode
**-y**, **\--yolo** : Enable yolo mode (auto-approve all actions). Also forces plain text output mode.
**-m**, **\--model** *model* : Model to use for the task
**-v**, **\--verbose** : Show verbose output
**-c**, **\--cwd** *path* : Working directory
**\--config** *path* : Configuration directory
**\--thinking** : Enable extended thinking (1024 token budget)
**\--json** : Output messages as JSON instead of styled text. Forces plain text mode.
# JSON OUTPUT FORMAT
When using **\--json**, each message is output as a JSON object with these fields:
**Required fields:**
- **type**: "ask" or "say"
- **text**: message text
- **ts**: Unix epoch timestamp in milliseconds
**Optional fields:**
- **reasoning**: reasoning text
- **say**: say subtype (when type is "say")
- **ask**: ask subtype (when type is "ask")
- **partial**: streaming flag
- **images**: list of image URIs
- **files**: list of file paths
# EXAMPLES
## Basic Usage
```bash
# Launch interactive mode
cline
# Run a task directly
cline "Create a hello world function in Python"
# Run with verbose output and extended thinking
cline -v --thinking "Analyze this codebase architecture"
```
## Mode Selection
```bash
# Run in plan mode (gather info before acting)
cline -p "Design a REST API for user management"
# Run in act mode with auto-approval (yolo)
cline -y "Fix the typo in README.md"
```
## Using Specific Models
```bash
# Use a specific model
cline -m claude-sonnet-4-5-20250929 "Refactor this function"
# Quick auth setup with model
cline auth -p anthropic -k sk-ant-xxxxx -m claude-sonnet-4-5-20250929
```
## Including Images
```bash
# Include images with explicit flag
cline task -i screenshot.png diagram.jpg "Fix the UI based on these images"
# Or use inline image references in the prompt
cline "Fix the layout shown in @./screenshot.png"
```
## Piped Input
```bash
# Pipe file contents to Cline
cat README.md | cline "Summarize this document"
# Pipe with additional prompt
echo "function add(a, b) { return a + b }" | cline "Add TypeScript types to this"
# Combine piped input with a prompt
git diff | cline "Review these changes and suggest improvements"
```
## Scripting and Automation
```bash
# JSON output for parsing
cline --json "What files are in this directory?" | jq '.text'
# Yolo mode for automated workflows (auto-approves all actions), forces plain text output
cline -y "Run the test suite and fix any failures"
```
## Task History
```bash
# List recent tasks
cline history
# Show more tasks with pagination
cline history -n 20 -p 2
```
## Authentication
```bash
# Interactive authentication wizard
cline auth
# Quick setup for Anthropic
cline auth -p anthropic -k sk-ant-api-xxxxx
# Quick setup for OpenAI
cline auth -p openai-native -k sk-xxxxx -m gpt-4o
# OpenAI-compatible provider with custom base URL
cline auth -p openai -k your-api-key -b https://api.example.com/v1
```
# ENVIRONMENT
**CLINE_DIR** : Override the default configuration directory. When set, Cline stores all data in this directory instead of `~/.cline/data/`.
**CLINE_COMMAND_PERMISSIONS** : JSON configuration for restricting which shell commands Cline can execute. When set, commands are validated against allow/deny patternks before execution. When not set, all commands are allowed.
Format: `{"allow": ["pattern1", "pattern2"], "deny": ["pattern3"], "allowRedirects": true}`
**Fields:**
- **allow** (array of strings): Glob patterns for allowed commands. If specified, only matching commands are permitted. Uses `*` to match any characters and `?` to match a single character. Setting allow on anything will deny all others.
- **deny** (array of strings): Glob patterns for denied commands. Deny rules take precedence over allow rules.
- **allowRedirects** (boolean): Whether to allow shell redirects (`>`, `>>`, `<`, etc.). Defaults to false.
**Rule evaluation:**
1. Check for dangerous characters (backticks outside single quotes, unquoted newlines)
2. Parse command into segments split by operators (`&&`, `||`, `|`, `;`)
3. If redirects detected and `allowRedirects` is not true, command is denied
4. Each segment is validated against deny rules first, then allow rules
5. Subshell contents (`$(...)` and `(...)`) are recursively validated
6. All segments must pass for the command to be allowed
**Examples:**
```bash
# Allow only npm and git commands.
export CLINE_COMMAND_PERMISSIONS='{"allow": ["npm *", "git *"]}'
# Allow development commands but deny dangerous ones. Deny not strictly required here since allow is set.
export CLINE_COMMAND_PERMISSIONS='{"allow": ["npm *", "git *", "node *"], "deny": ["rm -rf *", "sudo *"]}'
# Allow file operations with redirects
export CLINE_COMMAND_PERMISSIONS='{"allow": ["cat *", "echo *"], "allowRedirects": true}'
```
# CONFIGURATION FILES
```
~/.cline/
├── data/ # Default configuration directory
│ ├── globalState.json # Global settings and state
│ ├── secrets.json # API keys and secrets (stored securely)
│ ├── workspace/ # Workspace-specific state
│ └── tasks/ # Task history and conversation data
└── log/ # Log files for debugging
```
View logs with `cline dev log`.
# BUGS
Report bugs at: <https://github.com/cline/cline/issues>
For real-time help, join the Discord community at: <https://discord.gg/cline>
# SEE ALSO
Full documentation: <https://docs.cline.bot>
VS Code extension: <https://marketplace.visualstudio.com/items?itemName=saoudrizwan.claude-dev>
# AUTHORS
Cline is developed by Cline Bot Inc. and the open source community.
# COPYRIGHT
Copyright © 2025 Cline Bot Inc. Licensed under the Apache License 2.0.
+2950
View File
File diff suppressed because it is too large Load Diff
+81
View File
@@ -0,0 +1,81 @@
{
"name": "@cline/cli",
"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": {
"cline": "./dist/cli.mjs"
},
"type": "module",
"engines": {
"node": ">=20.0.0"
},
"scripts": {
"prepublishOnly": "npm run build:production",
"package:brew": "node ./scripts/update-brew-formula.mts",
"package": "npm pack --pack-destination ./dist",
"build": "node esbuild.mts",
"build:production": "node esbuild.mts --production",
"watch": "node esbuild.mts --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",
"claude",
"dev",
"mcp",
"openrouter",
"coding",
"agent",
"autonomous",
"chatgpt",
"sonnet",
"ai",
"llama",
"cli"
],
"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": "^19.2.9",
"dotenv": "^16.4.5",
"esbuild": "^0.25.0",
"ink-testing-library": "^4.0.0",
"rimraf": "^6.0.1",
"typescript": "^5.4.5",
"vitest": "^4.0.17"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.13.1",
"aws4fetch": "^1.0.20",
"chalk": "^5.3.0",
"commander": "^12.1.0",
"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": "^19.2.3"
}
}
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env node
import { execSync } from "node:child_process"
import { createHash } from "node:crypto"
import { readFile, unlink, writeFile } from "node:fs/promises"
import { dirname, join } from "node:path"
import { fileURLToPath } from "node:url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const CLI_DIR = join(__dirname, "..")
const FORMULA_PATH = join(CLI_DIR, "cline.rb")
interface PackageJson {
version: string
}
async function getLocalVersion(): Promise<string> {
const packageJson = JSON.parse(await readFile(join(CLI_DIR, "package.json"), "utf-8")) as PackageJson
return packageJson.version
}
async function packAndGetSHA256(version: string): Promise<string> {
console.log("Packing local package...")
execSync("npm run package", { cwd: CLI_DIR, stdio: "inherit" })
const tarballPath = join(CLI_DIR, "dist", `cline-cli-${version}.tgz`)
console.log(`Computing SHA256 for ${tarballPath}...`)
const buffer = await readFile(tarballPath)
const sha256 = createHash("sha256").update(buffer).digest("hex")
// Clean up the tarball
await unlink(tarballPath)
return sha256
}
async function updateFormula(version: string, sha256: string) {
console.log("Updating Homebrew formula...")
let formula = await readFile(FORMULA_PATH, "utf-8")
const tarballUrl = `https://registry.npmjs.org/cline/-/cline-${version}.tgz`
// Update URL - matches pattern like: url "https://registry.npmjs.org/cline/-/cline-1.0.10.tgz"
formula = formula.replace(/url "https:\/\/registry\.npmjs\.org\/cline\/-\/cline-[\d.]+\.tgz"/, `url "${tarballUrl}"`)
// Update SHA256
formula = formula.replace(/sha256 "[a-f0-9]+"/, `sha256 "${sha256}"`)
await writeFile(FORMULA_PATH, formula, "utf-8")
}
async function main() {
try {
const version = await getLocalVersion()
console.log(`\nLocal version: ${version}`)
const sha256 = await packAndGetSHA256(version)
console.log(`SHA256: ${sha256}`)
const tarballUrl = `https://registry.npmjs.org/cline/-/cline-${version}.tgz`
console.log(`Tarball URL: ${tarballUrl}`)
await updateFormula(version, sha256)
console.log("\n✓ Homebrew formula updated successfully!")
console.log("\nNext steps:")
console.log("1. Review the changes in cline.rb")
console.log("2. Test locally: brew install --build-from-source ./cline.rb")
console.log("3. Commit and push to your homebrew tap repository")
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
console.error(`\n✗ Error: ${errorMessage}\n`)
process.exit(1)
}
}
main()
+248
View File
@@ -0,0 +1,248 @@
/**
* ACP-based implementation of DiffViewProvider that uses the ACP client's
* filesystem capabilities for reading and writing files.
*
* This provider attempts to use the ACP client's fs/read_text_file and
* fs/write_text_file methods when available, falling back to the
* FileEditProvider's local filesystem implementation otherwise.
*
* @module acp
*/
import type * as acp from "@agentclientprotocol/sdk"
import { workspaceResolver } from "@core/workspace"
import { createDirectoriesForFile } from "@utils/fs"
import { getCwd } from "@utils/path"
import * as fs from "fs/promises"
import * as iconv from "iconv-lite"
import { HostProvider } from "@/hosts/host-provider"
import { FileEditProvider } from "@/integrations/editor/FileEditProvider"
import { detectEncoding } from "@/integrations/misc/extract-text"
import type { FileDiagnostics } from "@/shared/proto/index.cline"
import { Logger } from "@/shared/services/Logger"
/**
* A function that resolves the current session ID.
* This is used by ACPDiffViewProvider to get the session ID at runtime,
* since the provider may be created before a session exists.
*/
export type SessionIdResolver = () => string | undefined
/**
* A DiffViewProvider implementation that uses the ACP client's filesystem
* capabilities when available, with fallback to local filesystem operations.
*
* This class extends FileEditProvider and overrides the file I/O methods to
* use the ACP protocol's fs/read_text_file and fs/write_text_file requests
* when the client supports these capabilities. This allows the editor (client)
* to handle file operations, which enables features like:
* - Reading unsaved editor state
* - Tracking file modifications in the editor
* - Proper integration with the client's undo/redo stack
*/
export class ACPDiffViewProvider extends FileEditProvider {
private readonly connection: acp.AgentSideConnection
private readonly clientCapabilities: acp.ClientCapabilities | undefined
private readonly sessionIdResolver: SessionIdResolver
/**
* Creates a new ACPDiffViewProvider.
*
* @param connection - The ACP agent-side connection for making requests
* @param clientCapabilities - The client's advertised capabilities
* @param sessionIdResolver - A function that returns the current session ID
*/
constructor(
connection: acp.AgentSideConnection,
clientCapabilities: acp.ClientCapabilities | undefined,
sessionIdResolver: SessionIdResolver,
) {
super()
this.connection = connection
this.clientCapabilities = clientCapabilities
this.sessionIdResolver = sessionIdResolver
}
/**
* Gets the current session ID, or throws if no session is active.
*/
private getSessionId(): string {
const sessionId = this.sessionIdResolver()
if (!sessionId) {
throw new Error("No active ACP session. Cannot perform file operation.")
}
return sessionId
}
/**
* Check if the client supports file read operations.
*/
private canReadFile(): boolean {
return this.clientCapabilities?.fs?.readTextFile === true
}
/**
* Check if the client supports file write operations.
*/
private canWriteFile(): boolean {
return this.clientCapabilities?.fs?.writeTextFile === true
}
/**
* Opens a file for editing, using ACP fs capabilities when available.
*
* If the client supports fs/read_text_file, this method will read the file
* content via the ACP connection, which may include unsaved editor state.
* Otherwise, it falls back to the FileEditProvider's local fs implementation.
*/
override async open(relPath: string, options?: { displayPath?: string }): Promise<void> {
// If we can't read files via ACP, fall back to FileEditProvider
if (!this.canReadFile()) {
Logger.debug("[ACPDiffViewProvider] Client does not support fs.readTextFile, falling back to local fs")
return super.open(relPath, options)
}
// Set up state - this replicates the DiffViewProvider.open() logic
// but uses ACP for file reading instead of local fs
this.isEditing = true
const cwd = await getCwd()
const absolutePathResolved = workspaceResolver.resolveWorkspacePath(cwd, relPath, "ACPDiffViewProvider.open.absolutePath")
this.absolutePath = typeof absolutePathResolved === "string" ? absolutePathResolved : absolutePathResolved.absolutePath
this.relPath = options?.displayPath ?? relPath
const fileExists = this.editType === "modify"
// Read file content
if (fileExists) {
// Try to save any dirty state in the editor first
try {
await HostProvider.workspace.saveOpenDocumentIfDirty({
filePath: this.absolutePath!,
})
} catch {
// Ignore errors - the host may not support this
}
// Read file content via ACP
try {
Logger.debug("[ACPDiffViewProvider] Reading file via ACP:", this.absolutePath)
const response = await this.connection.readTextFile({
sessionId: this.getSessionId(),
path: this.absolutePath!,
})
this.originalContent = response.content
// ACP always returns UTF-8 text content
this.fileEncoding = "utf8"
Logger.debug("[ACPDiffViewProvider] Read file successfully, length:", response.content.length)
} catch (error) {
// If ACP read fails, fall back to local fs
Logger.debug("[ACPDiffViewProvider] ACP read failed, falling back to local fs:", error)
const fileBuffer = await fs.readFile(this.absolutePath!)
this.fileEncoding = await detectEncoding(fileBuffer)
this.originalContent = iconv.decode(fileBuffer, this.fileEncoding)
}
} else {
this.originalContent = ""
this.fileEncoding = "utf8"
}
// Create directories for new files
const createdDirs = await createDirectoriesForFile(this.absolutePath!)
// Store for potential cleanup - access via the private field workaround
;(this as any).createdDirs = createdDirs
// Make sure the file exists before we proceed
if (!fileExists) {
// For new files, write via ACP if possible, otherwise local fs
if (this.canWriteFile()) {
try {
await this.connection.writeTextFile({
sessionId: this.getSessionId(),
path: this.absolutePath!,
content: "",
})
} catch {
// Fall back to local fs
await fs.writeFile(this.absolutePath!, "")
}
} else {
await fs.writeFile(this.absolutePath!, "")
}
}
// Get diagnostics before editing
let preDiagnostics: FileDiagnostics[] = []
try {
preDiagnostics = (await HostProvider.workspace.getDiagnostics({})).fileDiagnostics
} catch {
preDiagnostics = []
}
;(this as any).preDiagnostics = preDiagnostics
// Call the parent's openDiffEditor to set up in-memory document content
await this.openDiffEditor()
await this.scrollEditorToLine(0)
;(this as any).streamedLines = []
}
/**
* Scrolls the editor to a specific line.
* No-op for file-based providers, but needed for protected access.
*/
protected override async scrollEditorToLine(_line: number): Promise<void> {
// No-op: No visual editor to scroll
}
/**
* Opens the diff editor.
*/
protected override async openDiffEditor(): Promise<void> {
// Set up in-memory document content from the original content
// no-op: No visual editor to open
}
/**
* Saves the document content, using ACP fs capabilities when available.
*
* If the client supports fs/write_text_file, this method will write the file
* content via the ACP connection. Otherwise, it falls back to the
* FileEditProvider's local fs implementation.
*/
protected override async saveDocument(): Promise<Boolean> {
// If we can't write files via ACP, fall back to FileEditProvider
if (!this.canWriteFile()) {
Logger.debug("[ACPDiffViewProvider] Client does not support fs.writeTextFile, falling back to local fs")
return super.saveDocument()
}
const content = await this.getContent()
if (!this.absolutePath || content === undefined) {
return false
}
try {
Logger.debug("[ACPDiffViewProvider] Writing file via ACP:", {
path: this.absolutePath,
contentLength: content.length,
})
await this.connection.writeTextFile({
sessionId: this.getSessionId(),
path: this.absolutePath,
content: content,
})
Logger.debug("[ACPDiffViewProvider] Write file successfully")
return true
} catch (error) {
// If ACP write fails, fall back to local fs
Logger.debug("[ACPDiffViewProvider] ACP write failed, falling back to local fs:", error)
return super.saveDocument()
}
}
}
@@ -0,0 +1,402 @@
/**
* ACP Host Bridge Client Provider
*
* Implements HostBridgeClientProvider for ACP mode, providing stub implementations
* of the 4 required service clients. These clients conform to the interfaces in
* host-bridge-client-types.ts and will use ACP connection capabilities where applicable.
*
* @module acp
*/
import type * as acp from "@agentclientprotocol/sdk"
import type {
DiffServiceClientInterface,
EnvServiceClientInterface,
WindowServiceClientInterface,
WorkspaceServiceClientInterface,
} 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 { Logger } from "@/shared/services/Logger"
/**
* Function type that resolves the current session ID.
* Returns undefined if no session is active.
*/
export type SessionIdResolver = () => string | undefined
/**
* Function type that resolves the current working directory.
* Returns undefined if no cwd is available (will fall back to process.cwd()).
*/
export type CwdResolver = () => string | undefined
/**
* ACP implementation of DiffService client.
*
* Handles diff operations for the ACP environment. Most operations are stubs
* that will be implemented in the next phase using ACP extension methods or
* the fs capabilities (readTextFile/writeTextFile).
*/
class ACPDiffServiceClient implements DiffServiceClientInterface {
async openDiff(_request: proto.host.OpenDiffRequest): Promise<proto.host.OpenDiffResponse> {
// Next phase: Could use ACP client capabilities to open a diff view in the editor.
// This would involve sending an ACP extension notification/request to the client
// to display a side-by-side diff of the original vs modified content.
Logger.debug("[ACPDiffServiceClient] openDiff called (stub)")
return proto.host.OpenDiffResponse.create({})
}
async getDocumentText(request: proto.host.GetDocumentTextRequest): Promise<proto.host.GetDocumentTextResponse> {
// Next phase: Use connection.readTextFile if clientCapabilities.fs.readTextFile is available.
// This would read the current document content from the editor, including any unsaved changes.
// For now, return empty content.
Logger.debug("[ACPDiffServiceClient] getDocumentText called (stub)", { diffId: request.diffId })
return proto.host.GetDocumentTextResponse.create({ content: "" })
}
async replaceText(_request: proto.host.ReplaceTextRequest): Promise<proto.host.ReplaceTextResponse> {
// Next phase: Use connection.writeTextFile if clientCapabilities.fs.writeTextFile is available.
// This would replace text in the document at the specified range.
Logger.debug("[ACPDiffServiceClient] replaceText called (stub)")
return proto.host.ReplaceTextResponse.create({})
}
async scrollDiff(_request: proto.host.ScrollDiffRequest): Promise<proto.host.ScrollDiffResponse> {
// Next phase: Send ACP extension notification to scroll the diff view to a specific line.
// No visual editor in ACP mode by default, so this is a no-op.
Logger.debug("[ACPDiffServiceClient] scrollDiff called (stub)")
return proto.host.ScrollDiffResponse.create({})
}
async truncateDocument(_request: proto.host.TruncateDocumentRequest): Promise<proto.host.TruncateDocumentResponse> {
// Next phase: Read file using readTextFile, truncate content, write back using writeTextFile.
// This is used to truncate a document to a specific line count.
Logger.debug("[ACPDiffServiceClient] truncateDocument called (stub)")
return proto.host.TruncateDocumentResponse.create({})
}
async saveDocument(_request: proto.host.SaveDocumentRequest): Promise<proto.host.SaveDocumentResponse> {
// Next phase: Use connection.writeTextFile to persist the document to disk.
// This saves the current document content to the file system.
Logger.debug("[ACPDiffServiceClient] saveDocument called (stub)")
return proto.host.SaveDocumentResponse.create({})
}
async closeAllDiffs(_request: proto.host.CloseAllDiffsRequest): Promise<proto.host.CloseAllDiffsResponse> {
// Next phase: Send ACP extension notification to close all diff views in the editor.
// No visual diff views in ACP mode by default, so this is a no-op.
Logger.debug("[ACPDiffServiceClient] closeAllDiffs called (stub)")
return proto.host.CloseAllDiffsResponse.create({})
}
async openMultiFileDiff(_request: proto.host.OpenMultiFileDiffRequest): Promise<proto.host.OpenMultiFileDiffResponse> {
// Next phase: Send ACP extension notification to open a multi-file diff view.
// This would display changes across multiple files in the editor.
Logger.debug("[ACPDiffServiceClient] openMultiFileDiff called (stub)")
return proto.host.OpenMultiFileDiffResponse.create({})
}
}
/**
* ACP implementation of EnvService client.
*
* Handles environment operations like clipboard access, version info, and telemetry.
* Most operations are stubs that will be implemented using ACP extension methods.
*/
class ACPEnvServiceClient implements EnvServiceClientInterface {
private readonly version: string
constructor(
_clientCapabilities: acp.ClientCapabilities | undefined,
_sessionIdResolver: SessionIdResolver,
version: string = "1.0.0",
) {
this.version = version
}
async debugLog(request: proto.cline.StringRequest): Promise<proto.cline.Empty> {
Logger.debug(request.value)
return proto.cline.Empty.create()
}
async clipboardWriteText(_request: proto.cline.StringRequest): Promise<proto.cline.Empty> {
Logger.debug("[ACPEnvServiceClient] clipboardWriteText called (stub)")
return proto.cline.Empty.create()
}
async clipboardReadText(_request: proto.cline.EmptyRequest): Promise<proto.cline.String> {
Logger.debug("[ACPEnvServiceClient] clipboardReadText called (stub)")
return proto.cline.String.create({ value: "" })
}
async getHostVersion(_request: proto.cline.EmptyRequest): Promise<proto.host.GetHostVersionResponse> {
// Return version info for the ACP agent.
return proto.host.GetHostVersionResponse.create({
version: this.version,
platform: "Cline ACP Agent",
clineType: ClineClient.Cli,
})
}
async getIdeRedirectUri(_request: proto.cline.EmptyRequest): Promise<proto.cline.String> {
Logger.debug("[ACPEnvServiceClient] getIdeRedirectUri called (stub)")
return proto.cline.String.create({ value: "" })
}
async getTelemetrySettings(_request: proto.cline.EmptyRequest): Promise<proto.host.GetTelemetrySettingsResponse> {
// Return telemetry as disabled by default in ACP mode.
return proto.host.GetTelemetrySettingsResponse.create({
isEnabled: proto.host.Setting.DISABLED,
})
}
subscribeToTelemetrySettings(
_request: proto.cline.EmptyRequest,
callbacks: StreamingCallbacks<proto.host.TelemetrySettingsEvent>,
): () => void {
// Send initial telemetry settings (disabled) and return unsubscribe function.
callbacks.onResponse(
proto.host.TelemetrySettingsEvent.create({
isEnabled: proto.host.Setting.DISABLED,
}),
)
// Return no-op unsubscribe function
return () => {}
}
async shutdown(_request: proto.cline.EmptyRequest): Promise<proto.cline.Empty> {
// Next phase: Graceful ACP connection shutdown.
// This would cleanly close the ACP connection and release resources.
Logger.debug("[ACPEnvServiceClient] shutdown called (stub)")
return proto.cline.Empty.create()
}
}
/**
* ACP implementation of WindowService client.
*
* Handles window/UI operations like showing documents, dialogs, and messages.
* Most operations are stubs that will be implemented using ACP extension methods.
*/
class ACPWindowServiceClient implements WindowServiceClientInterface {
constructor(_clientCapabilities: acp.ClientCapabilities | undefined, _sessionIdResolver: SessionIdResolver) {}
async showTextDocument(request: proto.host.ShowTextDocumentRequest): Promise<proto.host.TextEditorInfo> {
// Next phase: Send ACP extension request to open document in the editor.
// This would tell the ACP client to open the specified file.
Logger.debug("[ACPWindowServiceClient] showTextDocument called (stub)", { path: request.path })
return proto.host.TextEditorInfo.create({
documentPath: request.path,
})
}
async showOpenDialogue(_request: proto.host.ShowOpenDialogueRequest): Promise<proto.host.SelectedResources> {
// Next phase: Send ACP extension request for file picker dialog.
// This would display a file open dialog in the ACP client.
Logger.debug("[ACPWindowServiceClient] showOpenDialogue called (stub)")
return proto.host.SelectedResources.create({ paths: [] })
}
async showMessage(request: proto.host.ShowMessageRequest): Promise<proto.host.SelectedResponse> {
// Next phase: Send ACP extension notification to show message in the editor.
// This would display an information/warning/error message to the user.
Logger.debug("[ACPWindowServiceClient] showMessage called (stub)", {
message: request.message,
type: request.type,
})
return proto.host.SelectedResponse.create({})
}
async showInputBox(_request: proto.host.ShowInputBoxRequest): Promise<proto.host.ShowInputBoxResponse> {
// Next phase: Send ACP extension request for input dialog.
// This would display an input box for user text entry.
Logger.debug("[ACPWindowServiceClient] showInputBox called (stub)")
return proto.host.ShowInputBoxResponse.create({ response: "" })
}
async showSaveDialog(_request: proto.host.ShowSaveDialogRequest): Promise<proto.host.ShowSaveDialogResponse> {
// Next phase: Send ACP extension request for save dialog.
// This would display a file save dialog in the ACP client.
Logger.debug("[ACPWindowServiceClient] showSaveDialog called (stub)")
return proto.host.ShowSaveDialogResponse.create({ selectedPath: "" })
}
async openFile(request: proto.host.OpenFileRequest): Promise<proto.host.OpenFileResponse> {
// Next phase: Send ACP extension request to open file in the editor.
// This would open the specified file in the ACP client's editor.
Logger.debug("[ACPWindowServiceClient] openFile called (stub)", { filePath: request.filePath })
return proto.host.OpenFileResponse.create({})
}
async openSettings(_request: proto.host.OpenSettingsRequest): Promise<proto.host.OpenSettingsResponse> {
// Next phase: Send ACP extension request to open settings panel.
// This would open the settings/preferences in the ACP client.
Logger.debug("[ACPWindowServiceClient] openSettings called (stub)")
return proto.host.OpenSettingsResponse.create({})
}
async getOpenTabs(_request: proto.host.GetOpenTabsRequest): Promise<proto.host.GetOpenTabsResponse> {
// Next phase: Send ACP extension request to list open tabs/documents.
// This would return a list of currently open files in the editor.
Logger.debug("[ACPWindowServiceClient] getOpenTabs called (stub)")
return proto.host.GetOpenTabsResponse.create({ paths: [] })
}
async getVisibleTabs(_request: proto.host.GetVisibleTabsRequest): Promise<proto.host.GetVisibleTabsResponse> {
// Next phase: Send ACP extension request to list visible tabs.
// This would return a list of visible tabs/panes in the editor.
Logger.debug("[ACPWindowServiceClient] getVisibleTabs called (stub)")
return proto.host.GetVisibleTabsResponse.create({ paths: [] })
}
async getActiveEditor(_request: proto.host.GetActiveEditorRequest): Promise<proto.host.GetActiveEditorResponse> {
// Next phase: Send ACP extension request to get active editor info.
// This would return information about the currently focused editor.
Logger.debug("[ACPWindowServiceClient] getActiveEditor called (stub)")
return proto.host.GetActiveEditorResponse.create({})
}
}
/**
* ACP implementation of WorkspaceService client.
*
* Handles workspace operations like getting paths, diagnostics, and terminal commands.
* Uses the cwdResolver to get the current working directory, falling back to process.cwd().
*/
class ACPWorkspaceServiceClient implements WorkspaceServiceClientInterface {
private readonly _clientCapabilities: acp.ClientCapabilities | undefined
private readonly cwdResolver: CwdResolver
constructor(
clientCapabilities: acp.ClientCapabilities | undefined,
_sessionIdResolver: SessionIdResolver,
cwdResolver: CwdResolver,
) {
this._clientCapabilities = clientCapabilities
this.cwdResolver = cwdResolver
}
/**
* Get the current working directory, using the resolver if available,
* otherwise falling back to process.cwd().
*/
private getCwd(): string {
return this.cwdResolver() ?? process.cwd()
}
async getWorkspacePaths(_request: proto.host.GetWorkspacePathsRequest): Promise<proto.host.GetWorkspacePathsResponse> {
// Return the current working directory from the resolver.
const cwd = this.getCwd()
Logger.debug("[ACPWorkspaceServiceClient] getWorkspacePaths called", { cwd })
return proto.host.GetWorkspacePathsResponse.create({
paths: [cwd],
})
}
async saveOpenDocumentIfDirty(
_request: proto.host.SaveOpenDocumentIfDirtyRequest,
): Promise<proto.host.SaveOpenDocumentIfDirtyResponse> {
// Next phase: Use ACP extension or fs.writeTextFile to save dirty documents.
// This would save any unsaved changes in the specified document.
Logger.debug("[ACPWorkspaceServiceClient] saveOpenDocumentIfDirty called (stub)")
return proto.host.SaveOpenDocumentIfDirtyResponse.create({})
}
async getDiagnostics(_request: proto.host.GetDiagnosticsRequest): Promise<proto.host.GetDiagnosticsResponse> {
// Next phase: Send ACP extension request for diagnostics (errors, warnings).
// This would return linting/compilation errors from the ACP client.
Logger.debug("[ACPWorkspaceServiceClient] getDiagnostics called (stub)")
return proto.host.GetDiagnosticsResponse.create({ fileDiagnostics: [] })
}
async openProblemsPanel(_request: proto.host.OpenProblemsPanelRequest): Promise<proto.host.OpenProblemsPanelResponse> {
// Next phase: Send ACP extension notification to open the problems panel.
// This would show the diagnostics/problems view in the editor.
Logger.debug("[ACPWorkspaceServiceClient] openProblemsPanel called (stub)")
return proto.host.OpenProblemsPanelResponse.create({})
}
async openInFileExplorerPanel(
request: proto.host.OpenInFileExplorerPanelRequest,
): Promise<proto.host.OpenInFileExplorerPanelResponse> {
// Next phase: Send ACP extension notification to reveal file in explorer.
// This would highlight/reveal the specified path in the file tree.
Logger.debug("[ACPWorkspaceServiceClient] openInFileExplorerPanel called (stub)", { path: request.path })
return proto.host.OpenInFileExplorerPanelResponse.create({})
}
async openClineSidebarPanel(
_request: proto.host.OpenClineSidebarPanelRequest,
): Promise<proto.host.OpenClineSidebarPanelResponse> {
// Next phase: Send ACP extension notification to open Cline sidebar.
// This would show the Cline panel/sidebar in the editor.
Logger.debug("[ACPWorkspaceServiceClient] openClineSidebarPanel called (stub)")
return proto.host.OpenClineSidebarPanelResponse.create({})
}
async openTerminalPanel(_request: proto.host.OpenTerminalRequest): Promise<proto.host.OpenTerminalResponse> {
// Next phase: Send ACP extension notification or use createTerminal capability.
// This would open/show the terminal panel in the editor.
Logger.debug("[ACPWorkspaceServiceClient] openTerminalPanel called (stub)")
return proto.host.OpenTerminalResponse.create({})
}
async executeCommandInTerminal(
request: proto.host.ExecuteCommandInTerminalRequest,
): Promise<proto.host.ExecuteCommandInTerminalResponse> {
// Next phase: Use connection.createTerminal if clientCapabilities.terminal is available.
// This would execute the specified command in a terminal via the ACP client.
// The ACP SDK provides createTerminal() which returns a TerminalHandle with
// methods like currentOutput(), waitForExit(), kill(), and release().
Logger.debug("[ACPWorkspaceServiceClient] executeCommandInTerminal called (stub)", {
command: request.command,
hasTerminalCapability: this._clientCapabilities?.terminal,
})
return proto.host.ExecuteCommandInTerminalResponse.create({})
}
async openFolder(request: proto.host.OpenFolderRequest): Promise<proto.host.OpenFolderResponse> {
// Next phase: Send ACP extension request to change workspace/folder.
// This would open a new folder/workspace in the ACP client.
Logger.debug("[ACPWorkspaceServiceClient] openFolder called (stub)", { path: request.path })
return proto.host.OpenFolderResponse.create({ success: true })
}
}
/**
* ACP Host Bridge Client Provider
*
* Provides the 4 service clients required by HostBridgeClientProvider interface,
* implemented for the ACP environment. Uses the ACP connection and client capabilities
* to delegate operations to the ACP client where possible.
*/
export class ACPHostBridgeClientProvider implements HostBridgeClientProvider {
workspaceClient: WorkspaceServiceClientInterface
envClient: EnvServiceClientInterface
windowClient: WindowServiceClientInterface
diffClient: DiffServiceClientInterface
/**
* Creates a new ACPHostBridgeClientProvider.
*
* @param connection - The ACP agent-side connection for making requests
* @param clientCapabilities - The client's advertised capabilities
* @param sessionIdResolver - Function that returns the current session ID
* @param cwdResolver - Function that returns the current working directory
* @param debug - Whether to enable debug logging
* @param version - Version string for getHostVersion (optional)
*/
constructor(
clientCapabilities: acp.ClientCapabilities | undefined,
sessionIdResolver: SessionIdResolver,
cwdResolver: CwdResolver,
version: string = "1.0.0",
) {
this.workspaceClient = new ACPWorkspaceServiceClient(clientCapabilities, sessionIdResolver, cwdResolver)
this.envClient = new ACPEnvServiceClient(clientCapabilities, sessionIdResolver, version)
this.windowClient = new ACPWindowServiceClient(clientCapabilities, sessionIdResolver)
this.diffClient = new ACPDiffServiceClient()
}
}
+157
View File
@@ -0,0 +1,157 @@
/**
* AcpAgent - Thin wrapper that bridges stdio connection to ClineAgent.
*
* This class wraps the ClineAgent and connects it to an ACP AgentSideConnection
* for stdio-based communication. It:
* - Wires up the permission handler to call connection.requestPermission()
* - Subscribes to ClineAgent session events and forwards them to connection.sessionUpdate()
* - Delegates all acp.Agent methods to the internal ClineAgent
*
* For programmatic usage without stdio, use ClineAgent directly.
*
* @module acp
*/
import type * as acp from "@agentclientprotocol/sdk"
import { Logger } from "@/shared/services/Logger.js"
import { ClineAgent } from "../agent/ClineAgent.js"
import type { AcpAgentOptions, SessionUpdateType } from "../agent/types.js"
/**
* ACP Agent wrapper that bridges stdio connection to ClineAgent.
*
* This is the class used by runAcpMode() for stdio-based ACP communication.
* It creates an internal ClineAgent and wires up the connection for:
* - Permission requests (via connection.requestPermission)
* - Session updates (via connection.sessionUpdate)
*/
export class AcpAgent implements acp.Agent {
private readonly connection: acp.AgentSideConnection
private readonly clineAgent: ClineAgent
/** Track which sessions we've subscribed to for event forwarding */
private readonly subscribedSessions: Set<string> = new Set()
constructor(connection: acp.AgentSideConnection, options: AcpAgentOptions) {
this.connection = connection
// Create the internal ClineAgent
this.clineAgent = new ClineAgent(options)
// Wire up the permission handler to use the connection
this.clineAgent.setPermissionHandler(async (request, resolve) => {
try {
Logger.debug("[AcpAgent] Forwarding permission request to connection")
const response = await this.connection.requestPermission({
sessionId: this.getCurrentSessionId() ?? "",
toolCall: request.toolCall,
options: request.options,
})
resolve(response)
} catch (error) {
Logger.debug("[AcpAgent] Error requesting permission:", error)
resolve({ outcome: "rejected" as unknown as acp.RequestPermissionOutcome })
}
})
}
/**
* Get the current active session ID from the ClineAgent.
*/
private getCurrentSessionId(): string | undefined {
// Find the session that's currently processing
for (const [sessionId, session] of this.clineAgent.sessions) {
if (session.controller?.task) {
return sessionId
}
}
// Fall back to the first session if none is actively processing
const firstSession = this.clineAgent.sessions.keys().next()
return firstSession.done ? undefined : firstSession.value
}
/**
* Subscribe to session events and forward them to the connection.
*/
private subscribeToSessionEvents(sessionId: string): void {
if (this.subscribedSessions.has(sessionId)) {
return
}
const emitter = this.clineAgent.emitterForSession(sessionId)
// Forward session update by adding the sessionUpdate discriminator
const forwardSessionUpdate = <K extends SessionUpdateType>(eventName: K) => {
emitter.on(eventName, (payload: Record<string, unknown>) => {
const update = {
sessionUpdate: eventName,
...payload,
} as acp.SessionUpdate
this.connection.sessionUpdate({ sessionId, update }).catch((error) => {
Logger.error(`[AcpAgent] Error forwarding ${eventName}:`, error)
})
})
}
// Forward all standard session updates
forwardSessionUpdate("agent_message_chunk")
forwardSessionUpdate("agent_thought_chunk")
forwardSessionUpdate("tool_call")
forwardSessionUpdate("tool_call_update")
forwardSessionUpdate("available_commands_update")
forwardSessionUpdate("plan")
forwardSessionUpdate("current_mode_update")
forwardSessionUpdate("user_message_chunk")
forwardSessionUpdate("config_option_update")
forwardSessionUpdate("session_info_update")
// Handle errors specially (not part of ACP SessionUpdate)
emitter.on("error", (error) => {
Logger.error("[AcpAgent] Session error:", error)
})
this.subscribedSessions.add(sessionId)
}
// ============================================================
// acp.Agent Interface Implementation - Delegate to ClineAgent
// ============================================================
async initialize(params: acp.InitializeRequest): Promise<acp.InitializeResponse> {
return await this.clineAgent.initialize(params, this.connection)
}
async newSession(params: acp.NewSessionRequest): Promise<acp.NewSessionResponse> {
const response = await this.clineAgent.newSession(params)
// Subscribe to events for this new session
this.subscribeToSessionEvents(response.sessionId)
return response
}
async prompt(params: acp.PromptRequest): Promise<acp.PromptResponse> {
// Ensure we're subscribed to this session's events
this.subscribeToSessionEvents(params.sessionId)
return this.clineAgent.prompt(params)
}
async cancel(params: acp.CancelNotification): Promise<void> {
return this.clineAgent.cancel(params)
}
async setSessionMode(params: acp.SetSessionModeRequest): Promise<acp.SetSessionModeResponse> {
return this.clineAgent.setSessionMode(params)
}
async unstable_setSessionModel(params: acp.SetSessionModelRequest): Promise<acp.SetSessionModelResponse> {
return this.clineAgent.unstable_setSessionModel(params)
}
async authenticate(params: acp.AuthenticateRequest): Promise<acp.AuthenticateResponse> {
return this.clineAgent.authenticate(params)
}
async shutdown(): Promise<void> {
this.subscribedSessions.clear()
return this.clineAgent.shutdown()
}
}
File diff suppressed because it is too large Load Diff
+143
View File
@@ -0,0 +1,143 @@
/**
* Entry point for ACP (Agent Client Protocol) mode.
*
* When the CLI is invoked with `--acp`, this module sets up the ACP connection
* and runs Cline as an ACP-compliant agent communicating over stdio.
*
* This module exports:
* - `ClineAgent` - Decoupled agent for programmatic use (no stdio dependency)
* - `AcpAgent` - Thin wrapper that bridges stdio connection to ClineAgent
* - `ClineSessionEmitter` - Typed EventEmitter for per-session events
* - `runAcpMode` - Function to run Cline in stdio-based ACP mode
*
* @module acp
*/
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"
import { Logger } from "@/shared/services/Logger"
import { version as CLI_VERSION } from "../../../package.json"
import { AcpAgent } from "./AcpAgent.js"
import { nodeToWebReadable, nodeToWebWritable } from "./streamUtils.js"
// Re-export classes for programmatic use
export { ClineAgent } from "../agent/ClineAgent.js"
export { ClineSessionEmitter } from "../agent/ClineSessionEmitter.js"
// Re-export types
export type {
AcpAgentOptions,
AcpSessionState,
ClineAcpSession,
ClineAgentOptions,
ClineSessionEvents,
PermissionHandler,
PermissionResolver,
} from "../agent/types.js"
export { AcpAgent } from "./AcpAgent.js"
/** Original console methods for restoration if needed */
const originalConsole = {
log: console.log,
info: console.info,
warn: console.warn,
debug: console.debug,
error: console.error,
}
/**
* Redirect all console output to stderr.
*
* In ACP mode, stdout is reserved exclusively for JSON-RPC communication.
* All logging must go to stderr to avoid corrupting the protocol stream.
*/
function redirectConsoleToStderr(): void {
console.log = (...args) => console.error(...args)
console.info = (...args) => console.error(...args)
console.warn = (...args) => console.error(...args)
console.debug = (...args) => console.error(...args)
// console.error already goes to stderr
}
/**
* Restore console methods to their original behavior.
*/
export function restoreConsole(): void {
console.log = originalConsole.log
console.info = originalConsole.info
console.warn = originalConsole.warn
console.debug = originalConsole.debug
console.error = originalConsole.error
}
export interface AcpModeOptions {
/** Path to Cline configuration directory */
config?: string
/** Working directory (default: process.cwd()) */
cwd?: string
/** Enable verbose/debug logging to stderr */
verbose?: boolean
}
/**
* Run Cline in ACP mode.
*
* This function:
* 1. Redirects console output to stderr (stdout reserved for JSON-RPC)
* 2. Sets up the ndJsonStream for stdio communication
* 3. Creates the AgentSideConnection with our AcpAgent factory
* 4. Initializes the CLI infrastructure (StateManager, Controller, etc.)
* 5. Keeps the process alive until the connection closes
*
* @param options - Configuration options for ACP mode
*/
export async function runAcpMode(options: AcpModeOptions = {}): Promise<void> {
redirectConsoleToStderr()
const outputStream = nodeToWebWritable(process.stdout)
const inputStream = nodeToWebReadable(process.stdin)
const stream = ndJsonStream(outputStream, inputStream)
let agent: AcpAgent | null = null
new AgentSideConnection((conn) => {
agent = new AcpAgent(conn, {
version: CLI_VERSION,
debug: Boolean(options.verbose),
})
return agent
}, stream)
let isShuttingDown = false
const shutdown = async () => {
if (isShuttingDown) {
// Force exit on second signal
process.exit(1)
}
isShuttingDown = true
try {
await agent?.shutdown()
restoreConsole()
} catch (error) {
Logger.error("[ACP] Error during shutdown:", error)
}
process.exit(0)
}
process.on("SIGINT", shutdown)
process.on("SIGTERM", shutdown)
// Keep the process alive
// The ndJsonStream will handle stdin events automatically.
// We need to ensure the process doesn't exit while waiting for input.
process.stdin.resume()
// Handle stdin end (client disconnected)
process.stdin.on("end", shutdown)
// Handle stdin errors
process.stdin.on("error", async (error) => {
Logger.error("[ACP] stdin error:", error)
await shutdown()
})
Logger.info("[ACP] Process is now listening for ACP requests on stdin")
}
+54
View File
@@ -0,0 +1,54 @@
/**
* Stream conversion utilities for ACP mode.
*
* The ACP SDK's ndJsonStream function expects Web Streams (ReadableStream/WritableStream),
* but Node.js provides its own stream types. These utilities convert between them.
*
* @module acp/streamUtils
*/
import type { Readable, Writable } from "node:stream"
/**
* Convert a Node.js Writable stream to a Web WritableStream.
*
* Used to convert process.stdout for ACP output.
*
* @param nodeStream - Node.js Writable stream (e.g., process.stdout)
* @returns Web WritableStream compatible with ndJsonStream
*/
export function nodeToWebWritable(nodeStream: Writable): WritableStream<Uint8Array> {
return new WritableStream<Uint8Array>({
write(chunk) {
return new Promise<void>((resolve, reject) => {
nodeStream.write(Buffer.from(chunk), (err) => {
if (err) {
reject(err)
} else {
resolve()
}
})
})
},
})
}
/**
* Convert a Node.js Readable stream to a Web ReadableStream.
*
* Used to convert process.stdin for ACP input.
*
* @param nodeStream - Node.js Readable stream (e.g., process.stdin)
* @returns Web ReadableStream compatible with ndJsonStream
*/
export function nodeToWebReadable(nodeStream: Readable): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
start(controller) {
nodeStream.on("data", (chunk: Buffer) => {
controller.enqueue(new Uint8Array(chunk))
})
nodeStream.on("end", () => controller.close())
nodeStream.on("error", (err) => controller.error(err))
},
})
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,274 @@
/**
* Tests for ClineSessionEmitter - Typed EventEmitter for per-session ACP events.
*/
import { beforeEach, describe, expect, it, vi } from "vitest"
import { ClineSessionEmitter } from "./ClineSessionEmitter.js"
import type { SessionUpdatePayload } from "./types.js"
describe("ClineSessionEmitter", () => {
let emitter: ClineSessionEmitter
beforeEach(() => {
emitter = new ClineSessionEmitter()
})
describe("on/emit", () => {
it("should emit and receive agent_message_chunk events", () => {
const listener = vi.fn()
const payload: SessionUpdatePayload<"agent_message_chunk"> = {
content: { type: "text", text: "Hello, world!" },
}
emitter.on("agent_message_chunk", listener)
emitter.emit("agent_message_chunk", payload)
expect(listener).toHaveBeenCalledTimes(1)
expect(listener).toHaveBeenCalledWith(payload)
})
it("should emit and receive agent_thought_chunk events", () => {
const listener = vi.fn()
const payload: SessionUpdatePayload<"agent_thought_chunk"> = {
content: { type: "text", text: "Thinking..." },
}
emitter.on("agent_thought_chunk", listener)
emitter.emit("agent_thought_chunk", payload)
expect(listener).toHaveBeenCalledTimes(1)
expect(listener).toHaveBeenCalledWith(payload)
})
it("should emit and receive tool_call events", () => {
const listener = vi.fn()
const payload: SessionUpdatePayload<"tool_call"> = {
toolCallId: "test-tool-call-id",
title: "Test Tool Call",
status: "in_progress",
}
emitter.on("tool_call", listener)
emitter.emit("tool_call", payload)
expect(listener).toHaveBeenCalledTimes(1)
expect(listener).toHaveBeenCalledWith(payload)
})
it("should emit and receive tool_call_update events", () => {
const listener = vi.fn()
const payload: SessionUpdatePayload<"tool_call_update"> = {
toolCallId: "test-tool-call-id",
status: "completed",
rawOutput: { result: "success" },
}
emitter.on("tool_call_update", listener)
emitter.emit("tool_call_update", payload)
expect(listener).toHaveBeenCalledTimes(1)
expect(listener).toHaveBeenCalledWith(payload)
})
it("should emit and receive available_commands_update events", () => {
const listener = vi.fn()
const payload: SessionUpdatePayload<"available_commands_update"> = {
availableCommands: [{ name: "test", description: "Test command" }],
}
emitter.on("available_commands_update", listener)
emitter.emit("available_commands_update", payload)
expect(listener).toHaveBeenCalledTimes(1)
expect(listener).toHaveBeenCalledWith(payload)
})
it("should emit and receive current_mode_update events", () => {
const listener = vi.fn()
const payload: SessionUpdatePayload<"current_mode_update"> = {
currentModeId: "act",
}
emitter.on("current_mode_update", listener)
emitter.emit("current_mode_update", payload)
expect(listener).toHaveBeenCalledTimes(1)
expect(listener).toHaveBeenCalledWith(payload)
})
it("should emit and receive plan events", () => {
const listener = vi.fn()
const payload: SessionUpdatePayload<"plan"> = {
entries: [{ content: "Step 1", status: "pending", priority: "high" }],
}
emitter.on("plan", listener)
emitter.emit("plan", payload)
expect(listener).toHaveBeenCalledTimes(1)
expect(listener).toHaveBeenCalledWith(payload)
})
it("should emit and receive error events", () => {
const listener = vi.fn()
const error = new Error("Test error")
emitter.on("error", listener)
emitter.emit("error", error)
expect(listener).toHaveBeenCalledTimes(1)
expect(listener).toHaveBeenCalledWith(error)
})
})
describe("multiple listeners", () => {
it("should support multiple listeners for the same event", () => {
const listener1 = vi.fn()
const listener2 = vi.fn()
const payload: SessionUpdatePayload<"agent_message_chunk"> = {
content: { type: "text", text: "Hello" },
}
emitter.on("agent_message_chunk", listener1)
emitter.on("agent_message_chunk", listener2)
emitter.emit("agent_message_chunk", payload)
expect(listener1).toHaveBeenCalledTimes(1)
expect(listener2).toHaveBeenCalledTimes(1)
})
it("should call listeners in order of registration", () => {
const order: number[] = []
const listener1 = vi.fn(() => order.push(1))
const listener2 = vi.fn(() => order.push(2))
const payload: SessionUpdatePayload<"agent_message_chunk"> = {
content: { type: "text", text: "Hello" },
}
emitter.on("agent_message_chunk", listener1)
emitter.on("agent_message_chunk", listener2)
emitter.emit("agent_message_chunk", payload)
expect(order).toEqual([1, 2])
})
})
describe("off", () => {
it("should remove a specific listener", () => {
const listener = vi.fn()
const payload: SessionUpdatePayload<"agent_message_chunk"> = {
content: { type: "text", text: "Hello" },
}
emitter.on("agent_message_chunk", listener)
emitter.off("agent_message_chunk", listener)
emitter.emit("agent_message_chunk", payload)
expect(listener).not.toHaveBeenCalled()
})
it("should only remove the specified listener", () => {
const listener1 = vi.fn()
const listener2 = vi.fn()
const payload: SessionUpdatePayload<"agent_message_chunk"> = {
content: { type: "text", text: "Hello" },
}
emitter.on("agent_message_chunk", listener1)
emitter.on("agent_message_chunk", listener2)
emitter.off("agent_message_chunk", listener1)
emitter.emit("agent_message_chunk", payload)
expect(listener1).not.toHaveBeenCalled()
expect(listener2).toHaveBeenCalledTimes(1)
})
})
describe("once", () => {
it("should only call the listener once", () => {
const listener = vi.fn()
const payload: SessionUpdatePayload<"agent_message_chunk"> = {
content: { type: "text", text: "Hello" },
}
emitter.once("agent_message_chunk", listener)
emitter.emit("agent_message_chunk", payload)
emitter.emit("agent_message_chunk", payload)
expect(listener).toHaveBeenCalledTimes(1)
})
})
describe("removeAllListeners", () => {
it("should remove all listeners for a specific event", () => {
const listener1 = vi.fn()
const listener2 = vi.fn()
const payload: SessionUpdatePayload<"agent_message_chunk"> = {
content: { type: "text", text: "Hello" },
}
emitter.on("agent_message_chunk", listener1)
emitter.on("agent_message_chunk", listener2)
emitter.removeAllListeners("agent_message_chunk")
emitter.emit("agent_message_chunk", payload)
expect(listener1).not.toHaveBeenCalled()
expect(listener2).not.toHaveBeenCalled()
})
it("should remove all listeners when no event is specified", () => {
const listener1 = vi.fn()
const listener2 = vi.fn()
emitter.on("agent_message_chunk", listener1)
emitter.on("tool_call", listener2)
emitter.removeAllListeners()
emitter.emit("agent_message_chunk", { content: { type: "text", text: "Hello" } })
emitter.emit("tool_call", { toolCallId: "test", title: "Test" })
expect(listener1).not.toHaveBeenCalled()
expect(listener2).not.toHaveBeenCalled()
})
})
describe("listenerCount", () => {
it("should return the correct number of listeners", () => {
const listener1 = vi.fn()
const listener2 = vi.fn()
expect(emitter.listenerCount("agent_message_chunk")).toBe(0)
emitter.on("agent_message_chunk", listener1)
expect(emitter.listenerCount("agent_message_chunk")).toBe(1)
emitter.on("agent_message_chunk", listener2)
expect(emitter.listenerCount("agent_message_chunk")).toBe(2)
emitter.off("agent_message_chunk", listener1)
expect(emitter.listenerCount("agent_message_chunk")).toBe(1)
})
})
describe("chaining", () => {
it("should support method chaining", () => {
const listener = vi.fn()
const result = emitter.on("agent_message_chunk", listener).on("error", vi.fn()).off("error", vi.fn())
expect(result).toBe(emitter)
})
})
describe("emit return value", () => {
it("should return true when there are listeners", () => {
emitter.on("agent_message_chunk", vi.fn())
const result = emitter.emit("agent_message_chunk", { content: { type: "text", text: "Hello" } })
expect(result).toBe(true)
})
it("should return false when there are no listeners", () => {
const result = emitter.emit("agent_message_chunk", { content: { type: "text", text: "Hello" } })
expect(result).toBe(false)
})
})
})
+114
View File
@@ -0,0 +1,114 @@
/**
* Typed EventEmitter for per-session ACP events.
*
* This class provides a type-safe wrapper around Node's EventEmitter
* for emitting and subscribing to session-specific ACP events.
*
* @module acp
*/
import { EventEmitter } from "events"
import type { ClineSessionEvents } from "./types.js"
/**
* Type-safe EventEmitter for ClineAgent session events.
*
* Each session has its own emitter instance, allowing consumers to
* subscribe to events for specific sessions without filtering.
*
* @example
* ```typescript
* const agent = new ClineAgent({ version: "1.0.0" })
* const session = await agent.newSession({ cwd: "/path/to/project" })
*
* // Subscribe to session events
* agent.session(session.sessionId).on("agent_message_chunk", (content) => {
* console.log("Agent says:", content.text)
* })
*
* agent.session(session.sessionId).on("tool_call", (toolCall) => {
* console.log("Tool called:", toolCall.toolName)
* })
* ```
*/
export class ClineSessionEmitter {
private readonly emitter: EventEmitter
constructor() {
this.emitter = new EventEmitter()
// Increase max listeners since we may have many event types
this.emitter.setMaxListeners(20)
}
/**
* Subscribe to a session event.
*
* @param event - The event name to subscribe to
* @param listener - The callback function to invoke when the event is emitted
* @returns This emitter instance for chaining
*/
on<K extends keyof ClineSessionEvents>(event: K, listener: ClineSessionEvents[K]): this {
this.emitter.on(event, listener as (...args: unknown[]) => void)
return this
}
/**
* Subscribe to a session event for a single invocation.
*
* @param event - The event name to subscribe to
* @param listener - The callback function to invoke when the event is emitted
* @returns This emitter instance for chaining
*/
once<K extends keyof ClineSessionEvents>(event: K, listener: ClineSessionEvents[K]): this {
this.emitter.once(event, listener as (...args: unknown[]) => void)
return this
}
/**
* Unsubscribe from a session event.
*
* @param event - The event name to unsubscribe from
* @param listener - The callback function to remove
* @returns This emitter instance for chaining
*/
off<K extends keyof ClineSessionEvents>(event: K, listener: ClineSessionEvents[K]): this {
this.emitter.off(event, listener as (...args: unknown[]) => void)
return this
}
/**
* Emit a session event.
*
* @param event - The event name to emit
* @param args - The arguments to pass to the event listeners
* @returns True if the event had listeners, false otherwise
*/
emit<K extends keyof ClineSessionEvents>(event: K, ...args: Parameters<ClineSessionEvents[K]>): boolean {
return this.emitter.emit(event, ...args)
}
/**
* Remove all listeners for a specific event or all events.
*
* @param event - Optional event name to remove listeners for
* @returns This emitter instance for chaining
*/
removeAllListeners<K extends keyof ClineSessionEvents>(event?: K): this {
if (event) {
this.emitter.removeAllListeners(event)
} else {
this.emitter.removeAllListeners()
}
return this
}
/**
* Get the number of listeners for a specific event.
*
* @param event - The event name to count listeners for
* @returns The number of listeners
*/
listenerCount<K extends keyof ClineSessionEvents>(event: K): number {
return this.emitter.listenerCount(event)
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+356
View File
@@ -0,0 +1,356 @@
/**
* Permission handling for ACP integration.
*
* This module handles the translation between ACP permission requests/responses
* and Cline's internal permission system. It maps ClineAsk types to appropriate
* ACP permission options and translates user responses back to Cline's format.
*
* @module acp/permissionHandler
*/
import type * as acp from "@agentclientprotocol/sdk"
import type { ClineAsk } from "@shared/ExtensionMessage"
import type { ClineAskResponse } from "@shared/WebviewMessage"
import { Logger } from "@/shared/services/Logger.js"
import type { AcpSessionState, ClinePermissionOption } from "./types.js"
/**
* Standard permission options for operations that support "always allow".
* Used for commands, tools, and MCP server operations.
*/
const STANDARD_PERMISSION_OPTIONS: ClinePermissionOption[] = [
{ kind: "allow_once", optionId: "allow_once", name: "Allow Once" },
{ kind: "allow_always", optionId: "allow_always", name: "Always Allow" },
{ kind: "reject_once", optionId: "reject_once", name: "Reject" },
]
/**
* Permission options for operations that don't support "always allow".
* Used for browser actions and other one-time operations.
*/
const RESTRICTED_PERMISSION_OPTIONS: ClinePermissionOption[] = [
{ kind: "allow_once", optionId: "allow_once", name: "Allow Once" },
{ kind: "reject_once", optionId: "reject_once", name: "Reject" },
]
/**
* Mapping of ClineAsk types to their permission option sets.
*/
const ASK_TYPE_PERMISSION_MAP: Partial<Record<ClineAsk, ClinePermissionOption[]>> = {
// Commands support "always allow" for auto-approval
command: STANDARD_PERMISSION_OPTIONS,
// Tool operations support "always allow"
tool: STANDARD_PERMISSION_OPTIONS,
// MCP server operations support "always allow"
use_mcp_server: STANDARD_PERMISSION_OPTIONS,
// Browser actions are one-time, no "always allow"
browser_action_launch: RESTRICTED_PERMISSION_OPTIONS,
// Command output continuation - simple allow/reject
command_output: RESTRICTED_PERMISSION_OPTIONS,
}
/**
* ClineAsk types that require permission handling.
* Other ask types (like followup, plan_mode_respond) don't need permission UI.
*/
const PERMISSION_REQUIRING_ASK_TYPES: Set<ClineAsk> = new Set([
"command",
"tool",
"browser_action_launch",
"use_mcp_server",
"command_output",
])
/**
* Result of handling a permission response.
*/
export interface PermissionHandlerResult {
/** Cline's internal response type */
response: ClineAskResponse
/** Optional text to pass with the response */
text?: string
/** Whether "always allow" was selected (for auto-approval tracking) */
alwaysAllow?: boolean
/** Whether the request was cancelled */
cancelled?: boolean
}
/**
* Check if a ClineAsk type requires permission handling.
*
* @param askType - The ClineAsk type to check
* @returns True if the ask type requires permission UI
*/
export function requiresPermission(askType: ClineAsk): boolean {
return PERMISSION_REQUIRING_ASK_TYPES.has(askType)
}
/**
* Get the appropriate permission options for a ClineAsk type.
*
* @param askType - The ClineAsk type
* @returns Array of permission options, or undefined if the ask type doesn't require permission
*/
export function getPermissionOptionsForAskType(askType: ClineAsk): acp.PermissionOption[] | undefined {
const options = ASK_TYPE_PERMISSION_MAP[askType]
if (!options) {
return undefined
}
// Convert to ACP PermissionOption format
return options.map((opt) => ({
kind: opt.kind,
optionId: opt.optionId,
name: opt.name,
}))
}
/**
* Handle an ACP permission response and translate it to Cline's format.
*
* @param response - The ACP permission response from the client
* @param askType - The original ClineAsk type that triggered the permission request
* @returns The translated result for Cline's handleWebviewAskResponse
*/
export function handlePermissionResponse(response: acp.RequestPermissionResponse, askType: ClineAsk): PermissionHandlerResult {
// Check if cancelled
if (response.outcome.outcome === "cancelled") {
return {
response: "noButtonClicked",
cancelled: true,
}
}
// Get the selected option ID
const optionId = response.outcome.optionId
// Translate the option to Cline's response format
switch (optionId) {
case "allow_once":
return {
response: "yesButtonClicked",
alwaysAllow: false,
}
case "allow_always":
return {
response: "yesButtonClicked",
alwaysAllow: true,
}
case "reject_once":
case "reject_always":
return {
response: "noButtonClicked",
alwaysAllow: false,
}
default:
// Unknown option ID - treat as rejection for safety
Logger.error(`[permissionHandler] Unknown permission option: ${optionId}`)
return {
response: "noButtonClicked",
}
}
}
/**
* Create a permission request for an ACP tool call.
*
* @param toolCall - The ACP tool call that needs permission
* @param askType - The Cline ask type
* @returns The permission request options, or null if no permission needed
*/
export function createPermissionRequest(
toolCall: acp.ToolCall,
askType: ClineAsk,
): { toolCall: acp.ToolCall; options: acp.PermissionOption[] } | null {
const options = getPermissionOptionsForAskType(askType)
if (!options) {
return null
}
return {
toolCall,
options,
}
}
/**
* Track "always allow" decisions for auto-approval.
* This maintains a set of tool/command patterns that have been auto-approved.
*/
export class AutoApprovalTracker {
/** Set of auto-approved command prefixes */
private autoApprovedCommands: Set<string> = new Set()
/** Set of auto-approved tool names */
private autoApprovedTools: Set<string> = new Set()
/** Set of auto-approved MCP servers */
private autoApprovedMcpServers: Set<string> = new Set()
/**
* Record an "always allow" decision for a permission request.
*
* @param askType - The Cline ask type that was auto-approved
* @param identifier - The identifier for the operation (command, tool name, etc.)
*/
recordAlwaysAllow(askType: ClineAsk, identifier: string): void {
switch (askType) {
case "command":
// Store the first word of the command as the key
const commandPrefix = identifier.split(" ")[0]
this.autoApprovedCommands.add(commandPrefix)
break
case "tool":
this.autoApprovedTools.add(identifier)
break
case "use_mcp_server":
this.autoApprovedMcpServers.add(identifier)
break
}
}
/**
* Check if an operation has been auto-approved.
*
* @param askType - The Cline ask type
* @param identifier - The identifier for the operation
* @returns True if the operation was previously auto-approved
*/
isAutoApproved(askType: ClineAsk, identifier: string): boolean {
switch (askType) {
case "command":
const commandPrefix = identifier.split(" ")[0]
return this.autoApprovedCommands.has(commandPrefix)
case "tool":
return this.autoApprovedTools.has(identifier)
case "use_mcp_server":
return this.autoApprovedMcpServers.has(identifier)
default:
return false
}
}
/**
* Clear all auto-approval records.
*/
clear(): void {
this.autoApprovedCommands.clear()
this.autoApprovedTools.clear()
this.autoApprovedMcpServers.clear()
}
}
/**
* Process a pending permission request for a session.
*
* This function coordinates the permission flow:
* 1. Checks if the operation is already auto-approved
* 2. If not, requests permission from the ACP client
* 3. Tracks "always allow" decisions
* 4. Returns the translated result for Cline
*
* @param requestPermission - Function to request permission from the ACP client
* @param sessionId - The session ID
* @param toolCall - The tool call requiring permission
* @param askType - The Cline ask type
* @param identifier - Identifier for auto-approval tracking
* @param autoApprovalTracker - The auto-approval tracker
* @returns The permission handler result
*/
export async function processPermissionRequest(
requestPermission: (
sessionId: string,
toolCall: acp.ToolCall,
options: acp.PermissionOption[],
) => Promise<acp.RequestPermissionResponse>,
sessionId: string,
toolCall: acp.ToolCall,
askType: ClineAsk,
identifier: string,
autoApprovalTracker?: AutoApprovalTracker,
): Promise<PermissionHandlerResult> {
// Check if already auto-approved
if (autoApprovalTracker?.isAutoApproved(askType, identifier)) {
return {
response: "yesButtonClicked",
alwaysAllow: true,
}
}
// Get permission options for this ask type
const options = getPermissionOptionsForAskType(askType)
if (!options) {
// No permission options defined - allow by default
return {
response: "yesButtonClicked",
}
}
// Request permission from the ACP client
const response = await requestPermission(sessionId, toolCall, options)
// Handle the response
const result = handlePermissionResponse(response, askType)
// Track "always allow" decisions
if (result.alwaysAllow && autoApprovalTracker) {
autoApprovalTracker.recordAlwaysAllow(askType, identifier)
}
return result
}
/**
* Get the identifier for auto-approval tracking from a tool call.
*
* @param toolCall - The ACP tool call
* @param askType - The Cline ask type
* @returns The identifier string for auto-approval tracking
*/
export function getAutoApprovalIdentifier(toolCall: acp.ToolCall, askType: ClineAsk): string {
const rawInput = toolCall.rawInput as Record<string, unknown> | undefined
switch (askType) {
case "command":
return (rawInput?.command as string) || toolCall.title
case "tool":
// Try to get tool name from raw input or title
return (rawInput?.tool as string) || toolCall.title
case "use_mcp_server":
return (rawInput?.serverName as string) || toolCall.title
default:
return toolCall.toolCallId
}
}
/**
* Update the session state's pending tool call after permission is handled.
*
* @param sessionState - The session state to update
* @param toolCallId - The tool call ID that was handled
* @param approved - Whether the permission was approved
*/
export function updateSessionStateAfterPermission(sessionState: AcpSessionState, toolCallId: string, approved: boolean): void {
// Remove from pending tool calls
sessionState.pendingToolCalls.delete(toolCallId)
// Clear current tool call ID if it matches
if (sessionState.currentToolCallId === toolCallId && !approved) {
sessionState.currentToolCallId = undefined
}
}
+247
View File
@@ -0,0 +1,247 @@
/**
* Custom types and extensions for ACP integration with Cline CLI.
*
* This file extends the base ACP types with Cline-specific functionality.
*/
import type * as acp from "@agentclientprotocol/sdk"
import type { Controller } from "@/core/controller"
// ============================================================
// Session Update Type Utilities
// ============================================================
/**
* Extract the sessionUpdate discriminator value from a SessionUpdate variant.
*/
export type SessionUpdateType = acp.SessionUpdate["sessionUpdate"]
/**
* Extract the payload type for a given sessionUpdate discriminator value.
* This removes the `sessionUpdate` discriminator field from the type.
*/
export type SessionUpdatePayload<T extends SessionUpdateType> = Omit<
Extract<acp.SessionUpdate, { sessionUpdate: T }>,
"sessionUpdate"
>
// ============================================================
// Permission Handler Callback Types
// ============================================================
/**
* Callback to resolve a permission request with the user's response.
*/
export type PermissionResolver = (response: acp.RequestPermissionResponse) => void
/**
* Handler function for permission requests.
* Called when the agent needs permission for a tool call.
* The handler should present the request to the user and call resolve() with their response.
*/
export type PermissionHandler = (request: Omit<acp.RequestPermissionRequest, "sessionId">, resolve: PermissionResolver) => void
// ============================================================
// Session Event Emitter Types
// ============================================================
/**
* Maps ACP SessionUpdate types to their event listener signatures.
* Uses the sessionUpdate discriminator to derive event names and payload types.
*/
export type ClineSessionEvents = {
[K in SessionUpdateType]: (payload: SessionUpdatePayload<K>) => void
} & {
/** Error event for session-level errors (not part of ACP SessionUpdate) */
error: (error: Error) => void
}
// ============================================================
// ClineAgent Options (decoupled from connection)
// ============================================================
/**
* Options for creating a ClineAgent instance (decoupled from connection).
*/
export interface ClineAgentOptions {
/** CLI version string */
version: string
/** Whether debug logging is enabled */
debug?: boolean
}
// Re-export common ACP types for convenience
export type {
Agent,
AgentSideConnection,
AudioContent,
CancelNotification,
ContentBlock,
ImageContent,
InitializeRequest,
InitializeResponse,
LoadSessionRequest,
LoadSessionResponse,
McpServer,
ModelInfo,
NewSessionRequest,
NewSessionResponse,
PermissionOption,
PermissionOptionKind,
PromptRequest,
PromptResponse,
ReadTextFileRequest,
ReadTextFileResponse,
RequestPermissionRequest,
RequestPermissionResponse,
SessionConfigOption,
SessionModelState,
SessionNotification,
SessionUpdate,
SetSessionConfigOptionRequest,
SetSessionConfigOptionResponse,
SetSessionModelRequest,
SetSessionModelResponse,
SetSessionModeRequest,
SetSessionModeResponse,
StopReason,
TextContent,
ToolCall,
ToolCallStatus,
ToolCallUpdate,
ToolKind,
WriteTextFileRequest,
WriteTextFileResponse,
} from "@agentclientprotocol/sdk"
/**
* Cline-specific agent capabilities extending the ACP base capabilities.
*/
export interface ClineAgentCapabilities {
/** Support for loading sessions from disk */
loadSession: boolean
/** Prompt capabilities for the agent */
promptCapabilities: {
/** Support for image inputs */
image: boolean
/** Support for audio inputs */
audio: boolean
/** Support for embedded context (file resources) */
embeddedContext: boolean
}
/** MCP server passthrough capabilities */
mcpCapabilities: {
/** Support for HTTP MCP servers */
http: boolean
/** Support for SSE MCP servers */
sse: boolean
}
}
/**
* Cline agent info for ACP initialization response.
*/
export interface ClineAgentInfo {
name: "cline"
title: "Cline"
version: string
}
/**
* Extended session data stored by Cline for ACP sessions.
* Maps to Cline's task history structure.
*/
export interface ClineAcpSession {
/** Unique session/task ID */
sessionId: string
/** Working directory for the session */
cwd: string
/** Current mode (plan/act) */
mode: "plan" | "act"
/** MCP servers passed from the client */
mcpServers: acp.McpServer[]
/** Timestamp when session was created */
createdAt: number
/** Timestamp of last activity */
lastActivityAt: number
/** Whether this session was loaded from history (needs resume on first prompt) */
isLoadedFromHistory?: boolean
/** Controller instance for this session (manages task execution) */
controller?: Controller
/** Model ID override for plan mode (format: "provider/modelId") */
planModeModelId?: string
/** Model ID override for act mode (format: "provider/modelId") */
actModeModelId?: string
}
/**
* Permission option as presented to the ACP client.
*/
export interface ClinePermissionOption {
kind: acp.PermissionOptionKind
name: string
optionId: string
}
/**
* Mapping of Cline message types to their ACP session update equivalents.
*/
export type ClineToAcpUpdateMapping = {
/** Text messages from the agent */
text: "agent_message_chunk"
/** Reasoning/thinking from the agent */
reasoning: "agent_thought_chunk"
/** Markdown content from the agent */
markdown: "agent_message_chunk"
/** Tool execution */
tool: "tool_call"
/** Command execution */
command: "tool_call"
/** Command output */
command_output: "tool_call_update"
/** Task completion */
completion_result: "end_turn"
/** Error messages */
error: "tool_call_update" | "error"
}
/**
* Options for creating an ACP agent instance.
*/
export interface AcpAgentOptions {
/** CLI version string */
version: string
/** Whether debug logging is enabled */
debug?: boolean
}
/**
* Result of translating a Cline message to ACP session update(s).
* A single Cline message may produce multiple ACP updates.
*/
export interface TranslatedMessage {
/** The session updates to send */
updates: acp.SessionUpdate[]
/** Whether this message requires a permission request */
requiresPermission?: boolean
/** Permission request details if required */
permissionRequest?: Omit<acp.RequestPermissionRequest, "sessionId">
/** The toolCallId that was created/used (for tracking across streaming updates) */
toolCallId?: string
}
/**
* State tracking for an active ACP session within Cline.
*/
export interface AcpSessionState {
/** Session ID */
sessionId: string
/** Whether the session is currently processing a prompt */
isProcessing: boolean
/** Current tool call ID being executed (if any) */
currentToolCallId?: string
/** Whether the session has been cancelled */
cancelled: boolean
/** Accumulated tool calls for permission batching */
pendingToolCalls: Map<string, acp.ToolCall>
}
+194
View File
@@ -0,0 +1,194 @@
/**
* Account info view component
* Shows current provider, and for Cline provider: credit balance and organization name
*/
import { Box, Text } from "ink"
import React, { useCallback, useEffect, useState } from "react"
import { Controller } from "@/core/controller"
import { StateManager } from "@/core/storage/StateManager"
import { ClineAccountService } from "@/services/account/ClineAccountService"
import { AuthService, ClineAccountOrganization } from "@/services/auth/AuthService"
import { LoadingSpinner } from "./Spinner"
interface AccountInfoViewProps {
controller: Controller
}
/**
* Capitalize provider name for display
*/
function capitalize(str: string): string {
return str
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ")
}
/**
* Format balance as currency (balance is in microcredits, divide by 10000)
*/
function formatBalance(balance: number | null): string {
if (balance === null || balance === undefined) {
return "..."
}
return `$${(balance / 1000000).toFixed(2)}`
}
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)
const [email, setEmail] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const fetchAccountInfo = useCallback(async () => {
try {
setIsLoading(true)
setError(null)
// Get current provider from state
const stateManager = StateManager.get()
const mode = stateManager.getGlobalSettingsKey("mode") as string
const providerKey = mode === "act" ? "actModeApiProvider" : "planModeApiProvider"
const currentProvider = stateManager.getGlobalSettingsKey(providerKey) as string
setProvider(currentProvider || "cline")
// If using Cline provider, fetch additional info
if (currentProvider === "cline") {
const authService = AuthService.getInstance(controller)
// Wait for auth to be restored - poll until we have auth info or timeout
let authInfo = authService.getInfo()
let attempts = 0
const maxAttempts = 20 // 2 seconds max
while (!authInfo?.user?.uid && attempts < maxAttempts) {
await new Promise((resolve) => setTimeout(resolve, 100))
authInfo = authService.getInfo()
attempts++
}
// Get user info
if (authInfo?.user?.email) {
setEmail(authInfo.user.email)
} else {
// User not logged in to Cline
setEmail(null)
setIsLoading(false)
return
}
// Get organization info
const organizations = authService.getUserOrganizations()
if (organizations) {
const activeOrg = organizations.find((org) => org.active)
if (activeOrg) {
setOrganization(activeOrg)
}
}
// Fetch credit balance
try {
const accountService = ClineAccountService.getInstance()
const activeOrgId = authService.getActiveOrganizationId()
if (activeOrgId) {
// Fetch organization balance
const orgBalance = await accountService.fetchOrganizationCreditsRPC(activeOrgId)
if (orgBalance?.balance !== undefined) {
setBalance(orgBalance.balance)
}
} else {
// Fetch personal balance
const balanceData = await accountService.fetchBalanceRPC()
if (balanceData?.balance !== undefined) {
setBalance(balanceData.balance)
}
}
} catch {
// Balance fetch failed, but we can still show other info
// Don't log to console as it pollutes CLI output
}
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load account info")
} finally {
setIsLoading(false)
}
}, [controller])
useEffect(() => {
fetchAccountInfo()
}, [fetchAccountInfo])
if (isLoading) {
return (
<Box>
<LoadingSpinner />
<Text color="gray"> Loading account info...</Text>
</Box>
)
}
if (error) {
return (
<Box>
<Text color="red">Error: {error}</Text>
</Box>
)
}
// If not using Cline provider, just show the provider name
if (provider !== "cline") {
return (
<Box>
<Text color="gray">Provider: </Text>
<Text color="cyan">{capitalize(provider || "Not configured")}</Text>
</Box>
)
}
// Cline provider but not logged in
if (!email) {
return (
<Box>
<Text color="gray">Provider: </Text>
<Text color="cyan">Cline</Text>
<Text color="gray"> </Text>
<Text color="yellow">Not logged in (run 'cline auth' to sign in)</Text>
</Box>
)
}
// Cline provider - show full account info
return (
<Box flexDirection="column">
<Box>
<Text color="gray">Provider: </Text>
<Text color="cyan">Cline</Text>
{email && (
<Box>
<Text color="gray"> </Text>
<Text color="white">{email}</Text>
</Box>
)}
</Box>
<Box>
{organization ? (
<Box>
<Text color="gray">Organization: </Text>
<Text color="magenta">{organization.name}</Text>
</Box>
) : (
<Box>
<Text color="gray">Account: </Text>
<Text color="white">Personal</Text>
</Box>
)}
<Text color="gray"> Credits: </Text>
<Text color="green">{formatBalance(balance)}</Text>
</Box>
</Box>
)
})
+337
View File
@@ -0,0 +1,337 @@
/**
* 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 { useTerminalSize } from "../hooks/useTerminalSize"
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"
}
/**
* Determine which buttons are actually visible based on config
* Cancel is hidden in the CLI (ThinkingIndicator handles that with esc)
*/
export function getVisibleButtons(config: ButtonConfig) {
const hiddenActions = ["cancel"]
const hasPrimary = !!config.primaryText && !hiddenActions.includes(config.primaryAction || "")
const hasSecondary = !!config.secondaryText && !hiddenActions.includes(config.secondaryAction || "")
return { hasPrimary, hasSecondary }
}
/**
* 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
}
const { hasPrimary, hasSecondary } = getVisibleButtons(config)
if (!hasPrimary && !hasSecondary) {
return null
}
// Calculate button widths based on terminal width
const { columns: terminalWidth } = useTerminalSize()
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!, hasPrimary ? "2" : "1")}
</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>
)
}
+126
View File
@@ -0,0 +1,126 @@
import { Text } from "ink"
import { render } from "ink-testing-library"
import React from "react"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { App } from "./App"
// Mock the child components to isolate App routing logic
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", () => ({
HistoryView: ({ items }: any) => React.createElement(Text, null, `HistoryView: ${items?.length || 0} items`),
}))
vi.mock("./ConfigView", () => ({
ConfigView: ({ dataDir }: any) => React.createElement(Text, null, `ConfigView: ${dataDir}`),
}))
vi.mock("./AuthView", () => ({
AuthView: ({ quickSetup }: any) => React.createElement(Text, null, `AuthView: ${quickSetup?.provider || "no-provider"}`),
}))
vi.mock("../context/TaskContext", () => ({
TaskContextProvider: ({ children }: any) => children,
}))
vi.mock("../context/StdinContext", () => ({
StdinProvider: ({ children }: any) => children,
}))
// Mock useTerminalSize to prevent EventEmitter memory leak warnings from resize listeners
vi.mock("../hooks/useTerminalSize", () => ({
useTerminalSize: () => ({ columns: 80, rows: 24, resizeKey: 0 }),
}))
describe("App", () => {
const mockController = {
dispose: vi.fn(),
stateManager: { flushPendingState: vi.fn() },
}
beforeEach(() => {
vi.clearAllMocks()
})
describe("view routing", () => {
it("should render ChatView when view is task", () => {
const { lastFrame } = render(<App controller={mockController} taskId="test-task" view="task" />)
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")
})
it("should render HistoryView when view is history", () => {
const historyItems = [
{ id: "1", ts: Date.now(), task: "Task 1" },
{ id: "2", ts: Date.now(), task: "Task 2" },
]
const { lastFrame } = render(<App controller={mockController} historyItems={historyItems} view="history" />)
expect(lastFrame()).toContain("HistoryView")
expect(lastFrame()).toContain("2 items")
})
it("should render ConfigView when view is config", () => {
const { lastFrame } = render(
<App dataDir="/path/to/config" globalState={{ key: "value" }} view="config" workspaceState={{}} />,
)
expect(lastFrame()).toContain("ConfigView")
expect(lastFrame()).toContain("/path/to/config")
})
it("should render AuthView when view is auth", () => {
const { lastFrame } = render(<App authQuickSetup={{ provider: "openai" }} controller={mockController} view="auth" />)
expect(lastFrame()).toContain("AuthView")
expect(lastFrame()).toContain("openai")
})
it("should render ChatView when view is welcome", () => {
const { lastFrame } = render(
<App controller={mockController} onWelcomeExit={() => {}} onWelcomeSubmit={() => {}} view="welcome" />,
)
expect(lastFrame()).toContain("ChatView")
})
})
describe("default props", () => {
it("should use default verbose=false with jsonOutput", () => {
const { lastFrame } = render(<App controller={mockController} jsonOutput={true} view="task" />)
expect(lastFrame()).toContain("verbose=false")
})
it("should use empty array for historyItems by default", () => {
const { lastFrame } = render(<App controller={mockController} view="history" />)
expect(lastFrame()).toContain("0 items")
})
})
describe("props passing", () => {
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 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")
})
})
})
+279
View File
@@ -0,0 +1,279 @@
/**
* Main App component for Ink CLI
* Routes between different views (task, history, config)
*/
import { Box } from "ink"
import React, { ReactNode, useCallback, useState } from "react"
import { StdinProvider } from "../context/StdinContext"
import { TaskContextProvider } from "../context/TaskContext"
import { useTerminalSize } from "../hooks/useTerminalSize"
import { AuthView } from "./AuthView"
import { ChatView } from "./ChatView"
import { ConfigView } from "./ConfigView"
import { HistoryView } from "./HistoryView"
import { TaskJsonView } from "./TaskJsonView"
export type ViewType = "task" | "history" | "config" | "auth" | "welcome"
interface HistoryPagination {
page: number
totalPages: number
totalCount: number
limit: number
}
interface HookInfo {
name: string
enabled: boolean
absolutePath: string
}
interface WorkspaceHooks {
workspaceName: string
hooks: HookInfo[]
}
interface SkillInfo {
name: string
description: string
path: string
enabled: boolean
}
interface AppProps {
view: ViewType
taskId?: string
controller?: any
// Output Style
verbose?: boolean
jsonOutput?: boolean
// Status Callbacks
onComplete?: () => void
onError?: () => void
// For history view
historyItems?: Array<{ id: string; ts: number; task?: string; totalCost?: number; modelId?: string }>
historyAllItems?: Array<{ id: string; ts: number; task?: string; totalCost?: number; modelId?: string }>
historyPagination?: HistoryPagination
onHistoryPageChange?: (page: number) => void
// For config view
dataDir?: string
globalState?: Record<string, any>
workspaceState?: Record<string, any>
// Rules toggles
globalClineRulesToggles?: Record<string, boolean>
localClineRulesToggles?: Record<string, boolean>
localCursorRulesToggles?: Record<string, boolean>
localWindsurfRulesToggles?: Record<string, boolean>
localAgentsRulesToggles?: Record<string, boolean>
onToggleRule?: (isGlobal: boolean, rulePath: string, enabled: boolean, ruleType: string) => void
// Workflow toggles
globalWorkflowToggles?: Record<string, boolean>
localWorkflowToggles?: Record<string, boolean>
onToggleWorkflow?: (isGlobal: boolean, workflowPath: string, enabled: boolean) => void
// Hooks
hooksEnabled?: boolean
globalHooks?: HookInfo[]
workspaceHooks?: WorkspaceHooks[]
onToggleHook?: (isGlobal: boolean, hookName: string, enabled: boolean, workspaceName?: string) => void
// Skills
skillsEnabled?: boolean
globalSkills?: SkillInfo[]
localSkills?: SkillInfo[]
onToggleSkill?: (isGlobal: boolean, skillPath: string, enabled: boolean) => void
// For auth view
authQuickSetup?: {
provider?: string
apikey?: string
modelid?: string
baseurl?: string
}
// 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,
historyItems = [],
historyAllItems,
historyPagination,
onHistoryPageChange,
dataDir = "",
globalState = {},
workspaceState = {},
// Rules
globalClineRulesToggles,
localClineRulesToggles,
localCursorRulesToggles,
localWindsurfRulesToggles,
localAgentsRulesToggles,
onToggleRule,
// Workflows
globalWorkflowToggles,
localWorkflowToggles,
onToggleWorkflow,
// Hooks
hooksEnabled,
globalHooks,
workspaceHooks,
onToggleHook,
// Skills
skillsEnabled,
globalSkills,
localSkills,
onToggleSkill,
authQuickSetup,
onWelcomeSubmit,
onWelcomeExit,
initialPrompt,
initialImages,
isRawModeSupported = true,
robotTopRow,
}) => {
const { resizeKey } = useTerminalSize()
const [currentView, setCurrentView] = useState<ViewType>(initialView)
const [selectedTaskId, setSelectedTaskId] = useState<string | undefined>(taskId)
const handleSelectTask = useCallback((taskId: string) => {
setSelectedTaskId(taskId)
setCurrentView("task")
}, [])
const handleNavigateToWelcome = useCallback(() => {
setCurrentView("welcome")
}, [])
// Handle welcome submit when navigating internally (e.g., from auth -> welcome)
const _handleInternalWelcomeSubmit = useCallback(
async (prompt: string, imagePaths: string[]) => {
if (onWelcomeSubmit) {
// If external handler provided, use it
onWelcomeSubmit(prompt, imagePaths)
} else if (controller && prompt.trim()) {
// Otherwise, start a task directly via controller
setCurrentView("task")
// Convert image paths to data URLs if needed
const imageDataUrls =
imagePaths.length > 0
? await Promise.all(
imagePaths.map(async (p) => {
try {
const fs = await import("fs/promises")
const path = await import("path")
const data = await fs.readFile(p)
const ext = path.extname(p).toLowerCase().slice(1)
const mimeType = ext === "jpg" ? "jpeg" : ext
return `data:image/${mimeType};base64,${data.toString("base64")}`
} catch {
return null
}
}),
)
: []
const validImages = imageDataUrls.filter((img): img is string => img !== null)
await controller.initTask(prompt.trim(), validImages.length > 0 ? validImages : undefined)
}
},
[onWelcomeSubmit, controller],
)
let content: ReactNode
switch (currentView) {
case "history":
content = (
<HistoryView
allItems={historyAllItems}
controller={controller}
items={historyItems}
onPageChange={onHistoryPageChange}
onSelectTask={handleSelectTask}
pagination={historyPagination}
/>
)
break
case "config":
content = (
<ConfigView
dataDir={dataDir}
globalClineRulesToggles={globalClineRulesToggles}
globalHooks={globalHooks}
globalSkills={globalSkills}
globalState={globalState}
globalWorkflowToggles={globalWorkflowToggles}
hooksEnabled={hooksEnabled}
localAgentsRulesToggles={localAgentsRulesToggles}
localClineRulesToggles={localClineRulesToggles}
localCursorRulesToggles={localCursorRulesToggles}
localSkills={localSkills}
localWindsurfRulesToggles={localWindsurfRulesToggles}
localWorkflowToggles={localWorkflowToggles}
onToggleHook={onToggleHook}
onToggleRule={onToggleRule}
onToggleSkill={onToggleSkill}
onToggleWorkflow={onToggleWorkflow}
skillsEnabled={skillsEnabled}
workspaceHooks={workspaceHooks}
workspaceState={workspaceState}
/>
)
break
case "auth":
content = (
<AuthView
controller={controller}
onComplete={onComplete}
onError={onError}
onNavigateToWelcome={handleNavigateToWelcome}
quickSetup={authQuickSetup}
/>
)
break
case "task":
case "welcome":
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 (
<StdinProvider isRawModeSupported={isRawModeSupported}>
<Box key={resizeKey}>{content}</Box>
</StdinProvider>
)
}
File diff suppressed because it is too large Load Diff
+374
View File
@@ -0,0 +1,374 @@
/**
* User input prompt component
* Handles different types of user interactions (text input, confirmations, choices)
*/
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"
interface AskPromptProps {
onRespond?: (response: string) => void
}
type PromptType = "confirmation" | "text" | "options" | "plan_mode_text" | "completion" | "exit_confirmation" | "none"
function getPromptType(ask: ClineAsk, text: string): PromptType {
switch (ask) {
case "followup": {
const parts = jsonParseSafe(text, {
question: undefined as string | undefined,
options: undefined as string[] | undefined,
})
if (parts.options && parts.options.length > 0) {
return "options"
}
return "text"
}
case "plan_mode_respond": {
const parts = jsonParseSafe(text, {
question: undefined as string | undefined,
options: undefined as string[] | undefined,
})
if (parts.options && parts.options.length > 0) {
return "options"
}
// Plan mode without options - allow text input or toggle to Act mode
return "plan_mode_text"
}
case "completion_result":
// Task completed - allow follow-up question or exit
return "completion"
case "resume_task":
case "resume_completed_task":
return "exit_confirmation"
case "command":
case "tool":
case "browser_action_launch":
case "use_mcp_server":
return "confirmation"
default:
return "none"
}
}
export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
const { exit } = useApp()
const { isRawModeSupported } = useStdinContext()
const controller = useTaskController()
const lastAskMessage = useLastCompletedAskMessage()
const [textInput, setTextInput] = useState("")
const [responded, setResponded] = useState(false)
const lastAskTs = useRef<number | null>(null)
// Reset state when ask message changes
useEffect(() => {
if (lastAskMessage && lastAskMessage.ts !== lastAskTs.current) {
lastAskTs.current = lastAskMessage.ts
setTextInput("")
setResponded(false)
}
}, [lastAskMessage])
const sendResponse = useCallback(
async (responseType: string, text?: string) => {
if (responded || !controller?.task) {
return
}
setResponded(true)
try {
await controller.task.handleWebviewAskResponse(responseType, text)
onRespond?.(text || responseType)
} catch {
// Controller may be disposed
}
},
[controller, responded, onRespond],
)
const toggleToActMode = useCallback(async () => {
if (responded || !controller) {
return
}
setResponded(true)
try {
await controller.togglePlanActMode("act")
onRespond?.("Switched to Act mode")
} catch {
// Controller may be disposed
}
}, [controller, responded, onRespond])
// Handle keyboard input
useInput(
(input, key) => {
// Filter out mouse escape sequences
if (isMouseEscapeSequence(input)) {
return
}
if (!lastAskMessage || responded) {
return
}
const ask = lastAskMessage.ask as ClineAsk
const text = lastAskMessage.text || ""
const promptType = getPromptType(ask, text)
if (promptType === "confirmation" || promptType === "exit_confirmation") {
// y/n confirmation
if (input.toLowerCase() === "y") {
sendResponse("yesButtonClicked")
} else if (input.toLowerCase() === "n") {
if (promptType === "exit_confirmation") {
exit()
return
}
sendResponse("noButtonClicked")
}
} else if (promptType === "options") {
// Number selection for options, or free text input
const parts = jsonParseSafe(text, { options: [] as string[] })
if (key.return) {
// Submit free text on Enter
if (textInput.trim()) {
sendResponse("messageResponse", textInput.trim())
}
} else if (key.backspace || key.delete) {
setTextInput((prev) => prev.slice(0, -1))
} else if (input && !key.ctrl && !key.meta) {
// Check if it's a number for option selection (only when no text typed yet)
const num = parseInt(input, 10)
if (textInput === "" && !Number.isNaN(num) && num >= 1 && num <= parts.options.length) {
const selectedOption = parts.options[num - 1]
sendResponse("messageResponse", selectedOption)
} else {
// Regular character input for free text
setTextInput((prev) => prev + input)
}
}
} else if (promptType === "text") {
// Text input mode
if (key.return) {
// Submit on Enter
if (textInput.trim()) {
sendResponse("messageResponse", textInput.trim())
}
} else if (key.backspace || key.delete) {
setTextInput((prev) => prev.slice(0, -1))
} else if (input && !key.ctrl && !key.meta) {
// Regular character input
setTextInput((prev) => prev + input)
}
} else if (promptType === "plan_mode_text") {
// Plan mode text input - allows text response or toggle to Act mode
if (key.return) {
// Submit on Enter
if (textInput.trim()) {
sendResponse("messageResponse", textInput.trim())
} else {
// Empty enter = switch to Act mode
toggleToActMode()
}
} else if (key.backspace || key.delete) {
setTextInput((prev) => prev.slice(0, -1))
} else if (input && !key.ctrl && !key.meta) {
// Regular character input
setTextInput((prev) => prev + input)
}
} else if (promptType === "completion") {
// Task completed - allow follow-up question or exit
if (key.return) {
if (textInput.trim()) {
// Send follow-up question
sendResponse("messageResponse", textInput.trim())
} else {
// Empty enter = confirm completion (exit)
sendResponse("yesButtonClicked")
}
} else if (key.backspace || key.delete) {
setTextInput((prev) => prev.slice(0, -1))
} else if (input && !key.ctrl && !key.meta) {
// Regular character input
setTextInput((prev) => prev + input)
}
}
},
{ isActive: isRawModeSupported && !!lastAskMessage && !responded },
)
if (!lastAskMessage || responded) {
return null
}
const ask = lastAskMessage.ask as ClineAsk
const text = lastAskMessage.text || ""
const promptType = getPromptType(ask, text)
const icon = getCliMessagePrefixIcon(lastAskMessage)
if (promptType === "none") {
return null
}
switch (ask) {
case "followup": {
const parts = jsonParseSafe(text, {
question: undefined as string | undefined,
options: undefined as string[] | undefined,
})
if (parts.options && parts.options.length > 0) {
return (
<Box flexDirection="column" marginTop={1}>
<Text color="cyan">Select an option (enter number):</Text>
{parts.options.map((opt, idx) => (
<Box key={idx} marginLeft={2}>
<Text>{`${idx + 1}. ${opt}`}</Text>
</Box>
))}
<Box marginTop={1}>
<Text>{icon} </Text>
<Text color="cyan">Or type: </Text>
<Text>{textInput}</Text>
<Text inverse> </Text>
</Box>
<Text color="gray">(Enter number to select, or type response + Enter)</Text>
</Box>
)
}
// Text input prompt
return (
<Box flexDirection="column" marginTop={1}>
<Box>
<Text>{icon} </Text>
<Text color="cyan">Reply: </Text>
<Text>{textInput}</Text>
<Text inverse> </Text>
</Box>
<Text color="gray">(Type your response and press Enter)</Text>
</Box>
)
}
case "plan_mode_respond": {
const parts = jsonParseSafe(text, {
question: undefined as string | undefined,
options: undefined as string[] | undefined,
})
if (parts.options && parts.options.length > 0) {
return (
<Box flexDirection="column" marginTop={1}>
<Text color="cyan">Select an option (enter number):</Text>
{parts.options.map((opt, idx) => (
<Box key={idx} marginLeft={2}>
<Text>{`${idx + 1}. ${opt}`}</Text>
</Box>
))}
<Box marginTop={1}>
<Text>{icon} </Text>
<Text color="cyan">Or type: </Text>
<Text>{textInput}</Text>
<Text inverse> </Text>
</Box>
<Text color="gray">(Enter number to select, or type response + Enter)</Text>
</Box>
)
}
// Plan mode text input - show option to switch to Act mode
return (
<Box flexDirection="column" marginTop={1}>
<Box>
<Text>{icon} </Text>
<Text color="cyan">Reply: </Text>
<Text>{textInput}</Text>
<Text inverse> </Text>
</Box>
<Text color="gray">(Type response + Enter, or just Enter to switch to Act mode)</Text>
</Box>
)
}
case "command":
return (
<Box flexDirection="column" marginTop={1}>
<Box>
<Text>{icon} </Text>
<Text color="yellow"> Execute this command? </Text>
<Text color="gray">(y/n)</Text>
</Box>
</Box>
)
case "tool":
return (
<Box flexDirection="column" marginTop={1}>
<Box>
<Text>{icon} </Text>
<Text color="blue"> Use this tool? </Text>
<Text color="gray">(y/n)</Text>
</Box>
</Box>
)
case "completion_result":
return (
<Box flexDirection="column" marginTop={1}>
<Box>
<Text>{icon} </Text>
<Text color="cyan">Follow-up: </Text>
<Text>{textInput}</Text>
<Text inverse> </Text>
</Box>
<Text color="gray">(Type follow-up question + Enter, or q to exit)</Text>
</Box>
)
case "resume_task":
case "resume_completed_task":
return (
<Box flexDirection="column" marginTop={1}>
<Box>
<Text>{icon} </Text>
<Text color="cyan"> Resume task? </Text>
<Text color="gray">(y/n)</Text>
</Box>
</Box>
)
case "browser_action_launch":
return (
<Box flexDirection="column" marginTop={1}>
<Box>
<Text>{icon} </Text>
<Text color="cyan"> Launch browser? </Text>
<Text color="gray">(y/n)</Text>
</Box>
</Box>
)
case "use_mcp_server":
return (
<Box flexDirection="column" marginTop={1}>
<Box>
<Text>{icon} </Text>
<Text color="cyan"> Use MCP server? </Text>
<Text color="gray">(y/n)</Text>
</Box>
</Box>
)
default:
return null
}
}
+991
View File
@@ -0,0 +1,991 @@
/**
* Auth view component
* Handles interactive authentication and provider configuration
*/
import { Box, Text, useApp, useInput } from "ink"
import Spinner from "ink-spinner"
import React, { useCallback, useEffect, useMemo, useState } from "react"
import { StateManager } from "@/core/storage/StateManager"
import { openAiCodexOAuthManager } from "@/integrations/openai-codex/oauth"
import { AuthService } from "@/services/auth/AuthService"
import { API_PROVIDERS_LIST, openAiCodexDefaultModelId, openRouterDefaultModelId } from "@/shared/api"
import { ProviderToApiKeyMap } from "@/shared/storage"
import { openExternal } from "@/utils/env"
import { COLORS } from "../constants/colors"
import { getAllFeaturedModels } from "../constants/featured-models"
import { useStdinContext } from "../context/StdinContext"
import { useScrollableList } from "../hooks/useScrollableList"
import { type DetectedSources, detectImportSources, type ImportSource } from "../utils/import-configs"
import { isMouseEscapeSequence } from "../utils/input"
import { ApiKeyInput } from "./ApiKeyInput"
import { StaticRobotFrame } from "./AsciiMotionCli"
import { type BedrockConfig, BedrockSetup } from "./BedrockSetup"
import { ImportView } from "./ImportView"
import { getDefaultModelId, hasModelPicker, ModelPicker } from "./ModelPicker"
import { getProviderLabel, getProviderOrder } from "./ProviderPicker"
type AuthStep =
| "menu"
| "provider"
| "apikey"
| "modelid"
| "baseurl"
| "saving"
| "success"
| "error"
| "cline_auth"
| "cline_model"
| "openai_codex_auth"
| "bedrock"
| "import"
// Featured models loaded from shared constants
const featuredModels = getAllFeaturedModels()
interface AuthViewProps {
controller: any
onComplete?: () => void
onError?: () => void
onNavigateToWelcome?: () => void
// Quick setup options
quickSetup?: {
provider?: string
apikey?: string
modelid?: string
baseurl?: string
}
}
interface SelectItem {
label: string
value: string
}
/**
* Select component with keyboard navigation
*/
const Select: React.FC<{
items: SelectItem[]
onSelect: (value: string) => void
label?: string
}> = ({ items, onSelect, label }) => {
const { isRawModeSupported } = useStdinContext()
const [selectedIndex, setSelectedIndex] = useState(0)
useInput(
(input, key) => {
if (key.upArrow) {
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1))
} else if (key.downArrow) {
setSelectedIndex((prev) => (prev < items.length - 1 ? prev + 1 : 0))
} else if (key.return) {
onSelect(items[selectedIndex].value)
}
},
{ isActive: isRawModeSupported },
)
return (
<Box flexDirection="column">
{label && (
<Text bold color="cyan">
{label}
</Text>
)}
{items.map((item, index) => (
<Box key={item.value}>
<Text color={index === selectedIndex ? COLORS.primaryBlue : undefined}>
{index === selectedIndex ? " " : " "}
{item.label}
</Text>
</Box>
))}
<Text color="gray">(Use arrow keys to navigate, Enter to select)</Text>
</Box>
)
}
/**
* Text input component - minimal, just the input field
*/
const TextInput: React.FC<{
value: string
onChange: (value: string) => void
onSubmit: (value: string) => void
placeholder?: string
isPassword?: boolean
}> = ({ value, onChange, onSubmit, placeholder, isPassword }) => {
const { isRawModeSupported } = useStdinContext()
useInput(
(input, key) => {
// Filter out mouse escape sequences
if (isMouseEscapeSequence(input)) {
return
}
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 },
)
const displayValue = isPassword ? "•".repeat(value.length) : value
return (
<Box>
<Text color="white">{displayValue || placeholder || ""}</Text>
<Text inverse> </Text>
</Box>
)
}
export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onError, onNavigateToWelcome, quickSetup }) => {
const { exit } = useApp()
const [step, setStep] = useState<AuthStep>(quickSetup ? "saving" : "menu")
const [selectedProvider, setSelectedProvider] = useState<string>(
StateManager.get().getApiConfiguration().actModeApiProvider ||
StateManager.get().getApiConfiguration().planModeApiProvider ||
"",
)
const [apiKey, setApiKey] = useState("")
const [modelId, setModelId] = useState("")
const [baseUrl, setBaseUrl] = useState("")
const [errorMessage, setErrorMessage] = useState("")
const [authStatus, setAuthStatus] = useState<string>("")
const [providerSearch, setProviderSearch] = useState("")
const [providerIndex, setProviderIndex] = useState(0)
const [clineModelIndex, setClineModelIndex] = useState(0)
const [importSources, setImportSources] = useState<DetectedSources>({ codex: false, opencode: false })
const [importSource, setImportSource] = useState<ImportSource | null>(null)
const [bedrockConfig, setBedrockConfig] = useState<BedrockConfig | null>(null)
// Use providers.json order, filtered to only available providers
const sortedProviders = useMemo(() => {
const availableProviders = new Set(API_PROVIDERS_LIST)
return getProviderOrder().filter((p) => availableProviders.has(p))
}, [])
// Main menu items - conditionally include import options
const mainMenuItems: SelectItem[] = useMemo(() => {
const items: SelectItem[] = [{ label: "Sign in with Cline account", value: "cline_auth" }]
// Add OpenAI Codex option for ChatGPT subscribers
items.push({ label: "Sign in with ChatGPT Subscription", value: "openai_codex_auth" })
// Add import options if detected
if (importSources.codex) {
items.push({ label: "Import from Codex CLI", value: "import_codex" })
}
if (importSources.opencode) {
items.push({ label: "Import from OpenCode", value: "import_opencode" })
}
items.push({ label: "Use your own API key", value: "configure_byo" })
items.push({ label: "Exit", value: "exit" })
return items
}, [importSources])
// Provider menu items - filtered by search (searches both ID and display name)
const providerItems: SelectItem[] = useMemo(() => {
const search = providerSearch.toLowerCase()
const filtered = providerSearch
? sortedProviders.filter(
(p) => p.toLowerCase().includes(search) || getProviderLabel(p).toLowerCase().includes(search),
)
: sortedProviders
return filtered.map((p: string) => ({
label: getProviderLabel(p),
value: p,
}))
}, [sortedProviders, providerSearch])
// Use shared scrollable list hook for provider windowing
const TOTAL_PROVIDER_ROWS = 8
const {
visibleStart: providerVisibleStart,
visibleCount: providerVisibleCount,
showTopIndicator: showProviderTopIndicator,
showBottomIndicator: showProviderBottomIndicator,
} = useScrollableList(providerItems.length, providerIndex, TOTAL_PROVIDER_ROWS)
const visibleProviderItems = useMemo(() => {
return providerItems.slice(providerVisibleStart, providerVisibleStart + providerVisibleCount)
}, [providerItems, providerVisibleStart, providerVisibleCount])
// Detect import sources on mount
useEffect(() => {
setImportSources(detectImportSources())
}, [])
// Reset provider index when search changes
useEffect(() => {
setProviderIndex(0)
}, [providerSearch])
// Set default model when entering model step
useEffect(() => {
if (step === "modelid" && hasModelPicker(selectedProvider)) {
setModelId(getDefaultModelId(selectedProvider))
}
}, [step, selectedProvider])
// Handle quick setup
useEffect(() => {
if (quickSetup && step === "saving") {
handleQuickSetup()
}
}, [quickSetup, step])
// Subscribe to auth status updates when in cline_auth step
useEffect(() => {
if (step !== "cline_auth") {
return
}
let cancelled = false
// Create a streaming response handler that receives auth state updates
const responseHandler = async (authState: { user?: { email?: string } }, _isLast?: boolean) => {
if (cancelled) {
return
}
if (authState.user && authState.user.email) {
// Auth succeeded - save configuration and transition to success
const stateManager = StateManager.get()
const mode = stateManager.getGlobalSettingsKey("mode") || "act"
const providerKey = mode === "act" ? "actModeApiProvider" : "planModeApiProvider"
const modelIdKey = mode === "act" ? "actModeApiModelId" : "planModeApiModelId"
const config: Record<string, string> = {
actModeApiProvider: "cline",
[providerKey]: "cline",
[modelIdKey]: openRouterDefaultModelId,
}
stateManager.setApiConfiguration(config)
stateManager.flushPendingState()
setSelectedProvider("cline")
setModelId(config[modelIdKey])
setStep("cline_model")
}
}
// Subscribe to auth status updates
const authService = AuthService.getInstance(controller)
authService.subscribeToAuthStatusUpdate(controller, {}, responseHandler, `cli-auth-${Date.now()}`)
return () => {
cancelled = true
}
}, [step, controller])
const handleQuickSetup = async () => {
if (!quickSetup) {
return
}
try {
const { provider, apikey, modelid, baseurl } = quickSetup
// Validate required parameters
if (!provider || !apikey || !modelid) {
setErrorMessage("Quick setup requires --provider, --apikey, and --modelid flags")
setStep("error")
return
}
const normalizedProvider = provider.toLowerCase().trim()
if (!sortedProviders.includes(normalizedProvider)) {
setErrorMessage(`Invalid provider '${provider}'. Supported providers: ${sortedProviders.join(", ")}`)
setStep("error")
return
}
if (normalizedProvider === "bedrock") {
setErrorMessage(
"Bedrock provider is not supported for quick setup due to complex authentication requirements. Please use interactive setup.",
)
setStep("error")
return
}
if (baseurl && !["openai", "openai-native"].includes(normalizedProvider)) {
setErrorMessage("Base URL is only supported for OpenAI and OpenAI-compatible providers")
setStep("error")
return
}
// Save configuration
const stateManager = StateManager.get()
const config: Record<string, string> = {
actModeApiProvider: normalizedProvider,
planModeApiProvider: normalizedProvider,
actModeApiModelId: modelid,
planModeApiModelId: modelid,
}
// Use provider-specific API key field
const keyField = ProviderToApiKeyMap[normalizedProvider]
if (keyField) {
const fields = Array.isArray(keyField) ? keyField : [keyField]
// Set the first key field for the provider
config[fields[0]] = apikey
} else {
// Fallback to generic apiKey
config.apiKey = apikey
}
if (baseurl) {
config.openAiBaseUrl = baseurl
}
stateManager.setApiConfiguration(config)
await stateManager.flushPendingState()
setSelectedProvider(normalizedProvider)
setModelId(modelid)
setBaseUrl(baseurl || "")
setStep("success")
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : String(error))
setStep("error")
}
}
// Start OpenAI Codex OAuth flow
const startOpenAiCodexAuth = useCallback(async () => {
try {
// Get the authorization URL and start the callback server
const authUrl = openAiCodexOAuthManager.startAuthorizationFlow()
// Open browser to authorization URL (uses cross-platform 'open' package)
await openExternal(authUrl)
// Wait for the callback
await openAiCodexOAuthManager.waitForCallback()
// Success - save configuration
const stateManager = StateManager.get()
const mode = stateManager.getGlobalSettingsKey("mode") || "act"
const providerKey = mode === "act" ? "actModeApiProvider" : "planModeApiProvider"
const modelIdKey = mode === "act" ? "actModeApiModelId" : "planModeApiModelId"
const config: Record<string, string> = {
actModeApiProvider: "openai-codex",
planModeApiProvider: "openai-codex",
[providerKey]: "openai-codex",
[modelIdKey]: openAiCodexDefaultModelId,
}
stateManager.setApiConfiguration(config)
await stateManager.flushPendingState()
setSelectedProvider("openai-codex")
setModelId(openAiCodexDefaultModelId)
setStep("success")
} catch (error) {
openAiCodexOAuthManager.cancelAuthorizationFlow()
setErrorMessage(error instanceof Error ? error.message : String(error))
setStep("error")
}
}, [])
// Start Cline auth flow
const startClineAuth = useCallback(async () => {
try {
setStep("cline_auth")
setAuthStatus("Starting authentication...")
await AuthService.getInstance(controller).createAuthRequest()
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : String(error))
setStep("error")
}
}, [controller])
const handleMainMenuSelect = useCallback(
(value: string) => {
if (value === "exit") {
exit()
onComplete?.()
} else if (value === "cline_auth") {
startClineAuth()
} else if (value === "openai_codex_auth") {
setStep("openai_codex_auth")
startOpenAiCodexAuth()
} else if (value === "configure_byo") {
setStep("provider")
} else if (value === "import_codex") {
setImportSource("codex")
setStep("import")
} else if (value === "import_opencode") {
setImportSource("opencode")
setStep("import")
}
},
[exit, onComplete, startClineAuth, startOpenAiCodexAuth],
)
const handleProviderSelect = useCallback(
(value: string) => {
setSelectedProvider(value)
if (value === "cline") {
startClineAuth()
} else if (value === "openai-codex") {
setStep("openai_codex_auth")
startOpenAiCodexAuth()
} else if (value === "bedrock") {
setStep("bedrock")
} else {
setStep("apikey")
}
},
[startClineAuth, startOpenAiCodexAuth],
)
const handleApiKeySubmit = useCallback(
(value: string) => {
if (!value.trim() || !selectedProvider) {
// Don't allow empty
return
}
// Store in local state - will be saved via StateManager in saveConfiguration
setApiKey(value)
setStep("modelid")
},
[selectedProvider],
)
const saveConfiguration = useCallback(
async (model: string, base: string) => {
try {
const stateManager = StateManager.get()
const config: Record<string, string> = {
actModeApiProvider: selectedProvider,
planModeApiProvider: selectedProvider,
actModeApiModelId: model,
planModeApiModelId: model,
apiProvider: selectedProvider,
}
// Add API key or Bedrock-specific config
if (selectedProvider === "bedrock" && bedrockConfig) {
const bedrockFields: Record<string, unknown> = {
awsAuthentication: bedrockConfig.awsAuthentication,
awsRegion: bedrockConfig.awsRegion,
awsUseCrossRegionInference: bedrockConfig.awsUseCrossRegionInference,
}
if (bedrockConfig.awsProfile !== undefined) bedrockFields.awsProfile = bedrockConfig.awsProfile
if (bedrockConfig.awsAccessKey) bedrockFields.awsAccessKey = bedrockConfig.awsAccessKey
if (bedrockConfig.awsSecretKey) bedrockFields.awsSecretKey = bedrockConfig.awsSecretKey
if (bedrockConfig.awsSessionToken) bedrockFields.awsSessionToken = bedrockConfig.awsSessionToken
Object.assign(config, bedrockFields)
} else if (apiKey) {
const keyField = ProviderToApiKeyMap[selectedProvider as keyof typeof ProviderToApiKeyMap]
if (keyField) {
const fields = Array.isArray(keyField) ? keyField : [keyField]
config[fields[0]] = apiKey
}
}
if (base) {
config.openAiBaseUrl = base
}
stateManager.setApiConfiguration(config)
await stateManager.flushPendingState()
setStep("success")
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : String(error))
setStep("error")
}
},
[selectedProvider, apiKey, bedrockConfig],
)
const handleModelIdSubmit = useCallback(
(value: string) => {
if (value.trim()) {
setModelId(value)
}
// Only show baseurl step for OpenAI-like providers
if (["openai", "openai-native"].includes(selectedProvider)) {
setStep("baseurl")
} else {
setStep("saving")
saveConfiguration(value, "")
}
},
[selectedProvider, saveConfiguration],
)
const handleBaseUrlSubmit = useCallback(
(value: string) => {
setBaseUrl(value)
setStep("saving")
saveConfiguration(modelId, value)
},
[modelId, saveConfiguration],
)
const handleClineModelSelect = useCallback(
(modelId: string) => {
setModelId(modelId)
setStep("saving")
saveConfiguration(modelId, "")
},
[saveConfiguration],
)
const handleBedrockComplete = useCallback((config: BedrockConfig) => {
setBedrockConfig(config)
setStep("modelid")
}, [])
const handleImportComplete = useCallback(() => {
setStep("success")
}, [])
const handleImportCancel = useCallback(() => {
setImportSource(null)
setStep("menu")
}, [])
// Auto-navigate to welcome after success (immediate)
useEffect(() => {
if (step === "success" && onNavigateToWelcome) {
onNavigateToWelcome()
}
}, [step, onNavigateToWelcome])
// Error screen menu items
const errorMenuItems: SelectItem[] = useMemo(() => {
const items: SelectItem[] = [{ label: "Try again", value: "retry" }]
if (onNavigateToWelcome) {
items.push({ label: "Start a task", value: "welcome" })
}
items.push({ label: "Exit", value: "exit" })
return items
}, [onNavigateToWelcome])
const handleErrorMenuSelect = useCallback(
(value: string) => {
if (value === "retry") {
// Reset state and go back to menu
setErrorMessage("")
setApiKey("")
setModelId("")
setBaseUrl("")
setSelectedProvider("")
setStep("menu")
} else if (value === "welcome") {
onNavigateToWelcome?.()
} else if (value === "exit") {
onError?.()
exit()
}
},
[onNavigateToWelcome, onError, exit],
)
// Handle going back to previous step
const goBack = useCallback(() => {
switch (step) {
case "provider":
setProviderSearch("")
setProviderIndex(0)
setStep("menu")
break
case "apikey":
setApiKey("")
setStep("provider")
break
case "modelid":
setModelId("")
// Go back to cline_model if we came from there (Cline provider)
if (selectedProvider === "cline") {
setStep("cline_model")
} else {
setStep("apikey")
}
break
case "baseurl":
setBaseUrl("")
setStep("modelid")
break
case "cline_auth":
setStep("menu")
break
case "openai_codex_auth":
openAiCodexOAuthManager.cancelAuthorizationFlow()
setStep("menu")
break
case "cline_model":
setClineModelIndex(0)
setStep("menu")
break
case "bedrock":
setBedrockConfig(null)
setStep("provider")
break
case "import":
setImportSource(null)
setStep("menu")
break
case "error":
setErrorMessage("")
setStep("menu")
break
// menu, saving, success - no back action
}
}, [step, selectedProvider])
// Render the auth box content based on current step
// Note: "menu" step is rendered separately in the main return for proper menuIndex tracking
const renderAuthContent = () => {
switch (step) {
case "provider": {
return (
<Box flexDirection="column">
<Text color="white">Select a provider</Text>
<Text> </Text>
<Box>
<Text color="gray">Search: </Text>
<Text color="white">{providerSearch}</Text>
<Text inverse> </Text>
</Box>
<Text> </Text>
{showProviderTopIndicator && <Text color="gray">... {providerVisibleStart} more above</Text>}
{visibleProviderItems.map((item, i) => {
const actualIndex = providerVisibleStart + i
return (
<Box key={item.value}>
<Text color={actualIndex === providerIndex ? COLORS.primaryBlue : undefined}>
{actualIndex === providerIndex ? " " : " "}
{item.label}
</Text>
</Box>
)
})}
{showProviderBottomIndicator && (
<Text color="gray">
... {providerItems.length - providerVisibleStart - providerVisibleCount} more below
</Text>
)}
{providerItems.length === 0 && <Text color="gray">No providers match "{providerSearch}"</Text>}
<Text> </Text>
<Text color="gray">Type to search, arrows to navigate, Enter to select, Esc to go back</Text>
</Box>
)
}
case "apikey":
return (
<ApiKeyInput
isActive={step === "apikey"}
onCancel={goBack}
onChange={setApiKey}
onSubmit={handleApiKeySubmit}
providerName={getProviderLabel(selectedProvider)}
value={apiKey}
/>
)
case "modelid":
// Show model picker for providers with static model lists
if (hasModelPicker(selectedProvider)) {
return (
<Box flexDirection="column">
<Text color="white">Select a model</Text>
<Text> </Text>
<ModelPicker
controller={controller}
isActive={step === "modelid"}
onChange={setModelId}
onSubmit={handleModelIdSubmit}
provider={selectedProvider}
/>
<Text> </Text>
<Text color="gray">Type to search, arrows to navigate, Enter to select, Esc to go back</Text>
</Box>
)
}
// Fall back to text input for providers without static model lists
return (
<Box flexDirection="column">
<Text color="white">Model ID</Text>
<Text> </Text>
<Text color="gray">e.g., claude-sonnet-4-20250514, gpt-4o</Text>
<Text> </Text>
<TextInput onChange={setModelId} onSubmit={handleModelIdSubmit} placeholder="model-id" value={modelId} />
<Text> </Text>
<Text color="gray">Enter to continue, Esc to go back</Text>
</Box>
)
case "baseurl":
return (
<Box flexDirection="column">
<Text color="white">Base URL (optional)</Text>
<Text> </Text>
<Text color="gray">For self-hosted or proxy endpoints</Text>
<Text> </Text>
<TextInput
onChange={setBaseUrl}
onSubmit={handleBaseUrlSubmit}
placeholder="https://api.example.com/v1"
value={baseUrl}
/>
<Text> </Text>
<Text color="gray">Enter to skip or continue, Esc to go back</Text>
</Box>
)
case "saving":
return (
<Box>
<Text color={COLORS.primaryBlue}>
<Spinner type="dots" />
</Text>
<Text color="white"> Saving configuration...</Text>
</Box>
)
case "cline_auth":
return (
<Box flexDirection="column">
<Box>
<Text color={COLORS.primaryBlue}>
<Spinner type="dots" />
</Text>
<Text color="white"> Waiting for browser sign-in...</Text>
</Box>
<Text> </Text>
<Text color="gray">Complete sign-in in your browser, then return here.</Text>
<Text> </Text>
<Text color="gray">Esc to cancel</Text>
</Box>
)
case "openai_codex_auth":
return (
<Box flexDirection="column">
<Box>
<Text color={COLORS.primaryBlue}>
<Spinner type="dots" />
</Text>
<Text color="white"> Waiting for ChatGPT sign-in...</Text>
</Box>
<Text> </Text>
<Text color="gray">Sign in with your ChatGPT account in the browser.</Text>
<Text color="gray">Requires ChatGPT Plus, Pro, or Team subscription.</Text>
<Text> </Text>
<Text color="gray">Esc to cancel</Text>
</Box>
)
case "cline_model": {
const allModels = featuredModels
return (
<Box flexDirection="column">
<Text color="white">Choose a model</Text>
<Text> </Text>
{/* Model list */}
{allModels.map((model, i) => (
<Box flexDirection="column" key={model.id} marginBottom={1}>
<Box>
<Text color={i === clineModelIndex ? COLORS.primaryBlue : undefined}>
{i === clineModelIndex ? " " : " "}
</Text>
<Text bold color={i === clineModelIndex ? COLORS.primaryBlue : "white"}>
{model.name}
</Text>
{model.label && (
<>
<Text> </Text>
<Text
backgroundColor={model.label === "FREE" ? "gray" : COLORS.primaryBlue}
color="black">
{" "}
{model.label}{" "}
</Text>
</>
)}
</Box>
<Box paddingLeft={2}>
<Text color="gray">{model.description}</Text>
</Box>
</Box>
))}
{/* Browse all option */}
<Box>
<Text color={clineModelIndex === allModels.length ? COLORS.primaryBlue : "gray"}>
{clineModelIndex === allModels.length ? " " : " "}
Browse all models...
</Text>
</Box>
<Text> </Text>
<Text color="gray">Arrows to navigate, Enter to select</Text>
</Box>
)
}
case "bedrock":
return (
<BedrockSetup
isActive={step === "bedrock"}
onCancel={() => {
setBedrockConfig(null)
setStep("provider")
}}
onComplete={handleBedrockComplete}
/>
)
case "import":
if (!importSource) {
return null
}
return <ImportView onCancel={handleImportCancel} onComplete={handleImportComplete} source={importSource} />
case "error":
return (
<Box flexDirection="column">
<Text bold color="red">
Something went wrong
</Text>
<Text> </Text>
<Text color="yellow">{errorMessage}</Text>
<Text> </Text>
<Select items={errorMenuItems} onSelect={handleErrorMenuSelect} />
</Box>
)
default:
return null
}
}
// For menu step, we need to handle input at the top level
const { isRawModeSupported } = useStdinContext()
const [menuIndex, setMenuIndex] = useState(0)
// Steps that allow going back with escape (apikey handled by ApiKeyInput component)
const canGoBack = [
"provider",
"modelid",
"baseurl",
"cline_auth",
"cline_model",
"openai_codex_auth",
"bedrock",
"error",
].includes(step)
useInput(
(input, key) => {
// Handle escape to go back (except on menu)
if (key.escape && canGoBack) {
goBack()
return
}
if (step === "menu") {
if (key.upArrow) {
setMenuIndex((prev) => (prev > 0 ? prev - 1 : mainMenuItems.length - 1))
} else if (key.downArrow) {
setMenuIndex((prev) => (prev < mainMenuItems.length - 1 ? prev + 1 : 0))
} else if (key.return) {
handleMainMenuSelect(mainMenuItems[menuIndex].value)
}
} else if (step === "provider") {
if (key.upArrow) {
setProviderIndex((prev) => (prev > 0 ? prev - 1 : providerItems.length - 1))
} else if (key.downArrow) {
setProviderIndex((prev) => (prev < providerItems.length - 1 ? prev + 1 : 0))
} else if (key.return) {
if (providerItems[providerIndex]) {
handleProviderSelect(providerItems[providerIndex].value)
}
} else if (key.backspace || key.delete) {
setProviderSearch((prev) => prev.slice(0, -1))
} else if (input && !key.ctrl && !key.meta) {
setProviderSearch((prev) => prev + input)
}
} else if (step === "cline_model") {
const allModels = featuredModels
const maxIndex = allModels.length // includes "Browse all" option
if (key.upArrow) {
setClineModelIndex((prev) => (prev > 0 ? prev - 1 : maxIndex))
} else if (key.downArrow) {
setClineModelIndex((prev) => (prev < maxIndex ? prev + 1 : 0))
} else if (key.return) {
if (clineModelIndex === allModels.length) {
// "Browse all models" selected
setStep("modelid")
} else {
// Featured model selected
handleClineModelSelect(allModels[clineModelIndex].id)
}
}
}
// Note: modelid step input is handled by ModelPicker component
},
{ isActive: isRawModeSupported && (step === "menu" || step === "provider" || step === "cline_model" || canGoBack) },
)
return (
<Box flexDirection="column" paddingLeft={1} paddingRight={1} width="100%">
{/* Cline robot - centered */}
<StaticRobotFrame />
{/* Welcome text - centered */}
<Box justifyContent="center" marginTop={1}>
<Text bold color="white">
Welcome to Cline
</Text>
</Box>
{/* Auth box with border */}
<Box
borderColor="gray"
borderStyle="round"
flexDirection="column"
marginTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
paddingTop={1}>
{step === "menu" ? (
<Box flexDirection="column">
<Text color="gray">How would you like to get started?</Text>
<Text> </Text>
{mainMenuItems.map((item, index) => (
<Box key={item.value}>
<Text color={index === menuIndex ? COLORS.primaryBlue : undefined}>
{index === menuIndex ? " " : " "}
{item.label}
</Text>
</Box>
))}
<Text> </Text>
<Text color="gray">Use arrow keys, Enter to select</Text>
</Box>
) : (
renderAuthContent()
)}
</Box>
</Box>
)
}
+381
View File
@@ -0,0 +1,381 @@
import BedrockData from "@shared/providers/bedrock.json"
import { Box, Text, useInput } from "ink"
import React, { useCallback, useMemo, useState } from "react"
import { COLORS } from "../constants/colors"
import { useStdinContext } from "../context/StdinContext"
import { useScrollableList } from "../hooks/useScrollableList"
import { isMouseEscapeSequence } from "../utils/input"
type AuthMethod = "profile" | "credentials" | "default"
type BedrockStep = "auth_method" | "profile_name" | "access_key" | "secret_key" | "session_token" | "region" | "options"
export interface BedrockConfig {
awsAuthentication: string
awsProfile?: string
awsAccessKey?: string
awsSecretKey?: string
awsSessionToken?: string
awsRegion: string
awsUseCrossRegionInference: boolean
}
interface BedrockSetupProps {
isActive: boolean
onComplete: (config: BedrockConfig) => void
onCancel: () => void
}
const AUTH_METHODS: { label: string; value: AuthMethod; description: string }[] = [
{ label: "AWS Profile", value: "profile", description: "Use a named profile from ~/.aws/credentials" },
{ label: "AWS Credentials", value: "credentials", description: "Enter access key, secret key, and optional session token" },
{
label: "Default credential chain",
value: "default",
description: "Resolve from env vars, IAM role, or ~/.aws/credentials",
},
]
const AWS_REGIONS = BedrockData.regions
const REGION_ROWS = 8
/**
* Inline text input for credential fields
*/
const CredentialInput: React.FC<{
label: string
value: string
onChange: (value: string) => void
onSubmit: () => void
onCancel: () => void
isActive: boolean
isPassword?: boolean
placeholder?: string
hint?: string
}> = ({ label, value, onChange, onSubmit, onCancel, isActive, isPassword, placeholder, hint }) => {
const { isRawModeSupported } = useStdinContext()
useInput(
(input, key) => {
if (isMouseEscapeSequence(input)) return
if (key.escape) {
onCancel()
} else if (key.return) {
onSubmit()
} else if (key.backspace || key.delete) {
onChange(value.slice(0, -1))
} else if (input && !key.ctrl && !key.meta) {
onChange(value + input)
}
},
{ isActive: isActive && isRawModeSupported },
)
const displayValue = isPassword && value ? "•".repeat(value.length) : value
// Combine hint and placeholder into description shown above input
const description = hint || (placeholder ? `e.g. ${placeholder}` : undefined)
return (
<Box flexDirection="column">
<Text color="white">{label}</Text>
{description && <Text color="gray">{description}</Text>}
<Text> </Text>
<Box>
<Text color="white">{displayValue}</Text>
<Text inverse> </Text>
</Box>
<Text> </Text>
<Text color="gray">Enter to continue, Esc to go back</Text>
</Box>
)
}
export const BedrockSetup: React.FC<BedrockSetupProps> = ({ isActive, onComplete, onCancel }) => {
const { isRawModeSupported } = useStdinContext()
const [step, setStep] = useState<BedrockStep>("auth_method")
const [authMethodIndex, setAuthMethodIndex] = useState(0)
const [authMethod, setAuthMethod] = useState<AuthMethod>("profile")
// Credential state
const [profileName, setProfileName] = useState("")
const [accessKey, setAccessKey] = useState("")
const [secretKey, setSecretKey] = useState("")
const [sessionToken, setSessionToken] = useState("")
// Region state
const [regionSearch, setRegionSearch] = useState("")
const [regionIndex, setRegionIndex] = useState(0)
// Options state
const [crossRegion, setCrossRegion] = useState(false)
const [optionIndex, setOptionIndex] = useState(0)
// Filtered regions
const filteredRegions = useMemo(() => {
const search = regionSearch.toLowerCase()
return search ? AWS_REGIONS.filter((r) => r.includes(search)) : AWS_REGIONS
}, [regionSearch])
const {
visibleStart: regionVisibleStart,
visibleCount: regionVisibleCount,
showTopIndicator: showRegionTop,
showBottomIndicator: showRegionBottom,
} = useScrollableList(filteredRegions.length, regionIndex, REGION_ROWS)
const visibleRegions = useMemo(
() => filteredRegions.slice(regionVisibleStart, regionVisibleStart + regionVisibleCount),
[filteredRegions, regionVisibleStart, regionVisibleCount],
)
const nextStepAfterAuth = useCallback((method: AuthMethod) => {
setAuthMethod(method)
if (method === "profile") {
setStep("profile_name")
} else if (method === "credentials") {
setStep("access_key")
} else {
// default chain - skip credentials, go to region
setStep("region")
}
}, [])
const goBack = useCallback(() => {
switch (step) {
case "auth_method":
onCancel()
break
case "profile_name":
setStep("auth_method")
break
case "access_key":
setStep("auth_method")
break
case "secret_key":
setStep("access_key")
break
case "session_token":
setStep("secret_key")
break
case "region":
if (authMethod === "profile") setStep("profile_name")
else if (authMethod === "credentials") setStep("session_token")
else setStep("auth_method")
break
case "options":
setStep("region")
break
}
}, [step, authMethod, onCancel])
const finish = useCallback(() => {
const config: BedrockConfig = {
awsAuthentication: authMethod === "default" ? "credentials" : authMethod,
awsRegion: filteredRegions[regionIndex] || "us-east-1",
awsUseCrossRegionInference: crossRegion,
}
if (authMethod === "profile") {
config.awsProfile = profileName || ""
} else if (authMethod === "credentials") {
config.awsAccessKey = accessKey
config.awsSecretKey = secretKey
if (sessionToken) config.awsSessionToken = sessionToken
}
onComplete(config)
}, [authMethod, profileName, accessKey, secretKey, sessionToken, filteredRegions, regionIndex, crossRegion, onComplete])
// Handle input for auth_method, region, and options steps
useInput(
(input, key) => {
if (isMouseEscapeSequence(input)) return
if (step === "auth_method") {
if (key.escape) {
onCancel()
} else if (key.upArrow) {
setAuthMethodIndex((prev) => (prev > 0 ? prev - 1 : AUTH_METHODS.length - 1))
} else if (key.downArrow) {
setAuthMethodIndex((prev) => (prev < AUTH_METHODS.length - 1 ? prev + 1 : 0))
} else if (key.return) {
nextStepAfterAuth(AUTH_METHODS[authMethodIndex].value)
}
} else if (step === "region") {
if (key.escape) {
goBack()
} else if (key.upArrow) {
setRegionIndex((prev) => (prev > 0 ? prev - 1 : filteredRegions.length - 1))
} else if (key.downArrow) {
setRegionIndex((prev) => (prev < filteredRegions.length - 1 ? prev + 1 : 0))
} else if (key.return && filteredRegions.length > 0) {
setStep("options")
} else if (key.backspace || key.delete) {
setRegionSearch((prev) => prev.slice(0, -1))
setRegionIndex(0)
} else if (input && !key.ctrl && !key.meta) {
setRegionSearch((prev) => prev + input)
setRegionIndex(0)
}
} else if (step === "options") {
if (key.escape) {
goBack()
} else if (key.tab || key.return || input === " ") {
// Tab/Enter/Space on checkbox toggles it, on Done button finishes
if (optionIndex === 0) {
setCrossRegion((prev) => !prev)
} else {
finish()
}
} else if (key.upArrow) {
setOptionIndex((prev) => (prev > 0 ? prev - 1 : 1))
} else if (key.downArrow) {
setOptionIndex((prev) => (prev < 1 ? prev + 1 : 0))
}
}
},
{ isActive: isActive && isRawModeSupported && (step === "auth_method" || step === "region" || step === "options") },
)
if (step === "auth_method") {
return (
<Box flexDirection="column">
<Text color="white">Authentication method</Text>
<Text> </Text>
{AUTH_METHODS.map((method, i) => (
<Box flexDirection="column" key={method.value} marginBottom={i < AUTH_METHODS.length - 1 ? 1 : 0}>
<Text color={i === authMethodIndex ? COLORS.primaryBlue : undefined}>
{i === authMethodIndex ? " " : " "}
{method.label}
</Text>
<Box paddingLeft={2}>
<Text color="gray">{method.description}</Text>
</Box>
</Box>
))}
<Text> </Text>
<Text color="gray">Arrows to navigate, Enter to select, Esc to go back</Text>
</Box>
)
}
if (step === "profile_name") {
return (
<CredentialInput
hint="Leave empty to use the default profile"
isActive={isActive}
label="AWS Profile Name"
onCancel={goBack}
onChange={setProfileName}
onSubmit={() => setStep("region")}
placeholder="default"
value={profileName}
/>
)
}
if (step === "access_key") {
return (
<CredentialInput
isActive={isActive}
isPassword
label="AWS Access Key"
onCancel={goBack}
onChange={setAccessKey}
onSubmit={() => {
if (accessKey.trim()) setStep("secret_key")
}}
placeholder="Enter access key..."
value={accessKey}
/>
)
}
if (step === "secret_key") {
return (
<CredentialInput
isActive={isActive}
isPassword
label="AWS Secret Key"
onCancel={goBack}
onChange={setSecretKey}
onSubmit={() => {
if (secretKey.trim()) setStep("session_token")
}}
placeholder="Enter secret key..."
value={secretKey}
/>
)
}
if (step === "session_token") {
return (
<CredentialInput
hint="Optional - for temporary credentials"
isActive={isActive}
isPassword
label="AWS Session Token"
onCancel={goBack}
onChange={setSessionToken}
onSubmit={() => setStep("region")}
placeholder="Enter session token (optional)..."
value={sessionToken}
/>
)
}
if (step === "region") {
return (
<Box flexDirection="column">
<Text color="white">AWS Region</Text>
<Text> </Text>
<Box>
<Text color="gray">Search: </Text>
<Text color="white">{regionSearch}</Text>
<Text inverse> </Text>
</Box>
<Text> </Text>
{showRegionTop && <Text color="gray">... {regionVisibleStart} more above</Text>}
{visibleRegions.map((region, i) => {
const actualIndex = regionVisibleStart + i
return (
<Box key={region}>
<Text color={actualIndex === regionIndex ? COLORS.primaryBlue : undefined}>
{actualIndex === regionIndex ? " " : " "}
{region}
</Text>
</Box>
)
})}
{showRegionBottom && (
<Text color="gray">... {filteredRegions.length - regionVisibleStart - regionVisibleCount} more below</Text>
)}
{filteredRegions.length === 0 && <Text color="gray">No regions match "{regionSearch}"</Text>}
<Text> </Text>
<Text color="gray">Type to search, arrows to navigate, Enter to select, Esc to go back</Text>
</Box>
)
}
if (step === "options") {
return (
<Box flexDirection="column">
<Text color="white">Options</Text>
<Text> </Text>
<Text color={optionIndex === 0 ? COLORS.primaryBlue : undefined}>
{optionIndex === 0 ? " " : " "}
{crossRegion ? "[x]" : "[ ]"} Use cross-region inference
</Text>
<Text> </Text>
<Text color={optionIndex === 1 ? COLORS.primaryBlue : undefined}>
{optionIndex === 1 ? " " : " "}
Done
</Text>
<Text> </Text>
<Text color="gray">Arrows to navigate, Enter to select, Esc to go back</Text>
</Box>
)
}
return null
}
+597
View File
@@ -0,0 +1,597 @@
/**
* Claude Code style chat message component
* Renders messages with:
* - for user messages
* - ⏺ for assistant messages and tool calls
* - ⎿ for tool results (indented)
*/
import { CLINE_ACCOUNT_AUTH_ERROR_MESSAGE } from "@shared/ClineAccount"
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 { useTerminalSize } from "../hooks/useTerminalSize"
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 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 color="gray">{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 > 120 ? args.command.substring(0, 117) + "..." : 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
const { columns: terminalWidth } = useTerminalSize()
// User messages (task, user_feedback)
// If multi-line, extend background to full width for consistent appearance
if (say === "task" || say === "user_feedback") {
const content = "> " + (text || "")
const isMultiLine = content.includes("\n") || content.length > terminalWidth
if (isMultiLine) {
return (
<Box flexDirection="column" marginBottom={1} width="100%">
<Box backgroundColor="blackBright" paddingX={1} width="100%">
<Text color="white">{content}</Text>
</Box>
</Box>
)
}
return (
<Box flexDirection="column" marginBottom={1}>
<Box backgroundColor="blackBright" paddingX={1}>
<Text color="white">{content}</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>
)
}
// Show result content for completed tools, or file path for pending asks
const contentLines =
isToolSay && toolInfo.result?.trim()
? formatToolResult(toolInfo.result, 5)
: isToolAsk && filePath
? [filePath as string]
: []
return (
<Box flexDirection="column" marginBottom={1} width="100%">
<DotRow color={toolColor}>
<ToolCallText args={toolInfo.args} isAsk={isToolAsk} mode={mode} toolName={toolInfo.toolName} />
</DotRow>
{contentLines.length > 0 && (
<Box flexDirection="column" marginLeft={2} width="100%">
{contentLines.map((line, idx) => (
<ResultRow isFirst={idx === 0} key={idx}>
<Text color="gray">{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, 120)}</Text>
</Text>
</DotRow>
{output && (
<Box flexDirection="column" marginLeft={2} width="100%">
{formatToolResult(output, 8).map((line, idx) => (
<ResultRow isFirst={idx === 0} key={idx}>
<Text color="gray">{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 color="gray">{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
}
}
// Check for Cline auth error to show sign-in instructions
const isClineAuthError = errorMessage.includes(CLINE_ACCOUNT_AUTH_ERROR_MESSAGE)
return (
<Box flexDirection="column" marginBottom={1} width="100%">
<DotRow color="red">
<Text color="red" wrap="wrap">
<Text bold>Error</Text>: {errorMessage}
</Text>
</DotRow>
{isClineAuthError && (
<Box marginLeft={2} marginTop={1}>
<Text color="gray">
Run <Text color="cyan">/settings</Text> and go to Account to sign in.
</Text>
</Box>
)}
</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" : toolColor} 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>
)
}
}
// New task request from assistant
if (type === "ask" && ask === "new_task" && text) {
return (
<Box flexDirection="column" marginBottom={1} width="100%">
<DotRow color={COLORS.primaryBlue}>
<Text bold color={COLORS.primaryBlue}>
Cline wants to start a new task:
</Text>
</DotRow>
<Box flexDirection="column" paddingLeft={2}>
<Text color="gray">{text}</Text>
</Box>
</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>
)
}
+316
View File
@@ -0,0 +1,316 @@
/**
* Tests for ChatView component exit and cleanup behavior
*
* These tests verify that when the user exits (via Ctrl+C or other means),
* the input field is properly hidden before the app terminates.
*/
import { Text } from "ink"
import { render } from "ink-testing-library"
import React from "react"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { ChatView } from "./ChatView"
// Helper to wait for async state updates
// Using 60ms since handleExit has a 50ms setTimeout
const delay = (ms: number = 60) => new Promise((resolve) => setTimeout(resolve, ms))
// Type for our exit mock function
type ExitMockFn = ReturnType<typeof vi.fn> & (() => void)
// Track shutdown event state
const shutdownMockState = {
listeners: [] as Array<() => void>,
fire: () => {
shutdownMockState.listeners.forEach((listener) => listener())
},
reset: () => {
shutdownMockState.listeners = []
},
}
// Mock vscode-shim shutdownEvent
vi.mock("../vscode-shim", () => ({
shutdownEvent: {
event: (listener: () => void) => {
shutdownMockState.listeners.push(listener)
return {
dispose: () => {
const idx = shutdownMockState.listeners.indexOf(listener)
if (idx >= 0) shutdownMockState.listeners.splice(idx, 1)
},
}
},
fire: () => shutdownMockState.fire(),
},
}))
// Mock TaskContext
vi.mock("../context/TaskContext", () => ({
useTaskState: vi.fn(() => ({
clineMessages: [],
mode: "act",
})),
useTaskContext: vi.fn(() => ({
controller: null,
})),
}))
// Mock useIsSpinnerActive hook
vi.mock("../hooks/useStateSubscriber", () => ({
useIsSpinnerActive: vi.fn(() => ({
isActive: false,
startTime: null,
})),
}))
// Mock StateManager
vi.mock("@/core/storage/StateManager", () => ({
StateManager: {
get: vi.fn(() => ({
getGlobalSettingsKey: vi.fn((key: string) => {
if (key === "mode") return "act"
if (key === "yoloModeToggled") return false
if (key === "actModeApiModelId") return "claude-sonnet-4-20250514"
return null
}),
setGlobalState: vi.fn(),
})),
},
}))
// Mock child components that aren't under test
vi.mock("./ActionButtons", () => ({
ActionButtons: () => React.createElement(Text, null, "ActionButtons"),
getButtonConfig: vi.fn(() => ({ enableButtons: false })),
}))
vi.mock("./AsciiMotionCli", () => ({
AsciiMotionCli: () => React.createElement(Text, null, "AsciiMotion"),
StaticRobotFrame: () => React.createElement(Text, null, "StaticRobot"),
}))
vi.mock("./ChatMessage", () => ({
ChatMessage: ({ message }: { message?: { ts?: number } }) => React.createElement(Text, null, `Message: ${message?.ts}`),
}))
vi.mock("./FileMentionMenu", () => ({
FileMentionMenu: () => React.createElement(Text, null, "FileMentionMenu"),
}))
vi.mock("./HighlightedInput", () => ({
HighlightedInput: ({ text }: { text?: string }) => React.createElement(Text, null, `Input: ${text}`),
}))
vi.mock("./HistoryPanelContent", () => ({
HistoryPanelContent: () => React.createElement(Text, null, "HistoryPanel"),
}))
vi.mock("./SettingsPanelContent", () => ({
SettingsPanelContent: () => React.createElement(Text, null, "SettingsPanel"),
}))
vi.mock("./SlashCommandMenu", () => ({
SlashCommandMenu: () => React.createElement(Text, null, "SlashMenu"),
}))
vi.mock("./ThinkingIndicator", () => ({
ThinkingIndicator: () => React.createElement(Text, null, "ThinkingIndicator"),
}))
// Mock utility functions
vi.mock("../utils/file-search", () => ({
checkAndWarnRipgrepMissing: vi.fn(() => false),
extractMentionQuery: vi.fn(() => ({ inMentionMode: false, query: "", atIndex: -1 })),
getRipgrepInstallInstructions: vi.fn(() => "brew install ripgrep"),
insertMention: vi.fn((text: string) => text),
searchWorkspaceFiles: vi.fn(async () => []),
}))
vi.mock("../utils/slash-commands", () => ({
extractSlashQuery: vi.fn(() => ({ inSlashMode: false, query: "", slashIndex: -1 })),
filterCommands: vi.fn(() => []),
insertSlashCommand: vi.fn((text: string) => text),
sortCommandsWorkflowsFirst: vi.fn((cmds: unknown[]) => cmds),
}))
vi.mock("../utils/input", () => ({
isMouseEscapeSequence: vi.fn(() => false),
}))
vi.mock("../utils/parser", () => ({
jsonParseSafe: vi.fn((_text: string, defaultValue: unknown) => defaultValue),
parseImagesFromInput: vi.fn((text: string) => ({ prompt: text, imagePaths: [] })),
}))
vi.mock("../utils/tools", () => ({
isFileEditTool: vi.fn(() => false),
parseToolFromMessage: vi.fn(() => null),
}))
vi.mock("../utils/display", () => ({
setTerminalTitle: vi.fn(),
}))
vi.mock("../utils/cursor", () => ({
moveCursorUp: vi.fn((_text: string, pos: number) => pos),
moveCursorDown: vi.fn((_text: string, pos: number) => pos),
}))
vi.mock("@/core/controller/slash/getAvailableSlashCommands", () => ({
getAvailableSlashCommands: vi.fn(async () => ({ commands: [] })),
}))
vi.mock("@/core/controller/task/showTaskWithId", () => ({
showTaskWithId: vi.fn(async () => {}),
}))
vi.mock("@shared/combineCommandSequences", () => ({
combineCommandSequences: vi.fn((messages: unknown[]) => messages),
}))
vi.mock("@shared/getApiMetrics", () => ({
getApiMetrics: vi.fn(() => ({
totalTokensIn: 0,
totalTokensOut: 0,
totalCost: 0,
})),
}))
vi.mock("child_process", () => ({
execSync: vi.fn(() => "main"),
}))
// Helper to create a typed mock for onExit
const createExitMock = (): ExitMockFn => vi.fn() as ExitMockFn
describe("ChatView Exit and Cleanup", () => {
let mockOnExit: ExitMockFn
beforeEach(() => {
vi.clearAllMocks()
shutdownMockState.reset()
mockOnExit = createExitMock()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe("Initial render state", () => {
it("should render with input field, footer, and mode toggle visible", () => {
const { lastFrame } = render(<ChatView onExit={mockOnExit} />)
const frame = lastFrame()
// Input field visible
expect(frame).toContain("Input:")
// Footer with help text
expect(frame).toContain("@ for files")
expect(frame).toContain("/ for commands")
// Mode toggle
expect(frame).toContain("Plan")
expect(frame).toContain("Act")
})
})
describe("Ctrl+C exit handling", () => {
it("should hide input but keep footer, then call onExit", async () => {
const { lastFrame, stdin } = render(<ChatView onExit={mockOnExit} />)
// Verify UI visible before Ctrl+C
expect(lastFrame()).toContain("Input:")
expect(lastFrame()).toContain("@ for files")
// Simulate Ctrl+C
stdin.write("\x03")
// onExit should not be called immediately
expect(mockOnExit).not.toHaveBeenCalled()
// Wait for state update and callback
await delay()
// Input should be hidden, but footer should remain
const frameAfter = lastFrame()
expect(frameAfter).not.toContain("Input:")
expect(frameAfter).toContain("@ for files")
// onExit should have been called
expect(mockOnExit).toHaveBeenCalledTimes(1)
})
})
describe("Shutdown event handling", () => {
it("should subscribe on mount and unsubscribe on unmount", () => {
const { unmount } = render(<ChatView onExit={mockOnExit} />)
expect(shutdownMockState.listeners.length).toBe(1)
unmount()
expect(shutdownMockState.listeners.length).toBe(0)
})
it("should hide UI when shutdown event fires", async () => {
const { lastFrame } = render(<ChatView onExit={mockOnExit} />)
expect(lastFrame()).toContain("Input:")
shutdownMockState.fire()
await delay()
expect(lastFrame()).not.toContain("Input:")
})
})
describe("Edge cases", () => {
it("should handle exit when onExit prop is undefined", async () => {
const { lastFrame, stdin } = render(<ChatView />)
stdin.write("\x03")
await delay()
// Should not throw, UI should still hide
expect(lastFrame()).not.toContain("Input:")
})
it("should handle multiple Ctrl+C presses gracefully", async () => {
const { stdin } = render(<ChatView onExit={mockOnExit} />)
stdin.write("\x03")
stdin.write("\x03")
stdin.write("\x03")
await delay()
expect(mockOnExit).toHaveBeenCalled()
})
})
})
describe("ChatView UI State During Exit", () => {
beforeEach(() => {
vi.clearAllMocks()
shutdownMockState.reset()
})
it("should preserve static content and footer, only hide input during exit", async () => {
const onExit = createExitMock()
const { lastFrame, stdin } = render(<ChatView onExit={onExit} />)
// Footer contains auto-approve toggle
expect(lastFrame()).toContain("Auto-approve")
expect(lastFrame()).toContain("What can I do for you?")
expect(lastFrame()).toContain("Input:")
stdin.write("\x03")
await delay()
const frameAfter = lastFrame()
// Static content should still be present
expect(frameAfter).toContain("What can I do for you?")
// Footer should still be present (only input is hidden)
expect(frameAfter).toContain("Auto-approve")
// Input should be hidden
expect(frameAfter).not.toContain("Input:")
})
})
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>
)
}
+203
View File
@@ -0,0 +1,203 @@
/**
* Checkpoint menu component
* Displays available checkpoints and allows user to select one to restore
*/
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"
interface CheckpointOption {
ts: number
hash: string
date: Date
label: string
}
interface CheckpointMenuProps {
messages: ClineMessage[]
onSelect: (messageTs: number, restoreType: RestoreType) => void
onCancel: () => void
}
/**
* Extract checkpoint options from messages
*/
function getCheckpointOptions(messages: ClineMessage[]): CheckpointOption[] {
const options: CheckpointOption[] = []
for (const msg of messages) {
if (msg.lastCheckpointHash) {
options.push({
ts: msg.ts,
hash: msg.lastCheckpointHash,
date: new Date(msg.ts),
label: getCheckpointLabel(msg),
})
}
}
// Sort by timestamp descending (newest first)
return options.sort((a, b) => b.ts - a.ts)
}
/**
* Get a human-readable label for a checkpoint
*/
function getCheckpointLabel(msg: ClineMessage): string {
if (msg.say === "completion_result") {
return "Task completion"
}
if (msg.say === "checkpoint_created") {
return "Checkpoint"
}
if (msg.say === "api_req_started") {
return "API request"
}
return msg.say || msg.ask || "Message"
}
const RESTORE_TYPE_OPTIONS: { type: RestoreType; label: string; description: string }[] = [
{
type: "taskAndWorkspace",
label: "Task + Workspace",
description: "Restore messages and files",
},
{
type: "task",
label: "Task Only",
description: "Delete messages after this point",
},
{
type: "workspace",
label: "Workspace Only",
description: "Restore files only",
},
]
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()
}
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)
}
}
}
// 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">Checkpoints are created at task completion points</Text>
<Text color="gray">Press Escape to close</Text>
</Box>
)
}
if (stage === "checkpoint") {
return (
<Box borderColor="cyan" borderStyle="round" flexDirection="column" marginTop={1} paddingLeft={1} paddingRight={1}>
<Text bold color="cyan">
Restore Checkpoint
</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
const timeStr = cp.date.toLocaleTimeString()
const dateStr = cp.date.toLocaleDateString()
return (
<Box key={cp.ts}>
<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">
{" "}
- {dateStr} {timeStr}
</Text>
</Box>
)
})}
</Box>
</Box>
)
}
// Stage: restoreType
const selectedCp = checkpoints[selectedCheckpoint]
return (
<Box borderColor="cyan" borderStyle="round" flexDirection="column" marginTop={1} paddingLeft={1} paddingRight={1}>
<Text bold color="cyan">
Restore Type
</Text>
<Text color="gray">
Restoring to: {selectedCp?.label} ({selectedCp?.date.toLocaleString()})
</Text>
<Box flexDirection="column" marginTop={1}>
{RESTORE_TYPE_OPTIONS.map((opt, idx) => {
const isSelected = idx === selectedRestoreType
return (
<Box flexDirection="column" key={opt.type} marginBottom={idx < RESTORE_TYPE_OPTIONS.length - 1 ? 1 : 0}>
<Box>
<Text color={isSelected ? "green" : "gray"}>{isSelected ? "> " : " "}</Text>
<Text bold={isSelected} color={isSelected ? "white" : undefined}>
{opt.label}
</Text>
</Box>
<Box marginLeft={4}>
<Text color="gray">{opt.description}</Text>
</Box>
</Box>
)
})}
</Box>
<Text color="gray">(/ to select, Enter to confirm, Escape to go back)</Text>
</Box>
)
}
+199
View File
@@ -0,0 +1,199 @@
import { Text } from "ink"
import { render } from "ink-testing-library"
import React from "react"
import { beforeEach, describe, expect, it, vi } from "vitest"
// Create stable mock references using vi.hoisted - must be before any imports that use these modules
const { mockIsSettingsKey } = vi.hoisted(() => ({
mockIsSettingsKey: vi.fn((key: string) => key.startsWith("act") || key.startsWith("plan") || key === "mode"),
}))
vi.mock("./TaskView", () => ({
TaskView: ({ taskId, verbose }: any) =>
React.createElement(Text, null, `TaskView: ${taskId || "no-id"} verbose=${String(verbose)}`),
}))
// Mock the state-keys module - must be hoisted before ConfigView import
vi.mock("@shared/storage/state-keys", () => ({
isSettingsKey: mockIsSettingsKey,
SETTINGS_DEFAULTS: {
mode: "act",
actModeApiProvider: "anthropic",
},
GlobalStateAndSettings: {},
GlobalStateAndSettingsKey: {},
LocalState: {},
LocalStateKey: {},
}))
// Import ConfigView after mocks are set up
import { ConfigView } from "./ConfigView"
describe("ConfigView", () => {
const defaultProps = {
dataDir: "/home/user/.cline",
globalState: {},
workspaceState: {},
}
beforeEach(() => {
vi.clearAllMocks()
})
describe("rendering", () => {
it("should render the config header", () => {
const { lastFrame } = render(<ConfigView {...defaultProps} />)
expect(lastFrame()).toContain("Configuration")
})
it("should display the data directory", () => {
const { lastFrame } = render(<ConfigView {...defaultProps} dataDir="/custom/path" />)
expect(lastFrame()).toContain("/custom/path")
})
it("should display global state entries", () => {
const { lastFrame } = render(
<ConfigView
{...defaultProps}
globalState={{
mode: "act",
actModeApiProvider: "anthropic",
}}
/>,
)
expect(lastFrame()).toContain("mode")
expect(lastFrame()).toContain("act")
})
it("should display workspace state entries", () => {
const { lastFrame } = render(
<ConfigView
{...defaultProps}
workspaceState={{
customSetting: "value",
}}
/>,
)
expect(lastFrame()).toContain("customSetting")
expect(lastFrame()).toContain("value")
})
it("should show section headers", () => {
const { lastFrame } = render(
<ConfigView {...defaultProps} globalState={{ mode: "act" }} workspaceState={{ localKey: "localValue" }} />,
)
expect(lastFrame()).toContain("Global Settings")
})
})
describe("value formatting", () => {
it("should format boolean values", () => {
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ actModeSomeBool: true }} />)
expect(lastFrame()).toContain("true")
})
it("should format number values", () => {
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ actModeNumber: 42 }} />)
expect(lastFrame()).toContain("42")
})
it("should truncate long string values", () => {
const longString = "x".repeat(100)
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ actModeLongValue: longString }} />)
expect(lastFrame()).toContain("...")
})
it("should format object values as JSON", () => {
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ actModeObj: { nested: "value" } }} />)
expect(lastFrame()).toContain("nested")
})
})
describe("filtering", () => {
it("should exclude taskHistory key", () => {
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ taskHistory: [1, 2, 3], mode: "act" }} />)
expect(lastFrame()).not.toContain("taskHistory")
})
it("should exclude empty objects", () => {
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ emptyObj: {}, mode: "act" }} />)
expect(lastFrame()).not.toContain("emptyObj")
})
it("should exclude empty arrays", () => {
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ emptyArr: [], mode: "act" }} />)
expect(lastFrame()).not.toContain("emptyArr")
})
it("should exclude null/undefined values", () => {
const { lastFrame } = render(
<ConfigView {...defaultProps} globalState={{ nullVal: null, undefinedVal: undefined, mode: "act" }} />,
)
expect(lastFrame()).not.toContain("nullVal")
expect(lastFrame()).not.toContain("undefinedVal")
})
it("should exclude keys ending with Toggles", () => {
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ someToggles: { a: true }, mode: "act" }} />)
expect(lastFrame()).not.toContain("someToggles")
})
it("should exclude keys starting with apiConfig_", () => {
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ apiConfig_test: "value", mode: "act" }} />)
expect(lastFrame()).not.toContain("apiConfig_test")
})
})
describe("keyboard navigation", () => {
it("should show navigation help text", () => {
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ mode: "act" }} />)
expect(lastFrame()).toContain("Navigate")
expect(lastFrame()).toContain("Edit")
})
it("should highlight first item by default", () => {
const { lastFrame } = render(
<ConfigView {...defaultProps} globalState={{ mode: "act", actModeApiProvider: "anthropic" }} />,
)
// The selected indicator
expect(lastFrame()).toContain("")
})
it("should navigate down with arrow key", () => {
const { lastFrame, stdin } = render(
<ConfigView {...defaultProps} globalState={{ actModeFirst: "a", actModeSecond: "b" }} />,
)
// Press down arrow
stdin.write("\x1B[B")
const frame = lastFrame()
expect(frame).toContain("")
})
it("should navigate up with arrow key", () => {
const { lastFrame, stdin } = render(
<ConfigView {...defaultProps} globalState={{ actModeFirst: "a", actModeSecond: "b" }} />,
)
// Press down then up
stdin.write("\x1B[B")
stdin.write("\x1B[A")
expect(lastFrame()).toContain("")
})
})
describe("scrolling", () => {
it("should show scroll indicators when list is long", () => {
const manyEntries: Record<string, string> = {}
for (let i = 0; i < 20; i++) {
manyEntries[`actModeKey${i}`] = `value${i}`
}
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={manyEntries} />)
expect(lastFrame()).toContain("Showing")
})
})
})
+551
View File
@@ -0,0 +1,551 @@
/**
* Interactive config view component for displaying and editing configuration values
* Supports tabs for Settings, Rules, Workflows, Hooks, and Skills
*/
import {
GlobalStateAndSettings,
GlobalStateAndSettingsKey,
LocalState,
LocalStateKey,
SETTINGS_DEFAULTS,
} 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,
buildToggleEntries,
ConfigRow,
HookInfo,
HookRow,
MAX_VISIBLE,
parseValue,
SEPARATOR,
SectionHeader,
SkillInfo,
SkillRow,
TABS,
TabBar,
TabView,
TextInput,
ToggleEntry,
ToggleRow,
WorkspaceHooks,
} from "./ConfigViewComponents"
// ============================================================================
// Types
// ============================================================================
interface ConfigViewProps {
dataDir: string
globalState: Record<string, unknown>
workspaceState: Record<string, unknown>
onUpdateGlobal?: (key: GlobalStateAndSettingsKey, value: GlobalStateAndSettings[GlobalStateAndSettingsKey]) => void
onUpdateWorkspace?: (key: LocalStateKey, value: LocalState[LocalStateKey]) => void
// Rules toggles
globalClineRulesToggles?: Record<string, boolean>
localClineRulesToggles?: Record<string, boolean>
localCursorRulesToggles?: Record<string, boolean>
localWindsurfRulesToggles?: Record<string, boolean>
localAgentsRulesToggles?: Record<string, boolean>
onToggleRule?: (isGlobal: boolean, rulePath: string, enabled: boolean, ruleType: string) => void
// Workflow toggles
globalWorkflowToggles?: Record<string, boolean>
localWorkflowToggles?: Record<string, boolean>
onToggleWorkflow?: (isGlobal: boolean, workflowPath: string, enabled: boolean) => void
// Hooks
hooksEnabled?: boolean
globalHooks?: HookInfo[]
workspaceHooks?: WorkspaceHooks[]
onToggleHook?: (isGlobal: boolean, hookName: string, enabled: boolean, workspaceName?: string) => void
// Skills
skillsEnabled?: boolean
globalSkills?: SkillInfo[]
localSkills?: SkillInfo[]
onToggleSkill?: (isGlobal: boolean, skillPath: string, enabled: boolean) => void
// Open folder callback
onOpenFolder?: (folderType: "rules" | "workflows" | "hooks" | "skills", isGlobal: boolean) => void
}
// ============================================================================
// Main Component
// ============================================================================
export const ConfigView: React.FC<ConfigViewProps> = ({
dataDir,
globalState,
workspaceState,
onUpdateGlobal,
onUpdateWorkspace,
globalClineRulesToggles,
localClineRulesToggles,
localCursorRulesToggles,
localWindsurfRulesToggles,
localAgentsRulesToggles,
onToggleRule,
globalWorkflowToggles,
localWorkflowToggles,
onToggleWorkflow,
hooksEnabled,
globalHooks = [],
workspaceHooks = [],
onToggleHook,
skillsEnabled,
globalSkills = [],
localSkills = [],
onToggleSkill,
onOpenFolder,
}) => {
const { exit } = useApp()
const { isRawModeSupported } = useStdinContext()
const [currentTab, setCurrentTab] = useState<TabView>("settings")
const [isEditing, setIsEditing] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(0)
const [editValue, setEditValue] = useState("")
// Build entries for settings tab
const configEntries = useMemo(
() => [...buildConfigEntries(globalState, "global"), ...buildConfigEntries(workspaceState, "workspace")],
[globalState, workspaceState],
)
// Build entries for rules tab
const ruleEntries = useMemo(() => {
const entries: ToggleEntry[] = []
entries.push(...buildToggleEntries(globalClineRulesToggles, "global", "cline"))
entries.push(...buildToggleEntries(localClineRulesToggles, "workspace", "cline"))
entries.push(...buildToggleEntries(localCursorRulesToggles, "workspace", "cursor"))
entries.push(...buildToggleEntries(localWindsurfRulesToggles, "workspace", "windsurf"))
entries.push(...buildToggleEntries(localAgentsRulesToggles, "workspace", "agents"))
return entries
}, [
globalClineRulesToggles,
localClineRulesToggles,
localCursorRulesToggles,
localWindsurfRulesToggles,
localAgentsRulesToggles,
])
// Build entries for workflows tab
const workflowEntries = useMemo(() => {
const entries: ToggleEntry[] = []
entries.push(...buildToggleEntries(globalWorkflowToggles, "global"))
entries.push(...buildToggleEntries(localWorkflowToggles, "workspace"))
return entries
}, [globalWorkflowToggles, localWorkflowToggles])
// Build flat list of hooks
const hookEntries = useMemo(() => {
const entries: { hook: HookInfo; isGlobal: boolean; workspaceName?: string }[] = []
globalHooks.forEach((hook) => entries.push({ hook, isGlobal: true }))
workspaceHooks.forEach((ws) => {
ws.hooks.forEach((hook) => entries.push({ hook, isGlobal: false, workspaceName: ws.workspaceName }))
})
return entries.sort((a, b) => a.hook.name.localeCompare(b.hook.name))
}, [globalHooks, workspaceHooks])
// Build flat list of skills
const skillEntries = useMemo(() => {
const entries: { skill: SkillInfo; isGlobal: boolean }[] = []
globalSkills.forEach((skill) => entries.push({ skill, isGlobal: true }))
localSkills.forEach((skill) => entries.push({ skill, isGlobal: false }))
return entries.sort((a, b) => a.skill.name.localeCompare(b.skill.name))
}, [globalSkills, localSkills])
// Get current list length based on tab
const currentListLength = useMemo(() => {
switch (currentTab) {
case "settings":
return configEntries.length
case "rules":
return ruleEntries.length
case "workflows":
return workflowEntries.length
case "hooks":
return hookEntries.length
case "skills":
return skillEntries.length
default:
return 0
}
}, [currentTab, configEntries.length, ruleEntries.length, workflowEntries.length, hookEntries.length, skillEntries.length])
// Get available tabs
const availableTabs = useMemo(() => {
return TABS.filter((tab) => {
if (tab.requiresFlag === "hooks") {
return hooksEnabled
}
if (tab.requiresFlag === "skills") {
return skillsEnabled
}
return true
})
}, [hooksEnabled, skillsEnabled])
// Reset selection when changing tabs
const handleTabChange = (newTab: TabView) => {
setCurrentTab(newTab)
setSelectedIndex(0)
setIsEditing(false)
}
// Settings tab handlers
const selectedConfigEntry = configEntries[selectedIndex]
const handleSettingsSave = (value: string | boolean) => {
if (!selectedConfigEntry) {
return
}
const parsed = typeof value === "boolean" ? value : parseValue(value, selectedConfigEntry.type)
if (selectedConfigEntry.source === "global" && onUpdateGlobal) {
onUpdateGlobal(selectedConfigEntry.key as GlobalStateAndSettingsKey, parsed as never)
} else if (selectedConfigEntry.source === "workspace" && onUpdateWorkspace) {
onUpdateWorkspace(selectedConfigEntry.key as LocalStateKey, parsed as never)
}
setIsEditing(false)
}
const handleSettingsReset = () => {
if (!selectedConfigEntry?.isEditable || selectedConfigEntry.source !== "global") {
return
}
const defaultValue = (SETTINGS_DEFAULTS as Record<string, unknown>)[selectedConfigEntry.key]
if (defaultValue !== undefined && onUpdateGlobal) {
onUpdateGlobal(selectedConfigEntry.key as GlobalStateAndSettingsKey, defaultValue as never)
}
}
// Toggle handlers for rules/workflows/hooks/skills
const handleToggle = () => {
if (currentTab === "rules" && ruleEntries[selectedIndex] && onToggleRule) {
const entry = ruleEntries[selectedIndex]
onToggleRule(entry.source === "global", entry.path, !entry.enabled, entry.ruleType || "cline")
} else if (currentTab === "workflows" && workflowEntries[selectedIndex] && onToggleWorkflow) {
const entry = workflowEntries[selectedIndex]
onToggleWorkflow(entry.source === "global", entry.path, !entry.enabled)
} else if (currentTab === "hooks" && hookEntries[selectedIndex] && onToggleHook) {
const entry = hookEntries[selectedIndex]
onToggleHook(entry.isGlobal, entry.hook.name, !entry.hook.enabled, entry.workspaceName)
} else if (currentTab === "skills" && skillEntries[selectedIndex] && onToggleSkill) {
const entry = skillEntries[selectedIndex]
onToggleSkill(entry.isGlobal, entry.skill.path, !entry.skill.enabled)
}
}
// Input handling
useInput(
(input, key) => {
if (input.toLowerCase() === "q" || key.escape) {
exit()
}
// Tab navigation with Tab key or number keys
if (key.tab || (input >= "1" && input <= "5")) {
const targetIdx = key.tab
? (availableTabs.findIndex((t) => t.key === currentTab) + 1) % availableTabs.length
: parseInt(input) - 1
if (targetIdx >= 0 && targetIdx < availableTabs.length) {
handleTabChange(availableTabs[targetIdx].key)
}
return
}
// 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 || input === "j") {
setSelectedIndex((i) => (i < currentListLength - 1 ? i + 1 : 0))
}
// Tab-specific actions
if (currentTab === "settings") {
if ((key.return || input === "e") && selectedConfigEntry?.isEditable) {
setEditValue(selectedConfigEntry.value !== undefined ? String(selectedConfigEntry.value) : "")
setIsEditing(true)
} else if (input === "r") {
handleSettingsReset()
}
} else if (key.return || input === " ") {
// Toggle for rules/workflows/hooks/skills
handleToggle()
}
// Open folder (for rules/workflows/hooks/skills tabs)
if (input === "o" && onOpenFolder && currentTab !== "settings") {
// Determine if current selection is global or workspace based on the selected entry
let isGlobal = true
if (currentTab === "rules" && ruleEntries[selectedIndex]) {
isGlobal = ruleEntries[selectedIndex].source === "global"
} else if (currentTab === "workflows" && workflowEntries[selectedIndex]) {
isGlobal = workflowEntries[selectedIndex].source === "global"
} else if (currentTab === "hooks" && hookEntries[selectedIndex]) {
isGlobal = hookEntries[selectedIndex].isGlobal
} else if (currentTab === "skills" && skillEntries[selectedIndex]) {
isGlobal = skillEntries[selectedIndex].isGlobal
}
onOpenFolder(currentTab as "rules" | "workflows" | "hooks" | "skills", isGlobal)
}
},
{ isActive: isRawModeSupported && !isEditing },
)
// Scrolling window
const halfVisible = Math.floor(MAX_VISIBLE / 2)
const startIndex = Math.max(0, Math.min(selectedIndex - halfVisible, currentListLength - MAX_VISIBLE))
// Edit mode UI (settings only)
if (isEditing && selectedConfigEntry && currentTab === "settings") {
const header = (
<React.Fragment>
<Text bold color="white">
Edit Configuration
</Text>
<Text color="gray">{SEPARATOR}</Text>
</React.Fragment>
)
if (selectedConfigEntry.type === "boolean") {
return (
<Box flexDirection="column">
{header}
<BooleanSelect
label={selectedConfigEntry.key}
onCancel={() => setIsEditing(false)}
onSelect={handleSettingsSave}
value={Boolean(selectedConfigEntry.value)}
/>
</Box>
)
}
return (
<Box flexDirection="column">
{header}
<TextInput
label={selectedConfigEntry.key}
onCancel={() => setIsEditing(false)}
onChange={setEditValue}
onSubmit={handleSettingsSave}
type={selectedConfigEntry.type}
value={editValue}
/>
</Box>
)
}
// Render tab content
const renderTabContent = () => {
switch (currentTab) {
case "settings": {
const visibleEntries = configEntries.slice(startIndex, startIndex + MAX_VISIBLE)
return (
<React.Fragment>
<Box>
<Text>Data directory: </Text>
<Text color="blue" underline>
{dataDir}
</Text>
</Box>
<Text color="gray">{SEPARATOR}</Text>
<Box flexDirection="column">
{visibleEntries.map((entry, idx) => {
const actualIndex = startIndex + idx
const prevEntry = visibleEntries[idx - 1]
const showHeader = !prevEntry || prevEntry.source !== entry.source
return (
<React.Fragment key={`${entry.source}-${entry.key}`}>
{showHeader && (
<SectionHeader
title={entry.source === "global" ? "Global Settings:" : "Workspace Settings:"}
/>
)}
<ConfigRow entry={entry} isSelected={actualIndex === selectedIndex} />
</React.Fragment>
)
})}
</Box>
</React.Fragment>
)
}
case "rules": {
if (ruleEntries.length === 0) {
return (
<Box>
<Text color="gray">
No rules configured. Add .clinerules files to your workspace or global config.
</Text>
</Box>
)
}
const visibleEntries = ruleEntries.slice(startIndex, startIndex + MAX_VISIBLE)
return (
<Box flexDirection="column">
{visibleEntries.map((entry, idx) => {
const actualIndex = startIndex + idx
const prevEntry = visibleEntries[idx - 1]
const showHeader = !prevEntry || prevEntry.source !== entry.source
return (
<React.Fragment key={`${entry.source}-${entry.path}`}>
{showHeader && (
<SectionHeader title={entry.source === "global" ? "Global Rules:" : "Workspace Rules:"} />
)}
<ToggleRow entry={entry} isSelected={actualIndex === selectedIndex} showType />
</React.Fragment>
)
})}
</Box>
)
}
case "workflows": {
if (workflowEntries.length === 0) {
return (
<Box>
<Text color="gray">No workflows configured. Add workflow files to enable this feature.</Text>
</Box>
)
}
const visibleEntries = workflowEntries.slice(startIndex, startIndex + MAX_VISIBLE)
return (
<Box flexDirection="column">
{visibleEntries.map((entry, idx) => {
const actualIndex = startIndex + idx
const prevEntry = visibleEntries[idx - 1]
const showHeader = !prevEntry || prevEntry.source !== entry.source
return (
<React.Fragment key={`${entry.source}-${entry.path}`}>
{showHeader && (
<SectionHeader
title={entry.source === "global" ? "Global Workflows:" : "Workspace Workflows:"}
/>
)}
<ToggleRow entry={entry} isSelected={actualIndex === selectedIndex} />
</React.Fragment>
)
})}
</Box>
)
}
case "hooks": {
if (hookEntries.length === 0) {
return (
<Box>
<Text color="gray">No hooks configured. Add hook scripts to enable automation.</Text>
</Box>
)
}
const visibleEntries = hookEntries.slice(startIndex, startIndex + MAX_VISIBLE)
return (
<Box flexDirection="column">
{visibleEntries.map((entry, idx) => {
const actualIndex = startIndex + idx
const prevEntry = visibleEntries[idx - 1]
const showHeader =
!prevEntry ||
prevEntry.isGlobal !== entry.isGlobal ||
prevEntry.workspaceName !== entry.workspaceName
let sectionTitle = "Global Hooks:"
if (!entry.isGlobal && entry.workspaceName) {
sectionTitle = `${entry.workspaceName} Hooks:`
}
return (
<React.Fragment key={`${entry.isGlobal}-${entry.workspaceName || ""}-${entry.hook.name}`}>
{showHeader && <SectionHeader title={sectionTitle} />}
<HookRow hook={entry.hook} isSelected={actualIndex === selectedIndex} />
</React.Fragment>
)
})}
</Box>
)
}
case "skills": {
if (skillEntries.length === 0) {
return (
<Box>
<Text color="gray">No skills configured. Add SKILL.md files to enable skills.</Text>
</Box>
)
}
const visibleEntries = skillEntries.slice(startIndex, startIndex + MAX_VISIBLE)
return (
<Box flexDirection="column">
{visibleEntries.map((entry, idx) => {
const actualIndex = startIndex + idx
const prevEntry = visibleEntries[idx - 1]
const showHeader = !prevEntry || prevEntry.isGlobal !== entry.isGlobal
return (
<React.Fragment key={`${entry.isGlobal}-${entry.skill.path}`}>
{showHeader && (
<SectionHeader title={entry.isGlobal ? "Global Skills:" : "Workspace Skills:"} />
)}
<SkillRow isSelected={actualIndex === selectedIndex} skill={entry.skill} />
</React.Fragment>
)
})}
</Box>
)
}
default:
return null
}
}
// Help text based on current tab
const getHelpText = () => {
const base = "↑/↓/j/k Navigate • Tab/1-5 Switch tabs • q/Esc Exit"
if (currentTab === "settings") {
return `${base} • Enter/e Edit • r Reset`
}
const openFolder = onOpenFolder ? " • o Open folder" : ""
return `${base} • Enter/Space Toggle${openFolder}`
}
return (
<Box flexDirection="column">
<Text bold color="white">
Cline Configuration
</Text>
<Text color="gray">{SEPARATOR}</Text>
<TabBar currentTab={currentTab} hooksEnabled={hooksEnabled} skillsEnabled={skillsEnabled} tabs={TABS} />
<Text color="gray">{SEPARATOR}</Text>
{renderTabContent()}
{currentListLength > MAX_VISIBLE && (
<Box marginTop={1}>
<Text color="gray">
{startIndex > 0 ? "↑ " : " "}
Showing {startIndex + 1}-{Math.min(startIndex + MAX_VISIBLE, currentListLength)} of {currentListLength}
{startIndex + MAX_VISIBLE < currentListLength ? " ↓" : " "}
</Text>
</Box>
)}
<Text color="gray">{SEPARATOR}</Text>
<Box flexDirection="column">
<Text color="gray">{getHelpText()}</Text>
{currentTab === "settings" && selectedConfigEntry && !selectedConfigEntry.isEditable && (
<Text color="yellow">This field is read-only ({selectedConfigEntry.type} type or not a setting)</Text>
)}
</Box>
</Box>
)
}
@@ -0,0 +1,381 @@
/**
* Sub-components and types for ConfigView
*/
import { Box, Text, useInput } from "ink"
import React, { useState } from "react"
import { useStdinContext } from "../context/StdinContext"
// ============================================================================
// Types & Constants
// ============================================================================
export type ValueType = "string" | "number" | "boolean" | "object" | "undefined"
export type TabView = "settings" | "rules" | "workflows" | "hooks" | "skills"
export interface ConfigEntry {
key: string
value: unknown
type: ValueType
isEditable: boolean
source: "global" | "workspace"
}
export interface ToggleEntry {
path: string
enabled: boolean
source: "global" | "workspace" | "remote"
ruleType?: string
}
export interface HookInfo {
name: string
enabled: boolean
absolutePath: string
}
export interface WorkspaceHooks {
workspaceName: string
hooks: HookInfo[]
}
export interface SkillInfo {
name: string
description: string
path: string
enabled: boolean
}
export const EXCLUDED_KEYS = new Set([
"taskHistory",
"primaryRootIndex",
"subagentsEnabled",
"subagentTerminalOutputLineLimit",
"welcomeViewCompleted",
"isNewUser",
])
export const EDITABLE_TYPES: Set<ValueType> = new Set(["string", "number", "boolean"])
export const MAX_VISIBLE = 12
export const SEPARATOR = "─".repeat(80)
export const TABS: { key: TabView; label: string; requiresFlag?: "hooks" | "skills" }[] = [
{ key: "settings", label: "Settings" },
{ key: "rules", label: "Rules" },
{ key: "workflows", label: "Workflows" },
{ key: "hooks", label: "Hooks", requiresFlag: "hooks" },
{ key: "skills", label: "Skills", requiresFlag: "skills" },
]
// ============================================================================
// Helper Functions
// ============================================================================
export function getValueType(value: unknown): ValueType {
if (value === undefined || value === null) {
return "undefined"
}
if (typeof value === "boolean") {
return "boolean"
}
if (typeof value === "number") {
return "number"
}
if (typeof value === "object") {
return "object"
}
return "string"
}
export function isExcluded(key: string, value: unknown): boolean {
if (EXCLUDED_KEYS.has(key)) {
return true
}
if (key.endsWith("Toggles") || key.endsWith("ModelInfo")) {
return true
}
if (key.startsWith("apiConfig_") || key.startsWith("last")) {
return true
}
if (value === undefined || value === null) {
return true
}
if (typeof value === "object" && Object.keys(value as object).length === 0) {
return true
}
if (Array.isArray(value) && value.length === 0) {
return true
}
if (typeof value === "string" && value.trim() === "") {
return true
}
return false
}
export function formatValue(value: unknown, maxLen = 50): string {
if (value === undefined || value === null) {
return "<not set>"
}
if (typeof value === "boolean") {
return value ? "true" : "false"
}
if (typeof value === "number") {
return String(value)
}
if (typeof value === "object") {
const json = JSON.stringify(value)
return json.length > maxLen ? json.slice(0, maxLen - 3) + "..." : json
}
const str = String(value)
return str.length > maxLen ? str.slice(0, maxLen - 3) + "..." : str
}
export function parseValue(input: string, type: ValueType): unknown {
if (type === "boolean") {
return input.toLowerCase() === "true" || input === "1"
}
if (type === "number") {
const num = parseFloat(input)
return Number.isNaN(num) ? 0 : num
}
if (type === "object") {
try {
return JSON.parse(input)
} catch {
return {}
}
}
return input
}
// Import isSettingsKey at module level for proper test mocking
import { isSettingsKey } from "@shared/storage/state-keys"
export function buildConfigEntries(state: Record<string, unknown>, source: "global" | "workspace"): ConfigEntry[] {
return Object.entries(state)
.filter(([key, value]) => !isExcluded(key, value))
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => {
const type = getValueType(value)
const isEditable = EDITABLE_TYPES.has(type) && (source === "workspace" || isSettingsKey(key))
return { key, value, type, isEditable, source }
})
}
export function buildToggleEntries(
toggles: Record<string, boolean> | undefined,
source: "global" | "workspace" | "remote",
ruleType?: string,
): ToggleEntry[] {
if (!toggles) {
return []
}
return Object.entries(toggles)
.sort(([a], [b]) => a.localeCompare(b))
.map(([path, enabled]) => ({ path, enabled, source, ruleType }))
}
export function getFileName(path: string): string {
return path.split("/").pop() || path
}
// ============================================================================
// Sub-components
// ============================================================================
interface TextInputProps {
label: string
onChange: (value: string) => void
onCancel: () => void
onSubmit: (value: string) => void
type: ValueType
value: string
}
export const TextInput: React.FC<TextInputProps> = ({ label, onChange, onCancel, onSubmit, type, value }) => {
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}>
<Text bold color="cyan">
Edit: {label}
</Text>
<Box>
<Text color="white">{value}</Text>
<Text inverse> </Text>
</Box>
<Text color="gray">Type: {type} Enter to save Esc to cancel</Text>
</Box>
)
}
interface BooleanSelectProps {
label: string
onCancel: () => void
onSelect: (value: boolean) => void
value: boolean
}
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)
}
},
{ isActive: isRawModeSupported },
)
return (
<Box flexDirection="column" marginTop={1}>
<Text bold color="cyan">
Edit: {label}
</Text>
<Box flexDirection="column">
<Text color={selected ? "green" : undefined}>{selected ? " " : " "}true</Text>
<Text color={!selected ? "green" : undefined}>{!selected ? " " : " "}false</Text>
</Box>
<Text color="gray">/ to toggle Enter to save Esc to cancel</Text>
</Box>
)
}
export const ConfigRow: React.FC<{ entry: ConfigEntry; isSelected: boolean }> = ({ entry, isSelected }) => {
const valueColor = entry.type === "boolean" ? (entry.value ? "green" : "red") : "white"
return (
<Box>
<Text color={isSelected ? "cyan" : undefined}>
{isSelected ? " " : " "}
<Text color="cyan">{entry.key}</Text>
<Text color="gray">: </Text>
<Text color={valueColor}>{formatValue(entry.value)}</Text>
{!entry.isEditable && <Text color="gray"> (read-only)</Text>}
</Text>
</Box>
)
}
export const ToggleRow: React.FC<{
entry: ToggleEntry
isSelected: boolean
showType?: boolean
}> = ({ entry, isSelected, showType }) => {
const fileName = getFileName(entry.path)
const typeLabel = entry.ruleType ? ` [${entry.ruleType}]` : ""
return (
<Box>
<Text color={isSelected ? "cyan" : undefined}>
{isSelected ? " " : " "}
<Text color={entry.enabled ? "green" : "red"}>{entry.enabled ? "●" : "○"}</Text>
<Text> </Text>
<Text color="white">{fileName}</Text>
{showType && <Text color="gray">{typeLabel}</Text>}
</Text>
</Box>
)
}
export const HookRow: React.FC<{
hook: HookInfo
isSelected: boolean
}> = ({ hook, isSelected }) => {
return (
<Box>
<Text color={isSelected ? "cyan" : undefined}>
{isSelected ? " " : " "}
<Text color={hook.enabled ? "green" : "red"}>{hook.enabled ? "●" : "○"}</Text>
<Text> </Text>
<Text color="white">{hook.name}</Text>
</Text>
</Box>
)
}
export const SkillRow: React.FC<{
skill: SkillInfo
isSelected: boolean
}> = ({ skill, isSelected }) => {
return (
<Box flexDirection="column">
<Box>
<Text color={isSelected ? "cyan" : undefined}>
{isSelected ? " " : " "}
<Text color={skill.enabled ? "green" : "red"}>{skill.enabled ? "●" : "○"}</Text>
<Text> </Text>
<Text bold color="white">
{skill.name}
</Text>
</Text>
</Box>
{skill.description && (
<Box marginLeft={4}>
<Text color="gray">
{skill.description.length > 60 ? skill.description.slice(0, 57) + "..." : skill.description}
</Text>
</Box>
)}
</Box>
)
}
export const TabBar: React.FC<{
currentTab: TabView
tabs: typeof TABS
hooksEnabled?: boolean
skillsEnabled?: boolean
}> = ({ currentTab, tabs, hooksEnabled, skillsEnabled }) => {
const visibleTabs = tabs.filter((tab) => {
if (tab.requiresFlag === "hooks") {
return hooksEnabled
}
if (tab.requiresFlag === "skills") {
return skillsEnabled
}
return true
})
return (
<Box marginBottom={1}>
{visibleTabs.map((tab, idx) => (
<React.Fragment key={tab.key}>
{idx > 0 && <Text color="gray"> </Text>}
<Text bold={currentTab === tab.key} color={currentTab === tab.key ? "cyan" : "gray"}>
{currentTab === tab.key ? `[${tab.label}]` : tab.label}
</Text>
</React.Fragment>
))}
</Box>
)
}
export const SectionHeader: React.FC<{ title: string }> = ({ title }) => (
<Box marginTop={1}>
<Text bold color="yellow">
{title}
</Text>
</Box>
)
+301
View File
@@ -0,0 +1,301 @@
/**
* Stateful wrapper for ConfigView that handles toggle operations
*/
import { exec } from "node:child_process"
import os from "node:os"
import path from "node:path"
import { RuleScope } from "@shared/proto/cline/file"
import type { GlobalStateAndSettings, GlobalStateAndSettingsKey, LocalState, LocalStateKey } from "@shared/storage/state-keys"
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 {
name: string
enabled: boolean
absolutePath: string
}
interface WorkspaceHooks {
workspaceName: string
hooks: HookInfo[]
}
interface SkillInfo {
name: string
description: string
path: string
enabled: boolean
}
interface ConfigViewWrapperProps {
controller: Controller
dataDir: string
globalState: Record<string, unknown>
workspaceState: Record<string, unknown>
hooksEnabled: boolean
skillsEnabled: boolean
isRawModeSupported?: boolean
}
export const ConfigViewWrapper: React.FC<ConfigViewWrapperProps> = ({
controller,
dataDir,
globalState: initialGlobalState,
workspaceState: initialWorkspaceState,
hooksEnabled,
skillsEnabled,
isRawModeSupported = true,
}) => {
// Settings state (managed locally for UI updates)
const [globalStateLocal, setGlobalStateLocal] = useState<Record<string, unknown>>(initialGlobalState)
const [workspaceStateLocal, setWorkspaceStateLocal] = useState<Record<string, unknown>>(initialWorkspaceState)
// Rules state
const [globalClineRulesToggles, setGlobalClineRulesToggles] = useState<Record<string, boolean>>({})
const [localClineRulesToggles, setLocalClineRulesToggles] = useState<Record<string, boolean>>({})
const [localCursorRulesToggles, setLocalCursorRulesToggles] = useState<Record<string, boolean>>({})
const [localWindsurfRulesToggles, setLocalWindsurfRulesToggles] = useState<Record<string, boolean>>({})
const [localAgentsRulesToggles, setLocalAgentsRulesToggles] = useState<Record<string, boolean>>({})
// Workflow state
const [globalWorkflowToggles, setGlobalWorkflowToggles] = useState<Record<string, boolean>>({})
const [localWorkflowToggles, setLocalWorkflowToggles] = useState<Record<string, boolean>>({})
// Hooks state
const [globalHooks, setGlobalHooks] = useState<HookInfo[]>([])
const [workspaceHooksState, setWorkspaceHooksState] = useState<WorkspaceHooks[]>([])
// Skills state
const [globalSkills, setGlobalSkills] = useState<SkillInfo[]>([])
const [localSkills, setLocalSkills] = useState<SkillInfo[]>([])
// Load initial data
useEffect(() => {
const loadData = async () => {
const { refreshRules } = await import("@/core/controller/file/refreshRules")
const { refreshHooks } = await import("@/core/controller/file/refreshHooks")
const { refreshSkills } = await import("@/core/controller/file/refreshSkills")
const rulesData = await refreshRules(controller, {})
setGlobalClineRulesToggles(rulesData.globalClineRulesToggles?.toggles || {})
setLocalClineRulesToggles(rulesData.localClineRulesToggles?.toggles || {})
setLocalCursorRulesToggles(rulesData.localCursorRulesToggles?.toggles || {})
setLocalWindsurfRulesToggles(rulesData.localWindsurfRulesToggles?.toggles || {})
setLocalAgentsRulesToggles(rulesData.localAgentsRulesToggles?.toggles || {})
setGlobalWorkflowToggles(rulesData.globalWorkflowToggles?.toggles || {})
setLocalWorkflowToggles(rulesData.localWorkflowToggles?.toggles || {})
if (hooksEnabled) {
const hooksData = await refreshHooks(controller, {})
setGlobalHooks(hooksData.globalHooks || [])
setWorkspaceHooksState(hooksData.workspaceHooks || [])
}
if (skillsEnabled) {
const skillsData = await refreshSkills(controller)
setGlobalSkills(skillsData.globalSkills || [])
setLocalSkills(skillsData.localSkills || [])
}
}
loadData()
}, [controller, hooksEnabled, skillsEnabled])
// Toggle handlers
const handleToggleRule = useCallback(
async (isGlobal: boolean, rulePath: string, enabled: boolean, ruleType: string) => {
const { toggleClineRule } = await import("@/core/controller/file/toggleClineRule")
// Determine scope based on isGlobal and rule type
const scope = isGlobal ? RuleScope.GLOBAL : RuleScope.LOCAL
// For non-cline rules, we need different toggle functions
if (ruleType === "cursor") {
// Update local state optimistically
setLocalCursorRulesToggles((prev) => ({ ...prev, [rulePath]: enabled }))
// Cursor rules use toggleCursorRule but we'll just update the state manager directly
const toggles = controller.stateManager.getWorkspaceStateKey("localCursorRulesToggles") || {}
toggles[rulePath] = enabled
controller.stateManager.setWorkspaceState("localCursorRulesToggles", toggles)
} else if (ruleType === "windsurf") {
setLocalWindsurfRulesToggles((prev) => ({ ...prev, [rulePath]: enabled }))
const toggles = controller.stateManager.getWorkspaceStateKey("localWindsurfRulesToggles") || {}
toggles[rulePath] = enabled
controller.stateManager.setWorkspaceState("localWindsurfRulesToggles", toggles)
} else if (ruleType === "agents") {
setLocalAgentsRulesToggles((prev) => ({ ...prev, [rulePath]: enabled }))
const toggles = controller.stateManager.getWorkspaceStateKey("localAgentsRulesToggles") || {}
toggles[rulePath] = enabled
controller.stateManager.setWorkspaceState("localAgentsRulesToggles", toggles)
} else {
// Cline rules
const result = await toggleClineRule(controller, { metadata: undefined, rulePath, enabled, scope })
if (result.globalClineRulesToggles?.toggles) {
setGlobalClineRulesToggles(result.globalClineRulesToggles.toggles)
}
if (result.localClineRulesToggles?.toggles) {
setLocalClineRulesToggles(result.localClineRulesToggles.toggles)
}
}
},
[controller],
)
const handleToggleWorkflow = useCallback(
async (isGlobal: boolean, workflowPath: string, enabled: boolean) => {
const { toggleWorkflow } = await import("@/core/controller/file/toggleWorkflow")
const scope = isGlobal ? RuleScope.GLOBAL : RuleScope.LOCAL
// Optimistic update
if (isGlobal) {
setGlobalWorkflowToggles((prev) => ({ ...prev, [workflowPath]: enabled }))
} else {
setLocalWorkflowToggles((prev) => ({ ...prev, [workflowPath]: enabled }))
}
await toggleWorkflow(controller, { metadata: undefined, workflowPath, enabled, scope })
},
[controller],
)
const handleToggleHook = useCallback(
async (isGlobal: boolean, hookName: string, enabled: boolean, workspaceName?: string) => {
const { toggleHook } = await import("@/core/controller/file/toggleHook")
// Optimistic update
if (isGlobal) {
setGlobalHooks((prev) => prev.map((h) => (h.name === hookName ? { ...h, enabled } : h)))
} else {
setWorkspaceHooksState((prev) =>
prev.map((ws) =>
ws.workspaceName === workspaceName
? { ...ws, hooks: ws.hooks.map((h) => (h.name === hookName ? { ...h, enabled } : h)) }
: ws,
),
)
}
const result = await toggleHook(controller, { metadata: undefined, hookName, isGlobal, enabled, workspaceName })
if (result.hooksToggles) {
setGlobalHooks(result.hooksToggles.globalHooks || [])
setWorkspaceHooksState(result.hooksToggles.workspaceHooks || [])
}
},
[controller],
)
const handleToggleSkill = useCallback(
async (isGlobal: boolean, skillPath: string, enabled: boolean) => {
const { toggleSkill } = await import("@/core/controller/file/toggleSkill")
// Optimistic update
if (isGlobal) {
setGlobalSkills((prev) => prev.map((s) => (s.path === skillPath ? { ...s, enabled } : s)))
} else {
setLocalSkills((prev) => prev.map((s) => (s.path === skillPath ? { ...s, enabled } : s)))
}
await toggleSkill(controller, { metadata: undefined, skillPath, isGlobal, enabled })
},
[controller],
)
const handleOpenFolder = useCallback(
async (folderType: "rules" | "workflows" | "hooks" | "skills", isGlobal: boolean) => {
let folderPath: string
if (isGlobal) {
// Global folders are in dataDir (e.g., ~/.cline/)
const subFolder = folderType === "rules" ? "rules" : folderType
folderPath = path.join(dataDir, subFolder)
} else {
// Local folders are in the workspace
const workspacePaths = await HostProvider.workspace.getWorkspacePaths({})
const primaryWorkspace = workspacePaths.paths[0]
if (!primaryWorkspace) {
return
}
// Local rules/workflows/hooks/skills are in .clinerules or .cline
const subFolder = folderType === "rules" ? "rules" : folderType
folderPath = path.join(primaryWorkspace, ".clinerules", subFolder)
}
// Open folder using platform-specific command
const platform = os.platform()
let command: string
if (platform === "darwin") {
command = `open "${folderPath}"`
} else if (platform === "win32") {
command = `explorer "${folderPath}"`
} else {
command = `xdg-open "${folderPath}"`
}
exec(command, (error) => {
if (error) {
// Folder might not exist, try to create and open
exec(`mkdir -p "${folderPath}" && ${command}`)
}
})
},
[dataDir],
)
// Settings update handlers
const handleUpdateGlobal = useCallback(
async (key: GlobalStateAndSettingsKey, value: GlobalStateAndSettings[GlobalStateAndSettingsKey]) => {
// Update local state for immediate UI feedback
setGlobalStateLocal((prev) => ({ ...prev, [key]: value }))
// Persist to state manager
controller.stateManager.setGlobalState(key, value)
await controller.stateManager.flushPendingState()
},
[controller],
)
const handleUpdateWorkspace = useCallback(
async (key: LocalStateKey, value: LocalState[LocalStateKey]) => {
// Update local state for immediate UI feedback
setWorkspaceStateLocal((prev) => ({ ...prev, [key]: value }))
// Persist to state manager
controller.stateManager.setWorkspaceState(key, value)
await controller.stateManager.flushPendingState()
},
[controller],
)
return (
<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>
)
}
+216
View File
@@ -0,0 +1,216 @@
/**
* 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 {
/** Diff content (SEARCH/REPLACE format, ApplyPatch format, or raw content for new files) */
content?: string
}
interface DiffLine {
type: "add" | "remove" | "context" | "separator"
content: string
}
interface ParsedPatch {
additions: number
deletions: number
lines: DiffLine[]
}
// 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++
}
}
}
return result
}
/**
* Parse ApplyPatch format into diff lines
* Format: *** Begin Patch\n*** Update File: path\n+line\n-line\n*** End Patch
*/
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 result
}
/**
* Parse tool content into diff lines
* Detects format and delegates to appropriate parser
*/
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")
return {
additions: lines.length,
deletions: 0,
lines: lines.map((line) => ({ type: "add", content: line })),
}
}
// 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>
)
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
}
}
/**
* DiffView component that renders file edits as a diff
* Supports SEARCH/REPLACE format and ApplyPatch format
*/
export const DiffView: React.FC<DiffViewProps> = ({ content }) => {
if (!content) {
return null
}
const parsed = parseToolContent(content)
return (
<Box flexDirection="column" width="100%">
{parsed.lines.map((line, idx) => (
<DiffLineRow key={idx} line={line} />
))}
</Box>
)
}
+83
View File
@@ -0,0 +1,83 @@
/**
* File mention menu component for CLI
* Displays a list of matching files when user types @
*/
import { Box, Text } from "ink"
import React from "react"
import { COLORS } from "../constants/colors"
import { type FileSearchResult, getRipgrepInstallInstructions } from "../utils/file-search"
import { getVisibleWindow } from "../utils/slash-commands"
interface FileMentionMenuProps {
results: FileSearchResult[]
selectedIndex: number
isLoading: boolean
query: string
showRipgrepWarning?: boolean
}
/**
* Truncate path from the left if too long, keeping the filename visible
*/
function truncatePath(filePath: string, maxLength: number = 50): string {
if (filePath.length <= maxLength) {
return filePath
}
return "..." + filePath.slice(-(maxLength - 3))
}
export const FileMentionMenu: React.FC<FileMentionMenuProps> = ({
results,
selectedIndex,
isLoading,
query,
showRipgrepWarning,
}) => {
const ripgrepWarning = showRipgrepWarning && (
<Box marginTop={1}>
<Text color="yellow">ripgrep not found - file search will be slower. </Text>
<Text color="gray">Install: {getRipgrepInstallInstructions()}</Text>
</Box>
)
if (isLoading) {
return (
<Box flexDirection="column" marginBottom={1} paddingLeft={1} paddingRight={1}>
<Text color="gray">Searching files...</Text>
{ripgrepWarning}
</Box>
)
}
if (results.length === 0) {
return (
<Box flexDirection="column" marginBottom={1} paddingLeft={1} paddingRight={1}>
<Text color="gray">{query ? `No files matching "${query}"` : "Type to search files..."}</Text>
{ripgrepWarning}
</Box>
)
}
const { items: visibleResults, startIndex } = getVisibleWindow(results, selectedIndex)
const hasMoreBelow = startIndex + visibleResults.length < results.length
return (
<Box flexDirection="column" marginBottom={1} paddingLeft={1} paddingRight={1}>
{visibleResults.map((result, idx) => {
const isSelected = startIndex + idx === selectedIndex
const displayPath = truncatePath(result.path)
return (
<Box key={result.path}>
<Text color={isSelected ? COLORS.primaryBlue : undefined}>
{isSelected ? "" : " "} {displayPath}
</Text>
</Box>
)
})}
{hasMoreBelow && <Text color="gray">{" "}</Text>}
{ripgrepWarning}
</Box>
)
}
+185
View File
@@ -0,0 +1,185 @@
/**
* Focus Chain / To-Do List component for CLI
* Displays a progress-tracked checklist of tasks
*/
import { isCompletedFocusChainItem, isFocusChainItem, parseFocusChainItem } from "@shared/focus-chain-utils"
import { Box, Text } from "ink"
import React, { useMemo } from "react"
interface TodoInfo {
currentTodo: { text: string; completed: boolean; index: number } | null
currentIndex: number
completedCount: number
totalCount: number
progressPercentage: number
}
interface TodoItem {
text: string
checked: boolean
}
interface FocusChainProps {
focusChainChecklist?: string | null
expanded?: boolean
}
/**
* Parse the focus chain checklist text into TodoInfo
*/
function parseCurrentTodoInfo(text: string): TodoInfo | null {
if (!text) {
return null
}
let completedCount = 0
let totalCount = 0
let firstIncompleteIndex = -1
let firstIncompleteText: string | null = null
const lines = text.split("\n")
for (const rawLine of lines) {
const line = rawLine.trim()
if (isFocusChainItem(line)) {
const isCompleted = isCompletedFocusChainItem(line)
if (isCompleted) {
completedCount++
} else if (firstIncompleteIndex === -1) {
firstIncompleteIndex = totalCount
// Extract text after "- [ ] "
firstIncompleteText = line.substring(5).trim()
}
totalCount++
}
}
if (totalCount === 0) {
return null
}
const currentTodo = firstIncompleteText ? { text: firstIncompleteText, completed: false, index: firstIncompleteIndex } : null
return {
currentTodo,
currentIndex: firstIncompleteIndex >= 0 ? firstIncompleteIndex + 1 : totalCount,
completedCount,
totalCount,
progressPercentage: (completedCount / totalCount) * 100,
}
}
/**
* Parse all todo items from the checklist
*/
function parseTodoItems(text: string): TodoItem[] {
const items: TodoItem[] = []
const lines = text.split("\n")
for (const rawLine of lines) {
const line = rawLine.trim()
const parsed = parseFocusChainItem(line)
if (parsed) {
items.push(parsed)
}
}
return items
}
/**
* Render progress bar
*/
const ProgressBar: React.FC<{ percentage: number; width?: number }> = ({ percentage, width = 20 }) => {
const filled = Math.round((percentage / 100) * width)
const empty = width - filled
const bar = "█".repeat(filled) + "░".repeat(empty)
return (
<Text>
<Text color="green">{bar}</Text>
<Text dimColor> {Math.round(percentage)}%</Text>
</Text>
)
}
/**
* Header view showing current task and progress
*/
const Header: React.FC<{
todoInfo: TodoInfo
}> = ({ todoInfo }) => {
const { currentTodo, currentIndex, totalCount, completedCount } = todoInfo
const isCompleted = completedCount === totalCount
const displayText = isCompleted ? "All tasks completed!" : currentTodo?.text || "To-Do list"
const truncatedText = displayText.length > 50 ? displayText.substring(0, 47) + "..." : displayText
return (
<Box flexDirection="row" gap={1}>
<Text color={isCompleted ? "green" : "cyan"}>
[{currentIndex}/{totalCount}]
</Text>
<Text color={isCompleted ? "green" : undefined}>{truncatedText}</Text>
</Box>
)
}
/**
* Expanded view showing all todo items
*/
const ExpandedList: React.FC<{
items: TodoItem[]
isCompleted: boolean
}> = ({ items, isCompleted }) => {
return (
<Box flexDirection="column" marginLeft={2} marginTop={1}>
{items.map((item, index) => (
<Box key={index}>
<Text color={item.checked ? "green" : "gray"}>{item.checked ? "✓" : "○"} </Text>
<Text color={item.checked ? "green" : undefined} dimColor={item.checked}>
{item.text}
</Text>
</Box>
))}
{isCompleted && (
<Box marginTop={1}>
<Text dimColor italic>
New steps will be generated if you continue the task
</Text>
</Box>
)}
</Box>
)
}
/**
* Main FocusChain component for CLI
* Shows a progress summary of the current to-do list
* Use expanded={true} to show all items (e.g., in verbose mode)
*/
export const FocusChain: React.FC<FocusChainProps> = ({ focusChainChecklist, expanded = false }) => {
const todoInfo = useMemo(
() => (focusChainChecklist ? parseCurrentTodoInfo(focusChainChecklist) : null),
[focusChainChecklist],
)
const todoItems = useMemo(() => (focusChainChecklist ? parseTodoItems(focusChainChecklist) : []), [focusChainChecklist])
// No content to display
if (!todoInfo) {
return null
}
const isCompleted = todoInfo.completedCount === todoInfo.totalCount
return (
<Box borderColor={isCompleted ? "green" : "gray"} borderStyle="round" flexDirection="column" paddingX={1}>
<Header todoInfo={todoInfo} />
<ProgressBar percentage={todoInfo.progressPercentage} />
{expanded && <ExpandedList isCompleted={isCompleted} items={todoItems} />}
</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>
)
}
@@ -0,0 +1,226 @@
/**
* History panel content for inline display in ChatView
* Shows task history with search and keyboard navigation
*/
import { StringRequest } from "@shared/proto/cline/common"
import { GetTaskHistoryRequest } from "@shared/proto/cline/task"
import { Box, Text, useInput } from "ink"
import React, { useCallback, useEffect, useMemo, useState } from "react"
import type { Controller } from "@/core/controller"
import { getTaskHistory } from "@/core/controller/task/getTaskHistory"
import { showTaskWithId } from "@/core/controller/task/showTaskWithId"
import { COLORS } from "../constants/colors"
import { useStdinContext } from "../context/StdinContext"
import { useTerminalSize } from "../hooks/useTerminalSize"
import { isMouseEscapeSequence } from "../utils/input"
import { Panel } from "./Panel"
interface TaskHistoryItem {
id: string
ts: number
task: string
totalCost: number
tokensIn: number
tokensOut: number
isFavorited: boolean
}
interface HistoryPanelContentProps {
onClose: () => void
onSelectTask: (taskId: string) => void
controller: Controller
}
function formatRelativeDate(ts: number): string {
const now = Date.now()
const diff = now - ts
const minutes = Math.floor(diff / 60000)
const hours = Math.floor(diff / 3600000)
const days = Math.floor(diff / 86400000)
if (minutes < 1) return "just now"
if (minutes < 60) return `${minutes}m ago`
if (hours < 24) return `${hours}h ago`
if (days < 7) return `${days}d ago`
return new Date(ts).toLocaleDateString()
}
function formatCost(cost: number): string {
if (cost === 0) return ""
return `$${cost.toFixed(2)}`
}
export const HistoryPanelContent: React.FC<HistoryPanelContentProps> = ({ onClose, onSelectTask, controller }) => {
const { isRawModeSupported } = useStdinContext()
const { rows: terminalRows } = useTerminalSize()
const [items, setItems] = useState<TaskHistoryItem[]>([])
const [searchQuery, setSearchQuery] = useState("")
const [selectedIndex, setSelectedIndex] = useState(0)
const [loading, setLoading] = useState(true)
// Calculate how many items fit in the panel
// Panel has border (2) + header (1) + separator (1) + search bar (1) + hint (1) = 6 lines overhead
// Each item takes 2 lines (text + metadata)
const panelHeight = Math.min(terminalRows - 6, 20) // Cap panel height
const itemHeight = 2
const maxVisible = Math.max(1, Math.floor((panelHeight - 4) / itemHeight) - 2) // 4 lines for search + hints + padding
// Load history
useEffect(() => {
const load = async () => {
setLoading(true)
try {
const request = GetTaskHistoryRequest.create({
sortBy: "newest",
searchQuery: searchQuery || undefined,
})
const result = await getTaskHistory(controller, request)
setItems(
result.tasks.map((t) => ({
id: t.id,
ts: t.ts,
task: t.task,
totalCost: t.totalCost,
tokensIn: t.tokensIn,
tokensOut: t.tokensOut,
isFavorited: t.isFavorited,
})),
)
} catch {
setItems([])
}
setLoading(false)
}
load()
}, [controller, searchQuery])
// Reset selection when search changes
useEffect(() => {
setSelectedIndex(0)
}, [searchQuery])
const handleSelect = useCallback(
async (item: TaskHistoryItem) => {
try {
await showTaskWithId(controller, StringRequest.create({ value: item.id }))
onSelectTask(item.id)
} catch (error) {
console.error("Error opening task:", error)
}
},
[controller, onSelectTask],
)
// Visible window
const scrollOffset = useMemo(() => {
const half = Math.floor(maxVisible / 2)
let start = Math.max(0, selectedIndex - half)
const end = Math.min(items.length, start + maxVisible)
if (end - start < maxVisible) {
start = Math.max(0, end - maxVisible)
}
return start
}, [selectedIndex, maxVisible, items.length])
const visibleItems = items.slice(scrollOffset, scrollOffset + maxVisible)
const showUpIndicator = scrollOffset > 0
const showDownIndicator = scrollOffset + maxVisible < items.length
useInput(
(input, key) => {
if (isMouseEscapeSequence(input)) {
return
}
if (key.escape) {
if (searchQuery) {
setSearchQuery("")
} else {
onClose()
}
return
}
if (key.return && items[selectedIndex]) {
handleSelect(items[selectedIndex])
return
}
if (key.upArrow) {
setSelectedIndex((i) => Math.max(0, i - 1))
return
}
if (key.downArrow) {
setSelectedIndex((i) => Math.min(items.length - 1, i + 1))
return
}
// Backspace for search
if (key.backspace || key.delete) {
setSearchQuery((q) => q.slice(0, -1))
return
}
// Printable characters for search
if (input && !key.ctrl && !key.meta && input.length === 1 && input.charCodeAt(0) >= 32) {
setSearchQuery((q) => q + input)
}
},
{ isActive: isRawModeSupported },
)
const renderContent = () => {
if (loading) {
return <Text color="gray">Loading history...</Text>
}
if (items.length === 0) {
return <Text color="gray">{searchQuery ? "No tasks match your search." : "No task history."}</Text>
}
return (
<Box flexDirection="column">
<Text color="gray">{showUpIndicator ? " ▲" : " "}</Text>
{visibleItems.map((item, idx) => {
const actualIndex = scrollOffset + idx
const isSelected = actualIndex === selectedIndex
const taskText = item.task.replace(/\n/g, " ")
const meta = [formatRelativeDate(item.ts), formatCost(item.totalCost)].filter(Boolean).join(" · ")
return (
<Box flexDirection="column" key={item.id}>
<Box overflow="hidden">
<Text color={isSelected ? COLORS.primaryBlue : undefined} wrap="truncate">
{isSelected ? " " : " "}
{taskText}
</Text>
</Box>
<Box>
<Text color="gray">
{" "}
{meta}
</Text>
</Box>
</Box>
)
})}
<Text color="gray">{showDownIndicator ? " ▼" : " "}</Text>
</Box>
)
}
return (
<Panel label="History">
<Box>
<Text color="gray">Search: </Text>
<Text color="white">{searchQuery}</Text>
<Text inverse> </Text>
</Box>
<Box>
<Text color="gray">{searchQuery ? "Esc to clear" : "Enter to open · Esc to close"}</Text>
</Box>
{renderContent()}
</Panel>
)
}
+223
View File
@@ -0,0 +1,223 @@
import { Text } from "ink"
import { render } from "ink-testing-library"
import React from "react"
import { beforeEach, describe, expect, it, vi } from "vitest"
// Create stable mock reference using vi.hoisted
const { mockShowTaskWithId } = vi.hoisted(() => ({
mockShowTaskWithId: vi.fn().mockResolvedValue(undefined),
}))
vi.mock("./TaskView", () => ({
TaskView: ({ taskId, verbose }: any) =>
React.createElement(Text, null, `TaskView: ${taskId || "no-id"} verbose=${String(verbose)}`),
}))
// Mock the controller dependencies - must be before importing HistoryView
vi.mock("@/core/controller", () => ({
Controller: vi.fn(),
}))
vi.mock("@/core/controller/task/showTaskWithId", () => ({
showTaskWithId: mockShowTaskWithId,
}))
vi.mock("@/shared/proto/cline/common", () => ({
StringRequest: {
create: (data: any) => data,
},
}))
// Mock useTerminalSize to prevent EventEmitter memory leak warnings from resize listeners
vi.mock("../hooks/useTerminalSize", () => ({
useTerminalSize: () => ({ columns: 80, rows: 24, resizeKey: 0 }),
}))
// Import after mocks are set up
import { HistoryView } from "./HistoryView"
describe("HistoryView", () => {
const mockController = {
dispose: vi.fn(),
stateManager: { flushPendingState: vi.fn() },
} as any
const mockItems = [
{ id: "task-1", ts: Date.now() - 3600000, task: "First task" },
{ id: "task-2", ts: Date.now() - 7200000, task: "Second task" },
{ id: "task-3", ts: Date.now() - 10800000, task: "Third task" },
]
beforeEach(() => {
vi.clearAllMocks()
})
describe("rendering", () => {
it("should render the history header", () => {
const { lastFrame } = render(<HistoryView controller={mockController} items={mockItems} />)
expect(lastFrame()).toContain("Task History")
})
it("should show total count in header", () => {
const { lastFrame } = render(<HistoryView controller={mockController} items={mockItems} />)
expect(lastFrame()).toContain("3 total")
})
it("should render task items", () => {
const { lastFrame } = render(<HistoryView controller={mockController} items={mockItems} />)
expect(lastFrame()).toContain("First task")
expect(lastFrame()).toContain("Second task")
expect(lastFrame()).toContain("Third task")
})
it("should show task IDs", () => {
const { lastFrame } = render(<HistoryView controller={mockController} items={mockItems} />)
expect(lastFrame()).toContain("task-1")
expect(lastFrame()).toContain("task-2")
})
it("should show empty message when no items", () => {
const { lastFrame } = render(<HistoryView controller={mockController} items={[]} />)
expect(lastFrame()).toContain("No task history available")
})
it("should show navigation help", () => {
const { lastFrame } = render(<HistoryView controller={mockController} items={mockItems} />)
expect(lastFrame()).toContain("↑↓")
expect(lastFrame()).toContain("Enter")
})
})
describe("task details", () => {
it("should display task cost when available", () => {
const itemsWithCost = [{ id: "task-1", ts: Date.now(), task: "Task", totalCost: 0.0025 }]
const { lastFrame } = render(<HistoryView controller={mockController} items={itemsWithCost} />)
expect(lastFrame()).toContain("Cost:")
expect(lastFrame()).toContain("0.0025")
})
it("should display model ID when available", () => {
const itemsWithModel = [{ id: "task-1", ts: Date.now(), task: "Task", modelId: "claude-sonnet-4-20250514" }]
const { lastFrame } = render(<HistoryView controller={mockController} items={itemsWithModel} />)
expect(lastFrame()).toContain("Model:")
expect(lastFrame()).toContain("claude-sonnet-4-20250514")
})
it("should truncate long task descriptions", () => {
const longTask = "x".repeat(100)
const itemsWithLongTask = [{ id: "task-1", ts: Date.now(), task: longTask }]
const { lastFrame } = render(<HistoryView controller={mockController} items={itemsWithLongTask} />)
expect(lastFrame()).toContain("...")
})
it("should handle missing task text", () => {
const itemsWithoutTask = [{ id: "task-1", ts: Date.now() }]
const { lastFrame } = render(<HistoryView controller={mockController} items={itemsWithoutTask} />)
expect(lastFrame()).toContain("Unknown task")
})
})
describe("selection indicator", () => {
it("should show selection indicator on first item by default", () => {
const { lastFrame } = render(<HistoryView controller={mockController} items={mockItems} />)
expect(lastFrame()).toContain(">")
})
})
describe("keyboard navigation", () => {
it("should navigate down with arrow key", () => {
const { lastFrame, stdin } = render(<HistoryView controller={mockController} items={mockItems} />)
// Press down arrow
stdin.write("\x1B[B")
// Should still render properly
expect(lastFrame()).toContain("Task History")
})
it("should navigate up with arrow key", () => {
const { lastFrame, stdin } = render(<HistoryView controller={mockController} items={mockItems} />)
// Press down then up
stdin.write("\x1B[B")
stdin.write("\x1B[A")
expect(lastFrame()).toContain("Task History")
})
it("should not go below last item", () => {
const { lastFrame, stdin } = render(<HistoryView controller={mockController} items={mockItems} />)
// Press down many times
for (let i = 0; i < 10; i++) {
stdin.write("\x1B[B")
}
expect(lastFrame()).toContain("Task History")
})
it("should not go above first item", () => {
const { lastFrame, stdin } = render(<HistoryView controller={mockController} items={mockItems} />)
// Press up when already at first
stdin.write("\x1B[A")
expect(lastFrame()).toContain("Task History")
})
})
describe("pagination", () => {
it("should show pagination info when provided", () => {
const pagination = {
page: 2,
totalPages: 5,
totalCount: 50,
limit: 10,
}
const { lastFrame } = render(<HistoryView controller={mockController} items={mockItems} pagination={pagination} />)
expect(lastFrame()).toContain("Page 2 of 5")
})
it("should show correct total count from pagination", () => {
const pagination = {
page: 1,
totalPages: 3,
totalCount: 25,
limit: 10,
}
const { lastFrame } = render(<HistoryView controller={mockController} items={mockItems} pagination={pagination} />)
expect(lastFrame()).toContain("25 total")
})
it("should not show page info for single page", () => {
const pagination = {
page: 1,
totalPages: 1,
totalCount: 3,
limit: 10,
}
const { lastFrame } = render(<HistoryView controller={mockController} items={mockItems} pagination={pagination} />)
expect(lastFrame()).not.toContain("Page 1 of 1")
})
})
describe("scrolling", () => {
it("should show scroll indicators for long lists", () => {
const manyItems = Array.from({ length: 20 }, (_, i) => ({
id: `task-${i}`,
ts: Date.now() - i * 3600000,
task: `Task ${i}`,
}))
const { lastFrame, stdin } = render(<HistoryView controller={mockController} items={manyItems} visibleCount={5} />)
// Navigate down a bit
for (let i = 0; i < 5; i++) {
stdin.write("\x1B[B")
}
// Should show "more below" indicator
expect(lastFrame()).toContain("more")
})
})
})
+207
View File
@@ -0,0 +1,207 @@
/**
* History view component
* Displays task history with keyboard navigation
*/
import { Box, Text, useInput } from "ink"
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"
import { useTerminalSize } from "../hooks/useTerminalSize"
interface TaskHistoryItem {
id: string
ts: number
task?: string
totalCost?: number
modelId?: string
}
interface HistoryPagination {
page: number
totalPages: number
totalCount: number
limit: number
}
interface HistoryViewProps {
items: TaskHistoryItem[]
visibleCount?: number
controller: Controller
onSelectTask?: (taskId: string) => void
pagination?: HistoryPagination
onPageChange?: (page: number) => void
/** If provided, all items for internal pagination management */
allItems?: TaskHistoryItem[]
}
/**
* Format separator
*/
function formatSeparator(char: string = "─", width: number = 80): string {
return char.repeat(Math.max(width, 10))
}
export const HistoryView: React.FC<HistoryViewProps> = ({
items,
visibleCount,
controller,
onSelectTask,
pagination,
onPageChange,
allItems,
}) => {
const { isRawModeSupported } = useStdinContext()
const [selectedIndex, setSelectedIndex] = useState(0)
const [internalPage, setInternalPage] = useState(pagination?.page ?? 1)
const { rows: terminalRows } = useTerminalSize()
// Calculate visible count based on terminal height to prevent overflow
// Each item takes ~5 lines (date, id, task text, cost/model, margin)
// Reserve lines for header (title, hint, pagination, separator) and footer (separator)
const headerLines = (pagination?.totalPages ?? 1) > 1 ? 5 : 4
const footerLines = 1
const availableRows = terminalRows - headerLines - footerLines
const itemHeight = 5
const dynamicVisibleCount = Math.max(1, Math.floor(availableRows / itemHeight))
const effectiveVisibleCount = visibleCount ?? dynamicVisibleCount
const onSelect = useCallback(
(item: TaskHistoryItem) => {
// Load the task via controller, then notify parent to switch views
showTaskWithId(controller, StringRequest.create({ value: item.id }))
.then(() => {
onSelectTask?.(item.id)
})
.catch((error) => console.error("Error showing task:", error))
},
[controller, onSelectTask],
)
// Use internal pagination if allItems is provided, otherwise use external
const useInternalPagination = !!allItems
const limit = pagination?.limit ?? 10
const totalCount = allItems?.length ?? pagination?.totalCount ?? items.length
const totalPages = useInternalPagination ? Math.ceil(totalCount / limit) : (pagination?.totalPages ?? 1)
const currentPage = useInternalPagination ? internalPage : (pagination?.page ?? 1)
const hasPrevPage = currentPage > 1
const hasNextPage = currentPage < totalPages
// Get current page items
const pageItems = useInternalPagination ? (allItems ?? []).slice((currentPage - 1) * limit, currentPage * limit) : items
const handlePageChange = useCallback(
(newPage: number) => {
if (useInternalPagination) {
setInternalPage(newPage)
setSelectedIndex(0)
} else if (onPageChange) {
onPageChange(newPage)
setSelectedIndex(0)
}
},
[useInternalPagination, onPageChange],
)
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)
let startIndex = Math.max(0, selectedIndex - halfVisible)
const endIndex = Math.min(pageItems.length, startIndex + effectiveVisibleCount)
// Adjust start if we're near the end
if (endIndex - startIndex < effectiveVisibleCount) {
startIndex = Math.max(0, endIndex - effectiveVisibleCount)
}
const visibleTasks = pageItems.slice(startIndex, endIndex)
const showUpIndicator = startIndex > 0
const showDownIndicator = endIndex < pageItems.length
return (
<Box flexDirection="column">
<Text bold color="white">
{"📜 Task History (" + totalCount + " total)"}
</Text>
<Text color="gray">Use /j/k to navigate, Enter to select</Text>
{totalPages > 1 && (
<Box>
<Text color="gray">
Page {currentPage} of {totalPages}{" "}
</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>
{pageItems.length === 0 ? (
<Text>No task history available.</Text>
) : (
<Box flexDirection="column">
{showUpIndicator && <Text color="gray">{" ↑ " + startIndex + " more above"}</Text>}
{visibleTasks.map((task, index) => {
const actualIndex = startIndex + index
const isSelected = actualIndex === selectedIndex
const date = new Date(task.ts).toLocaleString()
const taskText = task.task?.substring(0, 60) || "Unknown task"
const truncated = (task.task?.length || 0) > 60 ? "..." : ""
return (
<Box flexDirection="column" key={`${task.id}-${actualIndex}`} marginBottom={1}>
<Box>
<Text color={isSelected ? "green" : undefined}>{isSelected ? "> " : " "}</Text>
<Text color="gray">{date}</Text>
</Box>
<Box marginLeft={4}>
<Text color="cyan">{task.id}</Text>
</Box>
<Box marginLeft={4}>
<Text bold={isSelected} color={isSelected ? "white" : undefined}>
{taskText}
{truncated}
</Text>
</Box>
{typeof task.totalCost === "number" && (
<Box marginLeft={4}>
<Text color="gray">Cost: ${task.totalCost ? task.totalCost.toFixed(4) : "0"}</Text>
</Box>
)}
{task.modelId && (
<Box marginLeft={4}>
<Text color="gray">Model: {task.modelId}</Text>
</Box>
)}
</Box>
)
})}
{showDownIndicator && <Text color="gray">{" ↓ " + (pageItems.length - endIndex) + " more below"}</Text>}
</Box>
)}
<Text>{formatSeparator()}</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)} />
}
+197
View File
@@ -0,0 +1,197 @@
/**
* 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 { refreshOpenRouterModels } from "@/core/controller/models/refreshOpenRouterModels"
import {
type ApiProvider,
anthropicDefaultModelId,
anthropicModels,
askSageDefaultModelId,
askSageModels,
basetenDefaultModelId,
basetenModels,
bedrockDefaultModelId,
bedrockModels,
cerebrasDefaultModelId,
cerebrasModels,
claudeCodeDefaultModelId,
claudeCodeModels,
deepSeekDefaultModelId,
deepSeekModels,
doubaoDefaultModelId,
doubaoModels,
fireworksDefaultModelId,
fireworksModels,
geminiDefaultModelId,
geminiModels,
groqDefaultModelId,
groqModels,
huaweiCloudMaasDefaultModelId,
huaweiCloudMaasModels,
huggingFaceDefaultModelId,
huggingFaceModels,
internationalQwenDefaultModelId,
internationalQwenModels,
internationalZAiDefaultModelId,
internationalZAiModels,
minimaxDefaultModelId,
minimaxModels,
mistralDefaultModelId,
mistralModels,
moonshotDefaultModelId,
moonshotModels,
nebiusDefaultModelId,
nebiusModels,
nousResearchDefaultModelId,
nousResearchModels,
openAiCodexDefaultModelId,
openAiCodexModels,
openAiNativeDefaultModelId,
openAiNativeModels,
qwenCodeDefaultModelId,
qwenCodeModels,
sambanovaDefaultModelId,
sambanovaModels,
sapAiCoreDefaultModelId,
sapAiCoreModels,
vertexDefaultModelId,
vertexModels,
xaiDefaultModelId,
xaiModels,
} from "@/shared/api"
import { filterOpenRouterModelIds } from "@/shared/utils/model-filters"
import { COLORS } from "../constants/colors"
import { 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 },
asksage: { models: askSageModels, defaultId: askSageDefaultModelId },
baseten: { models: basetenModels, defaultId: basetenDefaultModelId },
bedrock: { models: bedrockModels, defaultId: bedrockDefaultModelId },
cerebras: { models: cerebrasModels, defaultId: cerebrasDefaultModelId },
"claude-code": { models: claudeCodeModels, defaultId: claudeCodeDefaultModelId },
deepseek: { models: deepSeekModels, defaultId: deepSeekDefaultModelId },
doubao: { models: doubaoModels, defaultId: doubaoDefaultModelId },
fireworks: { models: fireworksModels, defaultId: fireworksDefaultModelId },
gemini: { models: geminiModels, defaultId: geminiDefaultModelId },
groq: { models: groqModels, defaultId: groqDefaultModelId },
"huawei-cloud-maas": { models: huaweiCloudMaasModels, defaultId: huaweiCloudMaasDefaultModelId },
huggingface: { models: huggingFaceModels, defaultId: huggingFaceDefaultModelId },
minimax: { models: minimaxModels, defaultId: minimaxDefaultModelId },
mistral: { models: mistralModels, defaultId: mistralDefaultModelId },
moonshot: { models: moonshotModels, defaultId: moonshotDefaultModelId },
nebius: { models: nebiusModels, defaultId: nebiusDefaultModelId },
nousResearch: { models: nousResearchModels, defaultId: nousResearchDefaultModelId },
"openai-codex": { models: openAiCodexModels, defaultId: openAiCodexDefaultModelId },
"openai-native": { models: openAiNativeModels, defaultId: openAiNativeDefaultModelId },
qwen: { models: internationalQwenModels, defaultId: internationalQwenDefaultModelId },
"qwen-code": { models: qwenCodeModels, defaultId: qwenCodeDefaultModelId },
sambanova: { models: sambanovaModels, defaultId: sambanovaDefaultModelId },
sapaicore: { models: sapAiCoreModels, defaultId: sapAiCoreDefaultModelId },
vertex: { models: vertexModels, defaultId: vertexDefaultModelId },
xai: { models: xaiModels, defaultId: xaiDefaultModelId },
zai: { models: internationalZAiModels, defaultId: internationalZAiDefaultModelId },
}
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
controller: any
onChange: (modelId: string) => void
onSubmit: (modelId: string) => void
isActive?: boolean
}
export const ModelPicker: React.FC<ModelPickerProps> = ({ provider, controller, onChange, onSubmit, isActive = true }) => {
const [isLoading, setIsLoading] = useState(false)
const [asyncModels, setAsyncModels] = useState<string[]>([])
// Fetch OpenRouter models when needed using shared core function
useEffect(() => {
if (usesOpenRouterModels(provider)) {
setIsLoading(true)
refreshOpenRouterModels(controller)
.then((models) => {
const modelIds = Object.keys(models).sort((a, b) => a.localeCompare(b))
const filtered = filterOpenRouterModelIds(modelIds, provider as ApiProvider)
setAsyncModels(filtered)
})
.finally(() => {
setIsLoading(false)
})
}
}, [provider, controller])
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)
}}
/>
)
}
@@ -0,0 +1,47 @@
/**
* Organization picker component for switching between personal account and organizations
*/
import React, { useMemo } from "react"
import type { ClineAccountOrganization } from "@/services/auth/AuthService"
import { SelectList, SelectListItem } from "./SelectList"
interface OrganizationPickerProps {
organizations: ClineAccountOrganization[]
onSelect: (orgId: string | null) => void // null = personal account
isActive?: boolean
}
/**
* Get the primary role for display (prioritize owner > admin > member)
*/
function getPrimaryRole(roles: string[]): string {
if (roles.includes("owner")) return "Owner"
if (roles.includes("admin")) return "Admin"
if (roles.includes("member")) return "Member"
return roles[0] || ""
}
export const OrganizationPicker: React.FC<OrganizationPickerProps> = ({ organizations, onSelect, isActive = true }) => {
const items: SelectListItem[] = useMemo(() => {
const result: SelectListItem[] = [
{
id: "personal",
label: "Personal",
},
]
for (const org of organizations) {
const role = getPrimaryRole(org.roles)
result.push({
id: org.organizationId,
label: org.name,
suffix: role ? `(${role})` : undefined,
})
}
return result
}, [organizations])
return <SelectList isActive={isActive} items={items} onSelect={(item) => onSelect(item.id === "personal" ? null : item.id)} />
}
+73
View File
@@ -0,0 +1,73 @@
/**
* 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"
import { useTerminalSize } from "../hooks/useTerminalSize"
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 { columns } = useTerminalSize()
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(Math.max(columns - 2, 0))}
</Text>
</Box>
{/* Content */}
<Box flexDirection="column" paddingLeft={1} paddingRight={1}>
{children}
</Box>
</Box>
)
}
+153
View File
@@ -0,0 +1,153 @@
/**
* Provider picker component for API provider selection
*/
import React, { useMemo } from "react"
import { StateManager } from "@/core/storage/StateManager"
import { API_PROVIDERS_LIST, ApiConfiguration } 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
}
/**
* Check if a provider is configured (has required credentials/settings)
* Based on webview's getConfiguredProviders logic
*/
function isProviderConfigured(providerId: string, config: ApiConfiguration): boolean {
switch (providerId) {
case "cline":
return true // Always available
case "anthropic":
return !!config.apiKey
case "openrouter":
return !!config.openRouterApiKey
case "bedrock":
return !!config.awsRegion
case "vertex":
return !!(config.vertexProjectId && config.vertexRegion)
case "gemini":
return !!config.geminiApiKey
case "openai-native":
return !!config.openAiNativeApiKey
case "openai-codex":
return !!config.openAiCodexRefreshToken
case "deepseek":
return !!config.deepSeekApiKey
case "xai":
return !!config.xaiApiKey
case "qwen":
case "qwen-code":
return !!config.qwenApiKey
case "doubao":
return !!config.doubaoApiKey
case "mistral":
return !!config.mistralApiKey
case "requesty":
return !!config.requestyApiKey
case "fireworks":
return !!config.fireworksApiKey
case "together":
return !!config.togetherApiKey
case "moonshot":
return !!config.moonshotApiKey
case "nebius":
return !!config.nebiusApiKey
case "asksage":
return !!config.asksageApiKey
case "sambanova":
return !!config.sambanovaApiKey
case "cerebras":
return !!config.cerebrasApiKey
case "sapaicore":
return !!(
config.sapAiCoreBaseUrl &&
config.sapAiCoreClientId &&
config.sapAiCoreClientSecret &&
config.sapAiCoreTokenUrl
)
case "zai":
return !!config.zaiApiKey
case "groq":
return !!config.groqApiKey
case "huggingface":
return !!config.huggingFaceApiKey
case "baseten":
return !!config.basetenApiKey
case "dify":
return !!(config.difyBaseUrl && config.difyApiKey)
case "minimax":
return !!config.minimaxApiKey
case "hicap":
return !!config.hicapApiKey
case "huawei-cloud-maas":
return !!config.huaweiCloudMaasApiKey
case "vercel-ai-gateway":
return !!config.vercelAiGatewayApiKey
case "aihubmix":
return !!config.aihubmixApiKey
case "nousResearch":
return !!config.nousResearchApiKey
case "openai":
return !!(
(config.openAiBaseUrl && config.openAiApiKey) ||
config.planModeOpenAiModelId ||
config.actModeOpenAiModelId
)
case "ollama":
return !!(config.ollamaBaseUrl || config.planModeOllamaModelId || config.actModeOllamaModelId)
case "lmstudio":
return !!(config.lmStudioBaseUrl || config.planModeLmStudioModelId || config.actModeLmStudioModelId)
case "litellm":
return !!(
config.liteLlmBaseUrl ||
config.liteLlmApiKey ||
config.planModeLiteLlmModelId ||
config.actModeLiteLlmModelId
)
case "claude-code":
return !!config.claudeCodePath
case "oca":
return !!config.ocaBaseUrl
default:
return false
}
}
interface ProviderPickerProps {
onSelect: (providerId: string) => void
isActive?: boolean
}
export const ProviderPicker: React.FC<ProviderPickerProps> = ({ onSelect, isActive = true }) => {
// Get API configuration to check which providers are configured
const apiConfig = StateManager.get().getApiConfiguration()
// 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: isProviderConfigured(providerId, apiConfig) ? "(Configured)" : undefined,
}))
}, [apiConfig])
return <SearchableList isActive={isActive} items={items} onSelect={(item) => onSelect(item.id)} />
}
+118
View File
@@ -0,0 +1,118 @@
/**
* 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 { fuzzyFilter } from "../utils/fuzzy-search"
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)
// Filter items by search using fuzzy matching
const filteredItems = useMemo(() => {
if (!search) return items
// Use custom filter if provided, otherwise use fuzzy search
if (filterFn) {
return items.filter((item) => filterFn(item, search))
}
return fuzzyFilter(items, search, (item) => `${item.label} ${item.id}`)
}, [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 || key.tab) {
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>
)
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Simple select list component - arrow keys to navigate, Enter to select
* No search functionality, just a straightforward list picker
*/
import { Box, Text, useInput } from "ink"
import { useState } from "react"
import { COLORS } from "../constants/colors"
import { useStdinContext } from "../context/StdinContext"
export interface SelectListItem {
id: string
label: string
suffix?: string
}
interface SelectListProps<T extends SelectListItem> {
items: T[]
onSelect: (item: T) => void
isActive?: boolean
}
export function SelectList<T extends SelectListItem>({ items, onSelect, isActive = true }: SelectListProps<T>) {
const { isRawModeSupported } = useStdinContext()
const [selectedIndex, setSelectedIndex] = useState(0)
useInput(
(_input, key) => {
if (key.upArrow) {
setSelectedIndex((i) => (i > 0 ? i - 1 : items.length - 1))
} else if (key.downArrow) {
setSelectedIndex((i) => (i < items.length - 1 ? i + 1 : 0))
} else if (key.return) {
const item = items[selectedIndex]
if (item) {
onSelect(item)
}
}
},
{ isActive: isActive && isRawModeSupported },
)
return (
<Box flexDirection="column">
{items.map((item, idx) => {
const isSelected = idx === selectedIndex
return (
<Box key={item.id}>
<Text color={isSelected ? COLORS.primaryBlue : undefined}>
{isSelected ? " " : " "}
{item.label}
{item.suffix && <Text color="gray"> {item.suffix}</Text>}
</Text>
</Box>
)
})}
</Box>
)
}
+188
View File
@@ -0,0 +1,188 @@
import { Box, Text } from "ink"
import React from "react"
import { Session } from "@/shared/services/Session"
/**
* Format milliseconds to a human-readable duration string
*/
function formatDuration(ms: number): string {
if (ms < 1000) {
return `${ms}ms`
}
const seconds = ms / 1000
if (seconds < 60) {
return `${seconds.toFixed(1)}s`
}
const minutes = Math.floor(seconds / 60)
const remainingSeconds = seconds % 60
return `${minutes}m ${remainingSeconds.toFixed(0)}s`
}
/**
* Format a percentage value
*/
function formatPercent(value: number, total: number): string {
if (total === 0) return "0.0%"
return `${((value / total) * 100).toFixed(1)}%`
}
/**
* Format bytes to a human-readable string (KB, MB, GB)
*/
function formatBytes(bytes: number): string {
if (bytes < 1024) {
return `${bytes}B`
}
const kb = bytes / 1024
if (kb < 1024) {
return `${kb.toFixed(1)}KB`
}
const mb = kb / 1024
if (mb < 1024) {
return `${mb.toFixed(1)}MB`
}
const gb = mb / 1024
return `${gb.toFixed(2)}GB`
}
interface SessionSummaryProps {
/** Optional width constraint */
width?: number
}
/**
* Displays session statistics when the CLI exits.
* Shows tool call counts, success rate, and timing breakdown.
*/
export const SessionSummary: React.FC<SessionSummaryProps> = ({ width }) => {
const session = Session.get()
const stats = session.getStats()
const wallTimeMs = session.getWallTimeMs()
const agentActiveMs = session.getAgentActiveTimeMs()
// Don't show if session just started (less than 1 second)
if (wallTimeMs < 1000) {
return null
}
return (
<Box borderColor="gray" borderStyle="single" flexDirection="column" paddingX={1} width={width}>
{/* Header */}
<Box marginBottom={1}>
<Text bold>Interaction Summary</Text>
</Box>
{/* Session ID */}
<Box>
<Box width={20}>
<Text color="gray">Session ID:</Text>
</Box>
<Text>{stats.sessionId}</Text>
</Box>
{/* Session Time */}
<Box>
<Box width={20}>
<Text color="gray">Session Time:</Text>
</Box>
<Text>
{session.formatTime(session.getStartTime())} {session.formatTime(session.getEndTime())}
</Text>
</Box>
{/* Tool Calls */}
<Box>
<Box width={20}>
<Text color="gray">Tool Calls:</Text>
</Box>
<Text>{stats.totalToolCalls}</Text>
</Box>
{/* Performance Header */}
<Box marginBottom={0}>
<Text bold>Performance</Text>
</Box>
{/* Wall Time */}
<Box>
<Box width={20}>
<Text color="gray">Wall Time:</Text>
</Box>
<Text>{formatDuration(wallTimeMs)}</Text>
</Box>
{/* Agent Active */}
<Box>
<Box width={20}>
<Text color="gray">Agent Active:</Text>
</Box>
<Text>{formatDuration(agentActiveMs)}</Text>
</Box>
{/* API Time */}
<Box>
<Box width={20}>
<Text color="gray"> » API Time:</Text>
</Box>
<Text>
{formatDuration(stats.apiTimeMs)} <Text color="gray">({formatPercent(stats.apiTimeMs, agentActiveMs)})</Text>
</Text>
</Box>
{/* Tool Time */}
<Box marginBottom={1}>
<Box width={20}>
<Text color="gray"> » Tool Time:</Text>
</Box>
<Text>
{formatDuration(stats.toolTimeMs)}{" "}
<Text color="gray">({formatPercent(stats.toolTimeMs, agentActiveMs)})</Text>
</Text>
</Box>
{/* Resources Header */}
<Box marginBottom={0}>
<Text bold>Resources</Text>
</Box>
{/* Memory Usage */}
<Box>
<Box width={20}>
<Text color="gray">Memory (RSS):</Text>
</Box>
<Text>{formatBytes(stats.resources.rss)}</Text>
</Box>
{/* Peak Memory */}
<Box>
<Box width={20}>
<Text color="gray">Peak Memory:</Text>
</Box>
<Text>{formatBytes(stats.peakMemoryBytes)}</Text>
</Box>
{/* Heap Usage */}
<Box>
<Box width={20}>
<Text color="gray">Heap Used:</Text>
</Box>
<Text>
{formatBytes(stats.resources.heapUsed)} <Text color="gray">/ {formatBytes(stats.resources.heapTotal)}</Text>
</Text>
</Box>
{/* CPU Time */}
<Box>
<Box width={20}>
<Text color="gray">CPU Time:</Text>
</Box>
<Text>
{formatDuration(stats.resources.userCpuMs + stats.resources.systemCpuMs)}{" "}
<Text color="gray">
(user: {formatDuration(stats.resources.userCpuMs)}, sys: {formatDuration(stats.resources.systemCpuMs)})
</Text>
</Text>
</Box>
</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>
)
}
+23
View File
@@ -0,0 +1,23 @@
/**
* Loading spinner component using ink-spinner
*/
import { Box, Text } from "ink"
import Spinner from "ink-spinner"
import React from "react"
interface LoadingSpinnerProps {
mode?: "act" | "plan"
}
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({ mode = "act" }) => {
const message = mode === "plan" ? "Planning" : "Thinking"
return (
<Box>
<Text color="cyan">
<Spinner type="dots" />
</Text>
<Text color="cyan"> {message}...</Text>
</Box>
)
}
+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 />
}
+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>
)
}
+328
View File
@@ -0,0 +1,328 @@
/**
* Welcome view component
* Shows an interactive prompt when user starts cline without a command
* Supports file mentions with @
*/
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,
type FileSearchResult,
getRipgrepInstallInstructions,
insertMention,
searchWorkspaceFiles,
} from "../utils/file-search"
import { isMouseEscapeSequence } from "../utils/input"
import { parseImagesFromInput } from "../utils/parser"
import { AccountInfoView } from "./AccountInfoView"
import { FileMentionMenu } from "./FileMentionMenu"
interface WelcomeViewProps {
onSubmit: (prompt: string, imagePaths: string[]) => void
onExit?: () => void
controller?: any
}
// ASCII art Cline logo
const CLINE_LOGO = [
" ::::::: ",
" ::::::::: ",
" ::::::::::::::::: ",
" ::::::::::::::::::::::: ",
" ::::::::::::::::::::::::: ",
" ::::::::::::::::::::::::::: ",
" ::::::: ::::::: ::::::: ",
" ::::::: ::::: ::::::: ",
":::::::: ::::: ::::::::",
":::::::: ::::: ::::::::",
" ::::::: ::::: ::::::: ",
" ::::::: ::::::: ::::::: ",
" ::::::::::::::::::::::::::: ",
" ::::::::::::::::::::::::: ",
" ::::::::::::::::::::::: ",
" :::::::::::::::: ",
]
const SEARCH_DEBOUNCE_MS = 150
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)
const [isSearching, setIsSearching] = useState(false)
const [showRipgrepWarning, setShowRipgrepWarning] = useState(false)
const [escPressedOnce, setEscPressedOnce] = useState(false)
const [mode, setMode] = useState<Mode>(() => {
const stateManager = StateManager.get()
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) || getProviderDefaultModelId(provider)
}, [mode, provider])
const toggleMode = useCallback(() => {
const newMode: Mode = mode === "act" ? "plan" : "act"
setMode(newMode)
const stateManager = StateManager.get()
stateManager.setGlobalState("mode", newMode)
}, [mode])
const refs = useRef({
searchTimeout: null as NodeJS.Timeout | null,
lastQuery: "",
hasCheckedRipgrep: false,
})
const { prompt, imagePaths } = parseImagesFromInput(textInput)
const mentionInfo = useMemo(() => extractMentionQuery(textInput), [textInput])
const workspacePath = useMemo(() => {
try {
const root = controller?.getWorkspaceManagerSync?.()?.getPrimaryRoot?.()
if (root?.path) {
return root.path
}
} catch {
// Fallback to cwd
}
return process.cwd()
}, [controller])
// Search for files when in mention mode
useEffect(() => {
const { current: r } = refs
if (!mentionInfo.inMentionMode) {
setFileResults([])
setSelectedIndex(0)
if (r.searchTimeout) {
clearTimeout(r.searchTimeout)
r.searchTimeout = null
}
return
}
// Check for ripgrep on first mention trigger
if (!r.hasCheckedRipgrep) {
r.hasCheckedRipgrep = true
if (checkAndWarnRipgrepMissing()) {
setShowRipgrepWarning(true)
setTimeout(() => setShowRipgrepWarning(false), RIPGREP_WARNING_DURATION_MS)
}
}
const { query } = mentionInfo
if (query === r.lastQuery) {
return
}
r.lastQuery = query
if (r.searchTimeout) {
clearTimeout(r.searchTimeout)
}
setIsSearching(true)
r.searchTimeout = setTimeout(async () => {
try {
const results = await searchWorkspaceFiles(query, workspacePath, MAX_SEARCH_RESULTS)
setFileResults(results)
setSelectedIndex(0)
} catch {
setFileResults([])
} finally {
setIsSearching(false)
}
}, SEARCH_DEBOUNCE_MS)
return () => {
if (r.searchTimeout) {
clearTimeout(r.searchTimeout)
}
}
}, [mentionInfo.inMentionMode, mentionInfo.query, workspacePath])
useInput(
(input, key) => {
// Filter out mouse escape sequences
if (isMouseEscapeSequence(input)) {
return
}
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 && !mentionInfo.inMentionMode) {
if (escPressedOnce) {
onExit?.()
} else {
setEscPressedOnce(true)
}
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"
return (
<Box flexDirection="column" width="100%">
{/* Account/Provider info at top */}
{controller && (
<Box marginBottom={1}>
<AccountInfoView controller={controller} />
</Box>
)}
{/* Cline logo - centered */}
<Box alignItems="center" flexDirection="column">
{CLINE_LOGO.map((line, idx) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static array that never changes
<Text color="white" key={idx}>
{line}
</Text>
))}
</Box>
{/* Main prompt - centered, bold */}
<Box justifyContent="center" marginTop={1}>
<Text bold color="white">
What can I do for you?
</Text>
</Box>
{/* Ripgrep warning if needed */}
{showRipgrepWarning && (
<Box marginTop={1}>
<Text color="yellow"> ripgrep not found - file search will be slower. </Text>
<Text color="gray">Install: {getRipgrepInstallInstructions()}</Text>
</Box>
)}
{/* Input field with border */}
<Box
borderColor={borderColor}
borderStyle="round"
flexDirection="row"
marginTop={1}
paddingLeft={1}
paddingRight={1}
width="100%">
<Text>{textInput}</Text>
<Text inverse> </Text>
</Box>
{/* Model ID and Mode toggle row */}
<Box justifyContent="space-between" width="100%">
{/* Model ID on left */}
<Text color="gray">{modelId}</Text>
{/* Mode toggle on right */}
<Box gap={1}>
<Box>
<Text bold={mode === "plan"} color={mode === "plan" ? "yellow" : "gray"}>
{mode === "plan" ? "●" : "○"} Plan
</Text>
</Box>
<Box>
<Text bold={mode === "act"} color={mode === "act" ? "blue" : "gray"}>
{mode === "act" ? "●" : "○"} Act
</Text>
</Box>
<Text color="gray">(Tab)</Text>
</Box>
</Box>
{/* File mention menu - below input */}
{mentionInfo.inMentionMode && (
<FileMentionMenu
isLoading={isSearching}
query={mentionInfo.query}
results={fileResults}
selectedIndex={selectedIndex}
/>
)}
{/* Attached images */}
{imagePaths.length > 0 && (
<Text color="magenta">
📎 {imagePaths.length} image{imagePaths.length > 1 ? "s" : ""} attached
</Text>
)}
{/* Help text */}
<Box>
<Text color="gray">Enter to submit · @ to mention files · </Text>
<Text bold={escPressedOnce} color={escPressedOnce ? "white" : "gray"}>
{escPressedOnce ? "Press Esc again to exit" : "Esc to exit"}
</Text>
</Box>
</Box>
)
}
+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
}

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