Compare commits

...

89 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
75 changed files with 11574 additions and 811 deletions
+26 -1
View File
@@ -5,4 +5,29 @@ 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.
- 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.
+5 -1
View File
@@ -154,6 +154,8 @@
],
"includes": [
"**",
"!**/esbuild.*",
"!**/*.mts",
"!**/webview-ui/**",
"!**/evals/**",
"!**/standalone/**",
@@ -167,7 +169,9 @@
"!**/*.js",
"!**/scripts/**",
"!**/*.tsx",
"!**/testing-platform/**"
"!**/testing-platform/**",
// ACP mode must redirect console to stderr - this is intentional
"!cli-ts/src/acp/index.ts"
]
},
{
+36 -1
View File
@@ -252,6 +252,41 @@ npm run watch
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:
@@ -316,4 +351,4 @@ npm run protos
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
+25 -13
View File
@@ -1,22 +1,23 @@
import "dotenv/config"
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
* @type {import('esbuild').Plugin}
*/
const aliasResolverPlugin = {
const aliasResolverPlugin: esbuild.Plugin = {
name: "alias-resolver",
setup(build) {
const aliases = {
@@ -65,6 +66,18 @@ const aliasResolverPlugin = {
}
}
// 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 }
})
@@ -74,9 +87,8 @@ const aliasResolverPlugin = {
/**
* Plugin to redirect vscode imports to our shim
* @type {import('esbuild').Plugin}
*/
const vscodeStubPlugin = {
const vscodeStubPlugin: esbuild.Plugin = {
name: "vscode-stub",
setup(build) {
// Redirect 'vscode' imports to our shim
@@ -86,11 +98,11 @@ const vscodeStubPlugin = {
},
}
const esbuildProblemMatcherPlugin = {
const esbuildProblemMatcherPlugin: esbuild.Plugin = {
name: "esbuild-problem-matcher",
setup(build) {
build.onStart(() => {
console.log("[cli-ts] Build started...")
console.log("[cli-ts esbuild] Build started...")
})
build.onEnd((result) => {
result.errors.forEach(({ text, location }) => {
@@ -99,13 +111,13 @@ const esbuildProblemMatcherPlugin = {
console.error(` ${location.file}:${location.line}:${location.column}:`)
}
})
console.log("[cli-ts] Build finished")
console.log("[cli-ts esbuild] Build finished")
})
},
}
// Plugin to stub out optional devtools module
const stubOptionalModulesPlugin = {
const stubOptionalModulesPlugin: esbuild.Plugin = {
name: "stub-optional-modules",
setup(build) {
build.onResolve({ filter: /^react-devtools-core$/ }, () => {
@@ -114,7 +126,7 @@ const stubOptionalModulesPlugin = {
},
}
const copyWasmFiles = {
const copyWasmFiles: esbuild.Plugin = {
name: "copy-wasm-files",
setup(build) {
build.onEnd(() => {
@@ -166,7 +178,7 @@ const copyWasmFiles = {
},
}
const buildEnvVars = {
const buildEnvVars: Record<string, string> = {
"process.env.IS_STANDALONE": JSON.stringify("true"),
"process.env.IS_CLI": JSON.stringify("true"),
}
@@ -196,7 +208,7 @@ if (production) {
buildEnvVars["process.env.IS_DEV"] = "false"
}
const config = {
const config: esbuild.BuildOptions = {
entryPoints: [path.join(__dirname, "src", "index.ts")],
bundle: true,
minify: production,
+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.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@cline/cli",
"version": "1.0.0",
"version": "2.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
@@ -2947,4 +2947,4 @@
"license": "MIT"
}
}
}
}
+7 -3
View File
@@ -11,9 +11,12 @@
"node": ">=20.0.0"
},
"scripts": {
"build": "node esbuild.mjs",
"build:production": "node esbuild.mjs --production",
"watch": "node esbuild.mjs --watch",
"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",
@@ -61,6 +64,7 @@
"vitest": "^4.0.17"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.13.1",
"aws4fetch": "^1.0.20",
"chalk": "^5.3.0",
"commander": "^12.1.0",
+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>
}
+15 -7
View File
@@ -8,6 +8,7 @@ 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"
/**
@@ -276,6 +277,17 @@ interface ActionButtonsProps {
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
@@ -287,18 +299,14 @@ export const ActionButtons: React.FC<ActionButtonsProps> = ({ config, mode = "ac
return null
}
// Don't show cancel buttons (ThinkingIndicator handles esc to interrupt)
// Don't show new_task buttons (CLI doesn't handle starting new tasks)
const hiddenActions = ["cancel", "new_task"]
const hasPrimary = !!config.primaryText && !hiddenActions.includes(config.primaryAction || "")
const hasSecondary = !!config.secondaryText && !hiddenActions.includes(config.secondaryAction || "")
const { hasPrimary, hasSecondary } = getVisibleButtons(config)
if (!hasPrimary && !hasSecondary) {
return null
}
// Calculate button widths based on terminal width
const terminalWidth = process.stdout.columns || 80
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
@@ -323,7 +331,7 @@ export const ActionButtons: React.FC<ActionButtonsProps> = ({ config, mode = "ac
return (
<Box flexDirection="row" gap={1} marginLeft={1} width="100%">
{hasPrimary && renderButton(config.primaryText!, "1")}
{hasSecondary && renderButton(config.secondaryText!, "2")}
{hasSecondary && renderButton(config.secondaryText!, hasPrimary ? "2" : "1")}
</Box>
)
}
+5
View File
@@ -35,6 +35,11 @@ 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(),
+3 -1
View File
@@ -7,6 +7,7 @@ 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"
@@ -142,6 +143,7 @@ export const App: React.FC<AppProps> = ({
isRawModeSupported = true,
robotTopRow,
}) => {
const { resizeKey } = useTerminalSize()
const [currentView, setCurrentView] = useState<ViewType>(initialView)
const [selectedTaskId, setSelectedTaskId] = useState<string | undefined>(taskId)
@@ -271,7 +273,7 @@ export const App: React.FC<AppProps> = ({
return (
<StdinProvider isRawModeSupported={isRawModeSupported}>
<Box>{content}</Box>
<Box key={resizeKey}>{content}</Box>
</StdinProvider>
)
}
+11
View File
@@ -333387,6 +333387,17 @@ export const AsciiMotionCli: React.FC<AsciiMotionCliProps> = ({
const getColor = useCallback((key: string): string => theme[key] || key, [theme]);
const defaultFg = hasDarkBackground ? "white" : "black";
// Stop animation on terminal resize to prevent visual glitches
useEffect(() => {
const handleResize = () => {
onScroll?.();
};
process.stdout.on("resize", handleResize);
return () => {
process.stdout.off("resize", handleResize);
};
}, [onScroll]);
// Mouse tracking - gracefully handle environments without tty support
useEffect(() => {
if (!stdin || !stdout) return;
+53 -35
View File
@@ -20,6 +20,7 @@ import { type DetectedSources, detectImportSources, type ImportSource } from "..
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"
@@ -36,6 +37,7 @@ type AuthStep =
| "cline_auth"
| "cline_model"
| "openai_codex_auth"
| "bedrock"
| "import"
// Featured models loaded from shared constants
@@ -162,6 +164,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
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(() => {
@@ -169,35 +172,6 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
return getProviderOrder().filter((p) => availableProviders.has(p))
}, [])
// Get configured providers (those with API keys set)
const configuredProviders = useMemo(() => {
try {
const config = StateManager.get().getApiConfiguration()
const configured = new Set<string>()
for (const provider of sortedProviders) {
const keyField = ProviderToApiKeyMap[provider]
if (!keyField) {
continue
}
const fields = Array.isArray(keyField) ? keyField : [keyField]
const hasKey = fields.some((field) => {
const value = (config as Record<string, unknown>)[field]
return value !== undefined && value !== null && value !== ""
})
if (hasKey) {
configured.add(provider)
}
}
return configured
} catch {
return new Set<string>()
}
}, [sortedProviders, ProviderToApiKeyMap])
// Main menu items - conditionally include import options
const mainMenuItems: SelectItem[] = useMemo(() => {
const items: SelectItem[] = [{ label: "Sign in with Cline account", value: "cline_auth" }]
@@ -228,10 +202,10 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
)
: sortedProviders
return filtered.map((p: string) => ({
label: `${getProviderLabel(p)}${configuredProviders.has(p) ? " (configured)" : ""}`,
label: getProviderLabel(p),
value: p,
}))
}, [sortedProviders, configuredProviders, providerSearch])
}, [sortedProviders, providerSearch])
// Use shared scrollable list hook for provider windowing
const TOTAL_PROVIDER_ROWS = 8
@@ -466,6 +440,8 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
} else if (value === "openai-codex") {
setStep("openai_codex_auth")
startOpenAiCodexAuth()
} else if (value === "bedrock") {
setStep("bedrock")
} else {
setStep("apikey")
}
@@ -499,8 +475,19 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
apiProvider: selectedProvider,
}
// Add API key using provider-specific field
if (apiKey) {
// 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]
@@ -520,7 +507,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
setStep("error")
}
},
[selectedProvider, apiKey],
[selectedProvider, apiKey, bedrockConfig],
)
const handleModelIdSubmit = useCallback(
@@ -557,6 +544,11 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
[saveConfiguration],
)
const handleBedrockComplete = useCallback((config: BedrockConfig) => {
setBedrockConfig(config)
setStep("modelid")
}, [])
const handleImportComplete = useCallback(() => {
setStep("success")
}, [])
@@ -639,6 +631,10 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
setClineModelIndex(0)
setStep("menu")
break
case "bedrock":
setBedrockConfig(null)
setStep("provider")
break
case "import":
setImportSource(null)
setStep("menu")
@@ -710,6 +706,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
<Text color="white">Select a model</Text>
<Text> </Text>
<ModelPicker
controller={controller}
isActive={step === "modelid"}
onChange={setModelId}
onSubmit={handleModelIdSubmit}
@@ -843,6 +840,18 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
)
}
case "bedrock":
return (
<BedrockSetup
isActive={step === "bedrock"}
onCancel={() => {
setBedrockConfig(null)
setStep("provider")
}}
onComplete={handleBedrockComplete}
/>
)
case "import":
if (!importSource) {
return null
@@ -872,7 +881,16 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
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", "error"].includes(step)
const canGoBack = [
"provider",
"modelid",
"baseurl",
"cline_auth",
"cline_model",
"openai_codex_auth",
"bedrock",
"error",
].includes(step)
useInput(
(input, key) => {
+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
}
+65 -25
View File
@@ -6,11 +6,13 @@
* - ⎿ 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"
@@ -51,11 +53,7 @@ function renderInlineMarkdown(text: string): React.ReactNode[] {
)
} else if (fullMatch.startsWith("`") && fullMatch.endsWith("`")) {
// Inline code
nodes.push(
<Text dimColor key={key}>
{fullMatch.slice(1, -1)}
</Text>,
)
nodes.push(<Text key={key}>{fullMatch.slice(1, -1)}</Text>)
}
lastIndex = regex.lastIndex
@@ -106,7 +104,7 @@ const DotRow: React.FC<{ children: React.ReactNode; color?: string }> = ({ child
const ResultRow: React.FC<{ children: React.ReactNode; isFirst?: boolean }> = ({ children, isFirst }) => (
<Box flexDirection="row">
<Box width={3}>
<Text dimColor>{isFirst ? "⎿ " : " "}</Text>
<Text color="gray">{isFirst ? "⎿ " : " "}</Text>
</Box>
<Box flexGrow={1}>{children}</Box>
</Box>
@@ -122,7 +120,7 @@ function getToolMainArg(_toolName: string, args: Record<string, unknown>): strin
// Command - truncate long commands
if (typeof args.command === "string") {
return args.command.length > 60 ? args.command.substring(0, 57) + "..." : args.command
return args.command.length > 120 ? args.command.substring(0, 117) + "..." : args.command
}
// Search regex
@@ -188,17 +186,28 @@ function formatToolResult(result: string, maxLines: number = 5): string[] {
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" paddingRight={1}>
<Text color="white" dimColor>
{" "}
&gt;{" "}
</Text>
<Text color="white">{text}</Text>
<Box backgroundColor="blackBright" paddingX={1}>
<Text color="white">{content}</Text>
</Box>
</Box>
)
@@ -241,19 +250,24 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({ message, mode }) => {
)
}
// Only show result content for completed tools (say), not for pending asks
const resultLines = isToolSay && toolInfo.result?.trim() ? formatToolResult(toolInfo.result, 5) : []
// 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>
{resultLines.length > 0 && (
{contentLines.length > 0 && (
<Box flexDirection="column" marginLeft={2} width="100%">
{resultLines.map((line, idx) => (
{contentLines.map((line, idx) => (
<ResultRow isFirst={idx === 0} key={idx}>
<Text dimColor>{line}</Text>
<Text color="gray">{line}</Text>
</ResultRow>
))}
</Box>
@@ -290,14 +304,14 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({ message, mode }) => {
<DotRow color={toolColor}>
<Text>
<Text color={toolColor}>{label}</Text>
<Text>{truncate(command, 60)}</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 dimColor>{line}</Text>
<Text color="gray">{line}</Text>
</ResultRow>
))}
</Box>
@@ -314,7 +328,7 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({ message, mode }) => {
<Box flexDirection="column" marginLeft={2} width="100%">
{lines.map((line, idx) => (
<ResultRow isFirst={idx === 0} key={idx}>
<Text dimColor>{line}</Text>
<Text color="gray">{line}</Text>
</ResultRow>
))}
</Box>
@@ -332,14 +346,24 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({ message, mode }) => {
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 bold color="red">
Error
<Text color="red" wrap="wrap">
<Text bold>Error</Text>: {errorMessage}
</Text>
<Text color="red">: {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>
)
}
@@ -490,7 +514,7 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({ message, mode }) => {
{parsed.options.map((opt, idx) => {
const isSelected = parsed.selected === opt
return (
<Text color={isSelected ? "green" : "gray"} key={opt}>
<Text color={isSelected ? "green" : toolColor} key={opt}>
{isSelected ? "✓" : `${idx + 1}.`} {opt}
</Text>
)
@@ -516,6 +540,22 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({ message, mode }) => {
}
}
// 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
}
+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:")
})
})
+436 -56
View File
@@ -101,28 +101,32 @@
* - log-update: node_modules/ink/build/log-update.js (eraseLines logic)
*/
import type { ModelInfo } from "@shared/api"
import { combineCommandSequences } from "@shared/combineCommandSequences"
import type { ClineAsk, ClineMessage } from "@shared/ExtensionMessage"
import { getApiMetrics } from "@shared/getApiMetrics"
import { getApiMetrics, getLastApiReqTotalTokens } from "@shared/getApiMetrics"
import { EmptyRequest, StringRequest } from "@shared/proto/cline/common"
import type { SlashCommandInfo } from "@shared/proto/cline/slash"
import { CLI_ONLY_COMMANDS } from "@shared/slashCommands"
import type { Mode } from "@shared/storage/types"
import { execSync } from "child_process"
import { Box, Static, Text, useInput } from "ink"
import { Box, Static, Text, useApp, useInput } from "ink"
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { getAvailableSlashCommands } from "@/core/controller/slash/getAvailableSlashCommands"
import { showTaskWithId } from "@/core/controller/task/showTaskWithId"
import { StateManager } from "@/core/storage/StateManager"
import { telemetryService } from "@/services/telemetry"
import { Logger } from "@/shared/services/Logger"
import { Session } from "@/shared/services/Session"
import { COLORS } from "../constants/colors"
import { useTaskContext, useTaskState } from "../context/TaskContext"
import { useIsSpinnerActive } from "../hooks/useStateSubscriber"
import { moveCursorDown, moveCursorUp } from "../utils/cursor"
import { setTerminalTitle } from "../utils/display"
import {
checkAndWarnRipgrepMissing,
extractMentionQuery,
type FileSearchResult,
getRipgrepInstallInstructions,
insertMention,
searchWorkspaceFiles,
} from "../utils/file-search"
@@ -130,11 +134,15 @@ import { isMouseEscapeSequence } from "../utils/input"
import { jsonParseSafe, parseImagesFromInput } from "../utils/parser"
import { extractSlashQuery, filterCommands, insertSlashCommand, sortCommandsWorkflowsFirst } from "../utils/slash-commands"
import { isFileEditTool, parseToolFromMessage } from "../utils/tools"
import { ActionButtons, type ButtonActionType, getButtonConfig } from "./ActionButtons"
import { shutdownEvent } from "../vscode-shim"
import { ActionButtons, type ButtonActionType, getButtonConfig, getVisibleButtons } from "./ActionButtons"
import { AsciiMotionCli, StaticRobotFrame } from "./AsciiMotionCli"
import { ChatMessage } from "./ChatMessage"
import { FileMentionMenu } from "./FileMentionMenu"
import { HighlightedInput } from "./HighlightedInput"
import { HistoryPanelContent } from "./HistoryPanelContent"
import { providerModels } from "./ModelPicker"
import { SessionSummary } from "./SessionSummary"
import { SettingsPanelContent } from "./SettingsPanelContent"
import { SlashCommandMenu } from "./SlashCommandMenu"
import { ThinkingIndicator } from "./ThinkingIndicator"
@@ -154,6 +162,8 @@ const SEARCH_DEBOUNCE_MS = 150
const RIPGREP_WARNING_DURATION_MS = 5000
const MAX_SEARCH_RESULTS = 15
const DEFAULT_CONTEXT_WINDOW = 200000
const PASTE_COLLAPSE_THRESHOLD = 100 // Characters before showing placeholder
const MAX_HISTORY_ITEMS = 20 // Max history items to navigate with up/down arrows
/**
* Get current git branch name
@@ -226,6 +236,37 @@ function centerText(text: string, terminalWidth?: number): string {
return " ".repeat(padding) + text
}
/**
* Yolo mode auto-approves tool use, commands, browser actions, etc. so the AI can work
* uninterrupted. But some ask types genuinely need user input -- you can't auto-approve
* "task completed, what next?" or a followup question the AI is asking the user.
*
* This whitelist defines which ask types should still show buttons and allow text input
* even when yolo mode is enabled. Everything NOT in this set gets suppressed (buttons
* hidden, input blocked), which is the correct behavior for tool/browser/mcp approvals
* since core auto-approves those before they even reach the UI.
*
* Any new ask types added in the future will be suppressed by default in yolo mode.
* If a new ask type needs user interaction, add it here explicitly.
*/
const YOLO_INTERACTIVE_ASKS = new Set<ClineAsk>([
"completion_result",
// In yolo mode, ExecuteCommandToolHandler auto-approves commands via say() (not ask()) at line 176,
// so command asks never reach the UI for regular tool use. The only command ask that reaches the UI
// is from AttemptCompletionHandler (line 135), which uses askApprovalAndPushFeedback("command", ...)
// to let the user choose whether to run the suggested verification command after task completion.
"command",
"followup",
"plan_mode_respond",
"resume_task",
"resume_completed_task",
"new_task",
])
function isYoloSuppressed(yolo: boolean, ask: ClineAsk | undefined): boolean {
return yolo && (!ask || !YOLO_INTERACTIVE_ASKS.has(ask))
}
/**
* Get the type of prompt needed for an ask message
*/
@@ -262,6 +303,17 @@ function parseAskOptions(text: string): string[] {
return parts.options || []
}
/**
* Expand pasted text placeholders back to actual content
* Replaces [Pasted text #N +X lines] with the stored content
*/
function expandPastedTexts(text: string, pastedTexts: Map<number, string>): string {
return text.replace(/\[Pasted text #(\d+) \+\d+ lines\]/g, (match, num) => {
const content = pastedTexts.get(parseInt(num, 10))
return content ?? match
})
}
export const ChatView: React.FC<ChatViewProps> = ({
controller,
onExit,
@@ -272,9 +324,12 @@ export const ChatView: React.FC<ChatViewProps> = ({
initialImages,
taskId,
}) => {
// Get Ink app instance for graceful exit
const { exit: inkExit } = useApp()
// Get task state from context
const taskState = useTaskState()
const { controller: taskController } = useTaskContext()
const { controller: taskController, clearState } = useTaskContext()
const { isActive: isSpinnerActive, startTime: spinnerStartTime } = useIsSpinnerActive()
// Prefer prop controller over context controller (memoized for stable reference in callbacks)
@@ -284,12 +339,26 @@ export const ChatView: React.FC<ChatViewProps> = ({
const [textInput, setTextInput] = useState("")
const [cursorPos, setCursorPos] = useState(0)
const [fileResults, setFileResults] = useState<FileSearchResult[]>([])
const [selectedIndex, setSelectedIndex] = useState(0)
const [selectedIndex, setSelectedIndex] = useState(0) // For file menu
const [historyIndex, setHistoryIndex] = useState(-1) // -1 = not browsing history, 0+ = history item index
const [savedInput, setSavedInput] = useState("") // Save user's input when entering history mode
const [isSearching, setIsSearching] = useState(false)
const [showRipgrepWarning, setShowRipgrepWarning] = useState(false)
const [respondedToAsk, setRespondedToAsk] = useState<number | null>(null)
const [userScrolled, setUserScrolled] = useState(false)
// Pasted text storage - maps placeholder number to full pasted content
const [pastedTexts, setPastedTexts] = useState<Map<number, string>>(new Map())
const pasteCounterRef = useRef(0)
// Track paste timing to combine chunks that arrive in rapid succession
const lastPasteTimeRef = useRef<number>(0)
const activePasteNumRef = useRef<number>(0)
const activePasteStartPosRef = useRef<number>(0) // Where the placeholder starts in the text
const activePasteLinesRef = useRef<number>(0) // Total line count for current paste
const pasteUpdateTimeoutRef = useRef<NodeJS.Timeout | null>(null) // Debounce placeholder updates
const PASTE_CHUNK_WINDOW_MS = 150 // Chunks within this window are combined into one paste
const PASTE_UPDATE_DEBOUNCE_MS = 50 // Debounce visual updates to avoid flicker
// Slash command state
const [availableCommands, setAvailableCommands] = useState<SlashCommandInfo[]>([])
const [selectedSlashIndex, setSelectedSlashIndex] = useState(0)
@@ -297,12 +366,30 @@ export const ChatView: React.FC<ChatViewProps> = ({
const lastSlashIndexRef = useRef<number>(-1)
// Settings panel state
const [activePanel, setActivePanel] = useState<{ type: "settings"; initialMode?: "model-picker" } | null>(null)
const [activePanel, setActivePanel] = useState<
{ type: "settings"; initialMode?: "model-picker" } | { type: "history" } | null
>(null)
// Track when we're exiting to hide UI elements before exit
const [isExiting, setIsExiting] = useState(false)
// Task switch handling: when switching tasks via /history, we clear the terminal and
// increment a counter used as the root Box's key. This forces React to remount the tree,
// giving us a fresh Static instance. Mirrors how App.tsx handles resize with resizeKey.
const [taskSwitchKey, setTaskSwitchKey] = useState(0)
const prevFirstMessageTs = useRef<number | null>(null)
// Listen for shutdown event (Ctrl+C) to hide UI before exit
useEffect(() => {
const subscription = shutdownEvent.event(() => {
const session = Session.get()
const summary = session.getStats()
telemetryService.captureHostEvent("exit", JSON.stringify(summary))
setIsExiting(true)
})
return () => subscription.dispose()
}, [])
// Track which messages have been rendered to Static (by timestamp)
// Using refs instead of state to avoid extra renders during streaming->static transition
const loggedMessageTsRef = useRef<Set<number>>(new Set())
const headerLoggedRef = useRef(false)
const [gitBranch, setGitBranch] = useState<string | null>(null)
const [gitDiffStats, setGitDiffStats] = useState<GitDiffStats | null>(null)
@@ -314,6 +401,13 @@ export const ChatView: React.FC<ChatViewProps> = ({
const [yolo, setYolo] = useState<boolean>(() => StateManager.get().getGlobalSettingsKey("yoloModeToggled") ?? false)
// Sync mode from core state updates (e.g. yolo auto-switching plan to act)
useEffect(() => {
if (taskState.mode && taskState.mode !== mode) {
setMode(taskState.mode as Mode)
}
}, [taskState.mode])
const toggleYolo = useCallback(() => {
const newValue = !yolo
setYolo(newValue)
@@ -328,12 +422,38 @@ export const ChatView: React.FC<ChatViewProps> = ({
return (stateManager.getGlobalSettingsKey(modelKey) as string) || "claude-sonnet-4-20250514"
}, [mode, activePanel])
const toggleMode = useCallback(() => {
// Get provider based on current mode
const provider = useMemo(() => {
const stateManager = StateManager.get()
const providerKey = mode === "act" ? "actModeApiProvider" : "planModeApiProvider"
return (stateManager.getGlobalSettingsKey(providerKey) as string) || "anthropic"
}, [mode, activePanel])
const toggleMode = useCallback(async () => {
const newMode: Mode = mode === "act" ? "plan" : "act"
setMode(newMode)
const stateManager = StateManager.get()
stateManager.setGlobalState("mode", newMode)
}, [mode])
// When switching from plan to act, include any text in the input box
// Text stays visible in the input - don't clear it
if (newMode === "act" && textInput.trim()) {
const expandedText = expandPastedTexts(textInput, pastedTexts)
await ctrl.togglePlanActMode(newMode, { message: expandedText.trim() })
} else {
await ctrl.togglePlanActMode(newMode)
}
}, [mode, ctrl, textInput, pastedTexts])
// Clear the terminal view and reset task state (used by /clear and "Start New Task" button)
const clearViewAndResetTask = useCallback(() => {
process.stdout.write("\x1b[2J\x1b[3J\x1b[H") // Clear screen + scrollback, cursor home
setTaskSwitchKey((k) => k + 1) // Force remount for fresh Static instance
clearState() // Force clear React state (bypasses empty messages check)
if (ctrl) {
ctrl.clearTask().then(() => ctrl.postStateToWebview())
}
setTextInput("")
setCursorPos(0)
}, [ctrl, clearState])
const refs = useRef({
searchTimeout: null as NodeJS.Timeout | null,
@@ -343,7 +463,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
const { prompt, imagePaths } = parseImagesFromInput(textInput)
const mentionInfo = useMemo(() => extractMentionQuery(textInput), [textInput])
const slashInfo = useMemo(() => extractSlashQuery(textInput), [textInput])
const slashInfo = useMemo(() => extractSlashQuery(textInput, cursorPos), [textInput, cursorPos])
const filteredCommands = useMemo(
() => filterCommands(availableCommands, slashInfo.query),
[availableCommands, slashInfo.query],
@@ -379,8 +499,10 @@ export const ChatView: React.FC<ChatViewProps> = ({
// Load existing task when taskId is provided
useEffect(() => {
if (!taskId) return
if (!ctrl) return
// Prevent duplicate loads after resize. The resize fix remounts components via
// resizeKey, but the controller's task persists. Skip if already loaded.
if (ctrl.task?.taskId === taskId) return
// Load the task by ID
showTaskWithId(ctrl, StringRequest.create({ value: taskId })).catch((error) => {
@@ -411,6 +533,18 @@ export const ChatView: React.FC<ChatViewProps> = ({
loadCommands()
}, [ctrl])
// Get history items (limited to MAX_HISTORY_ITEMS, most recent first)
const getHistoryItems = useCallback(() => {
const history = StateManager.get().getGlobalStateKey("taskHistory")
if (!history?.length) return []
const filtered = [...history]
.reverse()
.map((item) => item.task)
.slice(0, MAX_HISTORY_ITEMS)
.filter(Boolean) as string[]
return [...new Set(filtered)]
}, [])
const messages = taskState.clineMessages || []
// Refresh git diff stats when messages change (after file edits)
@@ -433,6 +567,19 @@ export const ChatView: React.FC<ChatViewProps> = ({
return combineCommandSequences(filtered)
}, [messages])
// Detect task switches by watching first message timestamp change.
// When user selects a different task from /history, the messages array updates with
// the new task's messages. We clear the terminal first, then increment the key to
// trigger a re-render. The clear must happen before setState so the new render isn't wiped.
const firstMessageTs = displayMessages[0]?.ts ?? null
useEffect(() => {
if (prevFirstMessageTs.current !== null && firstMessageTs !== null && prevFirstMessageTs.current !== firstMessageTs) {
process.stdout.write("\x1b[2J\x1b[3J\x1b[H") // Clear screen + scrollback, cursor home
setTaskSwitchKey((k) => k + 1) // Trigger remount after clear
}
prevFirstMessageTs.current = firstMessageTs
}, [firstMessageTs])
// Split messages into completed (for Static) and current (for dynamic region)
const { completedMessages, currentMessage } = useMemo(() => {
const completed: typeof displayMessages = []
@@ -556,17 +703,22 @@ export const ChatView: React.FC<ChatViewProps> = ({
async (responseType: string, text?: string) => {
if (!ctrl?.task || !pendingAsk) return
// Expand any pasted text placeholders
const expandedText = text ? expandPastedTexts(text, pastedTexts) : text
setRespondedToAsk(pendingAsk.ts)
setTextInput("")
setCursorPos(0)
setPastedTexts(new Map()) // Clear stored pastes
pasteCounterRef.current = 0
try {
await ctrl.task.handleWebviewAskResponse(responseType, text)
await ctrl.task.handleWebviewAskResponse(responseType, expandedText)
} catch {
// Controller may be disposed
}
},
[ctrl, pendingAsk],
[ctrl, pendingAsk, pastedTexts],
)
// Handle cancel/interrupt
@@ -580,6 +732,16 @@ export const ChatView: React.FC<ChatViewProps> = ({
}
}, [ctrl])
// Handle exit - hide input first, show summary, then exit Ink app gracefully
const handleExit = useCallback(() => {
setIsExiting(true)
// Delay to allow Ink to re-render with session summary visible
setTimeout(() => {
inkExit()
onExit?.()
}, 150)
}, [inkExit, onExit])
// Get button config based on the last message state
const buttonConfig = useMemo(() => {
const lastMsg = messages[messages.length - 1] as ClineMessage | undefined
@@ -601,7 +763,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
case "reject":
// Check for resume states that should trigger exit
if (pendingAsk?.ask === "resume_task" || pendingAsk?.ask === "resume_completed_task") {
onExit?.()
handleExit()
} else {
sendAskResponse("noButtonClicked")
}
@@ -611,17 +773,23 @@ export const ChatView: React.FC<ChatViewProps> = ({
sendAskResponse("yesButtonClicked")
break
case "new_task":
// For now, signal to start a new task (user can type new prompt)
setRespondedToAsk(pendingAsk?.ts || null)
setTextInput("")
setCursorPos(0)
if (pendingAsk?.ask === "new_task") {
// Model called new_task tool - create new task with context
setRespondedToAsk(pendingAsk.ts)
setTextInput("")
setCursorPos(0)
await ctrl.initTask(pendingAsk.text || "")
} else {
// From completion_result or resume_completed_task - full clear
clearViewAndResetTask()
}
break
case "cancel":
handleCancel()
break
}
},
[controller, taskController, sendAskResponse, pendingAsk, onExit, handleCancel],
[controller, taskController, sendAskResponse, pendingAsk, handleExit, handleCancel],
)
// Handle task submission (new task)
@@ -629,8 +797,13 @@ export const ChatView: React.FC<ChatViewProps> = ({
async (text: string, images: string[]) => {
if (!ctrl || !text.trim()) return
// Expand any pasted text placeholders
const expandedText = expandPastedTexts(text, pastedTexts)
setTextInput("")
setCursorPos(0)
setPastedTexts(new Map()) // Clear stored pastes
pasteCounterRef.current = 0
try {
// Convert image paths to data URLs if needed
@@ -652,12 +825,13 @@ export const ChatView: React.FC<ChatViewProps> = ({
)
: []
const validImages = imageDataUrls.filter((img): img is string => img !== null)
await ctrl.initTask(text.trim(), validImages.length > 0 ? validImages : undefined)
setTerminalTitle(expandedText.trim())
await ctrl.initTask(expandedText.trim(), validImages.length > 0 ? validImages : undefined)
} catch (_error) {
onError?.()
}
},
[ctrl, onError],
[ctrl, onError, pastedTexts],
)
// Auto-submit initial prompt if provided
@@ -677,6 +851,10 @@ export const ChatView: React.FC<ChatViewProps> = ({
await new Promise((resolve) => setTimeout(resolve, 100))
try {
// Set terminal title to the task prompt
if (initialPrompt) {
setTerminalTitle(initialPrompt)
}
// initialImages are already data URLs from index.ts processing
await ctrl.initTask(initialPrompt || "", initialImages && initialImages.length > 0 ? initialImages : undefined)
} catch (_error) {
@@ -743,6 +921,12 @@ export const ChatView: React.FC<ChatViewProps> = ({
// Handle keyboard input
useInput((input, key) => {
// Handle Ctrl+C - hide input and exit gracefully
if (input === "\x03" || (key.ctrl && input === "c")) {
handleExit()
return
}
// Filter out mouse escape sequences from AsciiMotionCli's mouse tracking
if (isMouseEscapeSequence(input)) {
return
@@ -788,6 +972,20 @@ export const ChatView: React.FC<ChatViewProps> = ({
setSlashMenuDismissed(true)
return
}
if (cmd.name === "history") {
setActivePanel({ type: "history" })
setTextInput("")
setCursorPos(0)
setSelectedSlashIndex(0)
setSlashMenuDismissed(true)
return
}
if (cmd.name === "clear") {
clearViewAndResetTask()
setSelectedSlashIndex(0)
setSlashMenuDismissed(true)
return
}
const newText = insertSlashCommand(textInput, slashInfo.slashIndex, cmd.name)
setTextInput(newText)
setCursorPos(newText.length)
@@ -831,21 +1029,85 @@ export const ChatView: React.FC<ChatViewProps> = ({
}
}
// History navigation with up/down arrows
// Only works when: input is empty, or input matches the currently selected history item
if (key.upArrow && !inSlashMenu && !inFileMenu) {
const historyItems = getHistoryItems()
if (historyItems.length > 0) {
const canNavigate =
textInput === "" ||
(historyIndex >= 0 && historyIndex < historyItems.length && textInput === historyItems[historyIndex])
if (canNavigate) {
// Save original input when first entering history mode
if (historyIndex === -1) {
setSavedInput(textInput)
}
const newIndex = Math.min(historyIndex + 1, historyItems.length - 1)
if (newIndex !== historyIndex) {
setHistoryIndex(newIndex)
const historyText = historyItems[newIndex]
setTextInput(historyText)
setCursorPos(historyText.length)
}
return
}
}
}
if (key.downArrow && !inSlashMenu && !inFileMenu) {
const historyItems = getHistoryItems()
if (historyIndex >= 0) {
const canNavigate = historyIndex < historyItems.length && textInput === historyItems[historyIndex]
if (canNavigate) {
const newIndex = historyIndex - 1
if (newIndex >= 0) {
// Move to older history item
setHistoryIndex(newIndex)
const historyText = historyItems[newIndex]
setTextInput(historyText)
setCursorPos(historyText.length)
} else {
// Exit history mode, restore saved input
setHistoryIndex(-1)
setTextInput(savedInput)
setCursorPos(savedInput.length)
}
return
}
}
}
// Handle button actions (1 for primary, 2 for secondary)
// Only when buttons are enabled, not streaming, and no text has been typed
if (buttonConfig.enableButtons && !isSpinnerActive && textInput === "" && !yolo) {
if (input === "1" && buttonConfig.primaryAction) {
handleButtonAction(buttonConfig.primaryAction, true)
return
if (
buttonConfig.enableButtons &&
!isSpinnerActive &&
textInput === "" &&
!isYoloSuppressed(yolo, pendingAsk?.ask as ClineAsk | undefined)
) {
const { hasPrimary, hasSecondary } = getVisibleButtons(buttonConfig)
if (input === "1") {
// "1" triggers primary if shown, otherwise secondary if it's the only button
if (hasPrimary && buttonConfig.primaryAction) {
handleButtonAction(buttonConfig.primaryAction, true)
return
} else if (hasSecondary && !hasPrimary && buttonConfig.secondaryAction) {
handleButtonAction(buttonConfig.secondaryAction, false)
return
}
}
if (input === "2" && buttonConfig.secondaryAction) {
if (input === "2" && hasPrimary && hasSecondary && buttonConfig.secondaryAction) {
// "2" only works when both buttons are shown
handleButtonAction(buttonConfig.secondaryAction, false)
return
}
}
// Handle ask responses for options and text input
if (pendingAsk && !yolo) {
if (pendingAsk && !isYoloSuppressed(yolo, pendingAsk.ask as ClineAsk | undefined)) {
// Allow sending text message for any ask type where sending is enabled
if (key.return && textInput.trim() && !buttonConfig.sendingDisabled) {
sendAskResponse("messageResponse", textInput.trim())
@@ -862,6 +1124,100 @@ export const ChatView: React.FC<ChatViewProps> = ({
}
}
// Handle Ctrl+ shortcuts
const keydown = input?.toLowerCase()
if (key.ctrl && cursorPos && keydown) {
switch (keydown) {
case "u": // Ctrl+U, clear line before cursor
if (cursorPos > 0) {
setTextInput((prev) => prev.slice(cursorPos))
setCursorPos(0)
}
return
case "e": // Ctrl+E, move cursor to end
setCursorPos(textInput.length)
return
case "b": // Ctrl+B, move cursor left
setCursorPos((pos) => Math.max(0, pos - 1))
return
case "f": // Ctrl+F, move cursor right
setCursorPos((pos) => Math.min(textInput.length, pos + 1))
return
case "d": // Ctrl+D, delete character after cursor
if (cursorPos < textInput.length) {
setTextInput((prev) => prev.slice(0, cursorPos) + prev.slice(cursorPos + 1))
}
return
case "h": // Ctrl+H, delete character before cursor (like backspace)
if (cursorPos > 0) {
setTextInput((prev) => prev.slice(0, cursorPos - 1) + prev.slice(cursorPos))
setCursorPos((pos) => pos - 1)
}
return
}
}
// Detect paste by checking if input length exceeds threshold
// Large pastes mess up the terminal UI, so we collapse them into a placeholder
// Terminal sends large pastes in multiple chunks, so we combine chunks that arrive rapidly
if (input && input.length > PASTE_COLLAPSE_THRESHOLD) {
const now = Date.now()
const timeSinceLastPaste = now - lastPasteTimeRef.current
lastPasteTimeRef.current = now
// Check if this is a continuation of a recent paste (within time window)
if (timeSinceLastPaste < PASTE_CHUNK_WINDOW_MS && activePasteNumRef.current > 0) {
// Append to existing paste content (store immediately, don't lose data)
const pasteNum = activePasteNumRef.current
const chunkLines = input.match(/[\r\n]/g)?.length || 0
activePasteLinesRef.current += chunkLines
setPastedTexts((prev) => {
const next = new Map(prev)
const existing = next.get(pasteNum) || ""
next.set(pasteNum, existing + input)
return next
})
// Debounce the visual update to avoid flicker while chunks are arriving
if (pasteUpdateTimeoutRef.current) {
clearTimeout(pasteUpdateTimeoutRef.current)
}
pasteUpdateTimeoutRef.current = setTimeout(() => {
const newPlaceholder = `[Pasted text #${pasteNum} +${activePasteLinesRef.current} lines]`
setTextInput((prev) => {
const pattern = new RegExp(`\\[Pasted text #${pasteNum} \\+\\d+ lines\\]`)
return prev.replace(pattern, newPlaceholder)
})
// Update cursor to be right after the placeholder
setCursorPos(activePasteStartPosRef.current + newPlaceholder.length)
Logger.info(`Paste #${pasteNum} complete: ${activePasteLinesRef.current} lines`)
}, PASTE_UPDATE_DEBOUNCE_MS)
return // Don't add another placeholder
}
// New paste operation - create placeholder
pasteCounterRef.current += 1
const pasteNum = pasteCounterRef.current
activePasteNumRef.current = pasteNum
activePasteStartPosRef.current = cursorPos // Track where placeholder starts
// Count line breaks in the pasted content (handle both \n and \r)
const extraLines = input.match(/[\r\n]/g)?.length || 0
activePasteLinesRef.current = extraLines // Track total lines
const placeholder = `[Pasted text #${pasteNum} +${extraLines} lines]`
// Store the full content
setPastedTexts((prev) => {
const next = new Map(prev)
next.set(pasteNum, input)
return next
})
setTextInput((prev) => prev.slice(0, cursorPos) + placeholder + prev.slice(cursorPos))
setCursorPos(cursorPos + placeholder.length)
return // Exit early - don't also add the raw input via normal handling below
}
// Normal input handling
if (key.shift && key.tab) {
toggleYolo()
@@ -901,6 +1257,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
setCursorPos(moveCursorDown(textInput, cursorPos))
return
}
// Normal input (single char or short paste)
if (input && !key.ctrl && !key.meta && !key.upArrow && !key.downArrow && !key.tab) {
setTextInput((prev) => prev.slice(0, cursorPos) + input + prev.slice(cursorPos))
setCursorPos((pos) => pos + input.length)
@@ -909,6 +1266,22 @@ export const ChatView: React.FC<ChatViewProps> = ({
const borderColor = mode === "act" ? COLORS.primaryBlue : "yellow"
const metrics = getApiMetrics(messages)
// Get last API request total tokens for context window progress
const lastApiReqTotalTokens = useMemo(() => getLastApiReqTotalTokens(messages), [messages])
// Get context window size from model info
const contextWindowSize = useMemo(() => {
const providerData = providerModels[provider]
if (providerData && modelId in providerData.models) {
const modelInfo = providerData.models[modelId] as ModelInfo
if (modelInfo?.contextWindow) {
return modelInfo.contextWindow
}
}
return DEFAULT_CONTEXT_WINDOW
}, [provider, modelId])
const showSlashMenu = slashInfo.inSlashMode && !slashMenuDismissed
const showFileMenu = mentionInfo.inMentionMode && !showSlashMenu
@@ -919,7 +1292,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
}
return (
<Box flexDirection="column" width="100%">
<Box flexDirection="column" key={taskSwitchKey} width="100%">
{/* Static content - rendered once, stays above dynamic region */}
<Static items={staticItems}>
{(item) => {
@@ -966,22 +1339,18 @@ export const ChatView: React.FC<ChatViewProps> = ({
</Box>
)}
{/* Ripgrep warning if needed */}
{showRipgrepWarning && (
<Box marginTop={1}>
<Text color="yellow">Warning: ripgrep not found - file search will be slower. </Text>
<Text color="gray">Install: {getRipgrepInstallInstructions()}</Text>
</Box>
)}
{/* Action buttons for tool approvals and other asks (not during streaming) */}
{buttonConfig.enableButtons && !isSpinnerActive && !yolo && <ActionButtons config={buttonConfig} mode={mode} />}
{buttonConfig.enableButtons &&
!isSpinnerActive &&
!isYoloSuppressed(yolo, pendingAsk?.ask as ClineAsk | undefined) && (
<ActionButtons config={buttonConfig} mode={mode} />
)}
{/* Thinking indicator when processing */}
{isSpinnerActive && <ThinkingIndicator mode={mode} onCancel={handleCancel} startTime={spinnerStartTime} />}
{/* Input field with border - hidden when panel is open */}
{!activePanel && (
{/* Input field with border - hidden when panel is open or exiting */}
{!activePanel && !isExiting && (
<Box
borderColor={borderColor}
borderStyle="round"
@@ -991,7 +1360,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
paddingRight={1}
width="100%">
<Box>
{inputPrompt && <Text color="yellow">{inputPrompt} </Text>}
{inputPrompt && <Text color={borderColor}>{inputPrompt} </Text>}
<HighlightedInput
availableCommands={availableCommands.map((c) => c.name)}
cursorPos={cursorPos}
@@ -1010,6 +1379,15 @@ export const ChatView: React.FC<ChatViewProps> = ({
/>
)}
{/* History panel */}
{activePanel?.type === "history" && ctrl && (
<HistoryPanelContent
controller={ctrl}
onClose={() => setActivePanel(null)}
onSelectTask={() => setActivePanel(null)}
/>
)}
{/* Slash command menu - below input (takes priority over file menu) */}
{showSlashMenu && !activePanel && (
<Box paddingLeft={1} paddingRight={1}>
@@ -1029,6 +1407,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
query={mentionInfo.query}
results={fileResults}
selectedIndex={selectedIndex}
showRipgrepWarning={showRipgrepWarning}
/>
</Box>
)}
@@ -1044,7 +1423,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
{/* Footer - hidden when any menu or panel is shown */}
{!showSlashMenu && !showFileMenu && !activePanel && (
<>
<Box flexDirection="column" width="100%">
{/* Row 1: Instructions (left, can wrap) | Plan/Act toggle (right, no wrap) */}
<Box justifyContent="space-between" paddingLeft={1} paddingRight={1} width="100%">
<Box flexShrink={1} flexWrap="wrap">
@@ -1069,18 +1448,16 @@ export const ChatView: React.FC<ChatViewProps> = ({
<Box paddingLeft={1} paddingRight={1}>
<Text>
{modelId} {(() => {
const bar = createContextBar(
metrics.totalTokensIn + metrics.totalTokensOut,
DEFAULT_CONTEXT_WINDOW,
)
const bar = createContextBar(lastApiReqTotalTokens, contextWindowSize)
return (
<Text>
<Text color="gray">{bar.filled}</Text>
<Text>{bar.filled}</Text>
<Text color="gray">{bar.empty}</Text>
</Text>
)
})()}({(metrics.totalTokensIn + metrics.totalTokensOut).toLocaleString()}) | $
{metrics.totalCost.toFixed(3)}
})()} <Text color="gray">
({lastApiReqTotalTokens.toLocaleString()}) | ${metrics.totalCost.toFixed(3)}
</Text>
</Text>
</Box>
@@ -1090,7 +1467,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
{workspacePath.split("/").pop() || workspacePath}
{gitBranch && ` (${gitBranch})`}
{gitDiffStats && gitDiffStats.files > 0 && (
<Text>
<Text color="gray">
{" "}
| {gitDiffStats.files} file{gitDiffStats.files !== 1 ? "s" : ""}{" "}
<Text color="green">+{gitDiffStats.additions}</Text>{" "}
@@ -1108,8 +1485,11 @@ export const ChatView: React.FC<ChatViewProps> = ({
<Text color="gray">Auto-approve all disabled (Shift+Tab)</Text>
)}
</Box>
</>
</Box>
)}
{/* Session summary - shown when exiting */}
{isExiting && <SessionSummary />}
</Box>
</Box>
)
+19 -2
View File
@@ -6,7 +6,7 @@
import { Box, Text } from "ink"
import React from "react"
import { COLORS } from "../constants/colors"
import type { FileSearchResult } from "../utils/file-search"
import { type FileSearchResult, getRipgrepInstallInstructions } from "../utils/file-search"
import { getVisibleWindow } from "../utils/slash-commands"
interface FileMentionMenuProps {
@@ -14,6 +14,7 @@ interface FileMentionMenuProps {
selectedIndex: number
isLoading: boolean
query: string
showRipgrepWarning?: boolean
}
/**
@@ -26,11 +27,25 @@ function truncatePath(filePath: string, maxLength: number = 50): string {
return "..." + filePath.slice(-(maxLength - 3))
}
export const FileMentionMenu: React.FC<FileMentionMenuProps> = ({ results, selectedIndex, isLoading, query }) => {
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>
)
}
@@ -39,6 +54,7 @@ export const FileMentionMenu: React.FC<FileMentionMenuProps> = ({ results, selec
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>
)
}
@@ -61,6 +77,7 @@ export const FileMentionMenu: React.FC<FileMentionMenuProps> = ({ results, selec
)
})}
{hasMoreBelow && <Text color="gray">{" "}</Text>}
{ripgrepWarning}
</Box>
)
}
@@ -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>
)
}
@@ -28,6 +28,11 @@ vi.mock("@/shared/proto/cline/common", () => ({
},
}))
// 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"
+3 -3
View File
@@ -3,12 +3,13 @@
* Displays task history with keyboard navigation
*/
import { Box, Text, useInput, useStdout } from "ink"
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
@@ -55,12 +56,11 @@ export const HistoryView: React.FC<HistoryViewProps> = ({
const { isRawModeSupported } = useStdinContext()
const [selectedIndex, setSelectedIndex] = useState(0)
const [internalPage, setInternalPage] = useState(pagination?.page ?? 1)
const { stdout } = useStdout()
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 terminalRows = stdout?.rows ?? 24
const headerLines = (pagination?.totalPages ?? 1) > 1 ? 5 : 4
const footerLines = 1
const availableRows = terminalRows - headerLines - footerLines
+72 -9
View File
@@ -6,38 +6,98 @@
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 { fetchOpenRouterModels, getOpenRouterDefaultModelId, usesOpenRouterModels } from "../utils/openrouter-models"
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 },
"openai-native": { models: openAiNativeModels, defaultId: openAiNativeDefaultModelId },
gemini: { models: geminiModels, defaultId: geminiDefaultModelId },
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 },
mistral: { models: mistralModels, defaultId: mistralDefaultModelId },
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 {
@@ -62,28 +122,31 @@ export function getModelList(provider: string): string[] {
interface ModelPickerProps {
provider: string
controller: any
onChange: (modelId: string) => void
onSubmit: (modelId: string) => void
isActive?: boolean
}
export const ModelPicker: React.FC<ModelPickerProps> = ({ provider, onChange, onSubmit, isActive = true }) => {
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
// Fetch OpenRouter models when needed using shared core function
useEffect(() => {
if (usesOpenRouterModels(provider)) {
setIsLoading(true)
fetchOpenRouterModels()
refreshOpenRouterModels(controller)
.then((models) => {
setAsyncModels(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])
}, [provider, controller])
const modelList = useMemo(() => {
if (usesOpenRouterModels(provider)) {
@@ -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)} />
}
+3 -1
View File
@@ -6,6 +6,7 @@
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
@@ -24,6 +25,7 @@ interface PanelProps {
}
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 (
@@ -58,7 +60,7 @@ export const Panel: React.FC<PanelProps> = ({ label, tabs, currentTab, children
{/* Separator line */}
<Box>
<Text bold color={COLORS.primaryBlue}>
{"─".repeat(process.stdout.columns - 2)}
{"─".repeat(Math.max(columns - 2, 0))}
</Text>
</Box>
+112 -5
View File
@@ -3,7 +3,8 @@
*/
import React, { useMemo } from "react"
import { API_PROVIDERS_LIST } from "@/shared/api"
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"
@@ -23,13 +24,119 @@ 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
configuredProviders?: Set<string>
}
export const ProviderPicker: React.FC<ProviderPickerProps> = ({ onSelect, isActive = true, configuredProviders = new Set() }) => {
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)
@@ -38,9 +145,9 @@ export const ProviderPicker: React.FC<ProviderPickerProps> = ({ onSelect, isActi
return sorted.map((providerId) => ({
id: providerId,
label: getProviderLabel(providerId),
suffix: configuredProviders.has(providerId) ? "(configured)" : undefined,
suffix: isProviderConfigured(providerId, apiConfig) ? "(Configured)" : undefined,
}))
}, [configuredProviders])
}, [apiConfig])
return <SearchableList isActive={isActive} items={items} onSelect={(item) => onSelect(item.id)} />
}
+9 -11
View File
@@ -9,6 +9,7 @@ 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 {
@@ -38,17 +39,14 @@ export function SearchableList<T extends SearchableListItem>({
const [search, setSearch] = useState("")
const [index, setIndex] = useState(0)
// Default filter: search in id and label
const defaultFilter = (item: T, searchStr: string) => {
const searchLower = searchStr.toLowerCase()
return item.id.toLowerCase().includes(searchLower) || item.label.toLowerCase().includes(searchLower)
}
// Filter items by search
// Filter items by search using fuzzy matching
const filteredItems = useMemo(() => {
if (!search) return items
const filter = filterFn || defaultFilter
return items.filter((item) => filter(item, search))
// 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
@@ -78,7 +76,7 @@ export function SearchableList<T extends SearchableListItem>({
setIndex((prev) => Math.max(0, prev - 1))
} else if (key.downArrow) {
setIndex((prev) => Math.min(filteredItems.length - 1, prev + 1))
} else if (key.return) {
} else if (key.return || key.tab) {
if (filteredItems[index]) {
onSelect(filteredItems[index])
}
@@ -106,7 +104,7 @@ export function SearchableList<T extends SearchableListItem>({
return (
<Box key={item.id}>
<Text color={isSelected ? COLORS.primaryBlue : undefined}>
{isSelected ? "> " : " "}
{isSelected ? " " : " "}
{item.label}
{item.suffix && <Text color="gray"> {item.suffix}</Text>}
</Text>
+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>
)
}
+492 -86
View File
@@ -10,19 +10,22 @@ import type { TelemetrySetting } from "@shared/TelemetrySetting"
import { Box, Text, useInput } from "ink"
import Spinner from "ink-spinner"
import React, { useCallback, useEffect, useMemo, useState } from "react"
import { buildApiHandler } from "@/core/api"
import type { Controller } from "@/core/controller"
import { StateManager } from "@/core/storage/StateManager"
import { openAiCodexOAuthManager } from "@/integrations/openai-codex/oauth"
import { openAiCodexDefaultModelId } from "@/shared/api"
import { ClineAccountService } from "@/services/account/ClineAccountService"
import { AuthService, ClineAccountOrganization } from "@/services/auth/AuthService"
import { openExternal } from "@/utils/env"
import { COLORS } from "../constants/colors"
import { useStdinContext } from "../context/StdinContext"
import { isMouseEscapeSequence } from "../utils/input"
import { applyProviderConfig } from "../utils/provider-config"
import { ApiKeyInput } from "./ApiKeyInput"
import { type BedrockConfig, BedrockSetup } from "./BedrockSetup"
import { Checkbox } from "./Checkbox"
import { LanguagePicker } from "./LanguagePicker"
import { getDefaultModelId, hasModelPicker, ModelPicker } from "./ModelPicker"
import { OrganizationPicker } from "./OrganizationPicker"
import { Panel, PanelTab } from "./Panel"
import { getProviderLabel, ProviderPicker } from "./ProviderPicker"
@@ -32,12 +35,12 @@ interface SettingsPanelContentProps {
initialMode?: "model-picker"
}
type SettingsTab = "api" | "auto-approve" | "features" | "other"
type SettingsTab = "api" | "auto-approve" | "features" | "other" | "account"
interface ListItem {
key: string
label: string
type: "checkbox" | "readonly" | "editable" | "separator" | "header" | "spacer"
type: "checkbox" | "readonly" | "editable" | "separator" | "header" | "spacer" | "action"
value: string | boolean
description?: string
isSubItem?: boolean
@@ -48,6 +51,7 @@ const TABS: PanelTab[] = [
{ key: "api", label: "API" },
{ key: "auto-approve", label: "Auto-approve" },
{ key: "features", label: "Features" },
{ key: "account", label: "Account" },
{ key: "other", label: "Other" },
]
@@ -99,6 +103,16 @@ const FEATURE_SETTINGS = {
type FeatureKey = keyof typeof FEATURE_SETTINGS
/**
* Format balance as currency (balance is in microcredits, divide by 1000000)
*/
function formatBalance(balance: number | null): string {
if (balance === null || balance === undefined) {
return "..."
}
return `$${(balance / 1000000).toFixed(2)}`
}
export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onClose, controller, initialMode }) => {
const { isRawModeSupported } = useStdinContext()
const stateManager = StateManager.get()
@@ -114,6 +128,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
const [isPickingProvider, setIsPickingProvider] = useState(false)
const [isPickingLanguage, setIsPickingLanguage] = useState(false)
const [isEnteringApiKey, setIsEnteringApiKey] = useState(false)
const [isConfiguringBedrock, setIsConfiguringBedrock] = useState(false)
const [isWaitingForCodexAuth, setIsWaitingForCodexAuth] = useState(false)
const [codexAuthError, setCodexAuthError] = useState<string | null>(null)
const [pendingProvider, setPendingProvider] = useState<string | null>(null)
@@ -154,6 +169,16 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
() => stateManager.getGlobalSettingsKey("telemetrySetting") || "unset",
)
// Account tab state
const [accountEmail, setAccountEmail] = useState<string | null>(null)
const [accountBalance, setAccountBalance] = useState<number | null>(null)
const [accountOrganization, setAccountOrganization] = useState<ClineAccountOrganization | null>(null)
const [accountOrganizations, setAccountOrganizations] = useState<ClineAccountOrganization[] | null>(null)
const [isAccountLoading, setIsAccountLoading] = useState(false)
const [isPickingOrganization, setIsPickingOrganization] = useState(false)
const [isWaitingForClineAuth, setIsWaitingForClineAuth] = useState(false)
const [accountChecked, setAccountChecked] = useState(false) // Tracks if we've already checked auth
// Get current provider and model info
const apiConfig = stateManager.getApiConfiguration()
const [provider, setProvider] = useState<string>(
@@ -173,12 +198,171 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
[features, stateManager],
)
// Fetch account info (reused pattern from AccountInfoView.tsx)
const fetchAccountInfo = useCallback(async () => {
if (!controller) {
return
}
try {
setIsAccountLoading(true)
const authService = AuthService.getInstance(controller)
// Wait for auth to be restored
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) {
setAccountEmail(authInfo.user.email)
} else {
setAccountEmail(null)
setIsAccountLoading(false)
return
}
// Get organization info
const organizations = authService.getUserOrganizations()
if (organizations) {
setAccountOrganizations(organizations)
const activeOrg = organizations.find((org) => org.active)
setAccountOrganization(activeOrg || null)
}
// Fetch credit balance
try {
const accountService = ClineAccountService.getInstance()
const activeOrgId = authService.getActiveOrganizationId()
if (activeOrgId) {
const orgBalance = await accountService.fetchOrganizationCreditsRPC(activeOrgId)
if (orgBalance?.balance !== undefined) {
setAccountBalance(orgBalance.balance)
}
} else {
const balanceData = await accountService.fetchBalanceRPC()
if (balanceData?.balance !== undefined) {
setAccountBalance(balanceData.balance)
}
}
} catch {
// Balance fetch failed, but we can still show other info
}
} catch {
// Error fetching account info
} finally {
setIsAccountLoading(false)
setAccountChecked(true)
}
}, [controller])
// Handle Cline login - starts OAuth flow
const handleClineLogin = useCallback(() => {
if (!controller) {
return
}
// Set waiting state first (synchronously) to show the waiting UI immediately
setIsWaitingForClineAuth(true)
// Then start the auth request (async, but we don't need to await)
AuthService.getInstance(controller)
.createAuthRequest()
.catch(() => {
setIsWaitingForClineAuth(false)
})
}, [controller])
// Handle Cline logout
const handleClineLogout = useCallback(async () => {
if (!controller) {
return
}
await AuthService.getInstance(controller).handleDeauth()
setAccountEmail(null)
setAccountBalance(null)
setAccountOrganization(null)
setAccountOrganizations(null)
setAccountChecked(true) // Mark as checked so we don't re-fetch
}, [controller])
// Handle organization selection
const handleOrganizationSelect = useCallback(
async (orgId: string | null) => {
if (!controller) {
return
}
setIsPickingOrganization(false)
try {
await ClineAccountService.getInstance().switchAccount(orgId || undefined)
// Refetch to get updated auth info with new active org
await AuthService.getInstance(controller).restoreRefreshTokenAndRetrieveAuthInfo()
fetchAccountInfo()
} catch {
// Error switching organization
}
},
[controller, fetchAccountInfo],
)
// Fetch account info when switching to account tab (only if not already checked)
useEffect(() => {
if (currentTab === "account" && !accountEmail && !isAccountLoading && !accountChecked && controller) {
fetchAccountInfo()
}
}, [currentTab, accountEmail, isAccountLoading, accountChecked, controller, fetchAccountInfo])
// Subscribe to auth status updates when waiting for Cline auth
useEffect(() => {
if (!isWaitingForClineAuth || !controller) {
return
}
let cancelled = false
const authService = AuthService.getInstance(controller)
const responseHandler = async (authState: { user?: { email?: string } }) => {
if (cancelled) {
return
}
if (authState.user?.email) {
setIsWaitingForClineAuth(false)
setAccountChecked(false) // Reset so fetchAccountInfo can run
await applyProviderConfig({ providerId: "cline", controller })
setProvider("cline")
fetchAccountInfo()
}
}
authService.subscribeToAuthStatusUpdate(controller, {}, responseHandler, `settings-auth-${Date.now()}`)
return () => {
cancelled = true
}
}, [isWaitingForClineAuth, controller, fetchAccountInfo])
// Build items list based on current tab
const items: ListItem[] = useMemo(() => {
// OpenAI Native, Codex, and GPT models don't support thinking budget (they use reasoning effort)
const isGptModel = actModelId?.toLowerCase().includes("gpt") || planModelId?.toLowerCase().includes("gpt")
const showThinkingOption = provider !== "openai-native" && provider !== "openai-codex" && !isGptModel
switch (currentTab) {
case "api":
return [
{ key: "provider", label: "Provider", type: "editable", value: provider || "not configured" },
{
key: "provider",
label: "Provider",
type: "editable",
value: provider ? getProviderLabel(provider) : "not configured",
},
...(provider === "cline"
? [{ key: "viewAccount", label: "View account", type: "action" as const, value: "" }]
: []),
...(separateModels
? [
{ key: "spacer0", label: "", type: "spacer" as const, value: "" },
@@ -189,12 +373,16 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
type: "editable" as const,
value: actModelId || "not set",
},
{
key: "actThinkingEnabled",
label: "Enable thinking",
type: "checkbox" as const,
value: actThinkingEnabled,
},
...(showThinkingOption
? [
{
key: "actThinkingEnabled",
label: "Enable thinking",
type: "checkbox" as const,
value: actThinkingEnabled,
},
]
: []),
{ key: "planHeader", label: "Plan Mode", type: "header" as const, value: "" },
{
key: "planModelId",
@@ -202,12 +390,16 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
type: "editable" as const,
value: planModelId || "not set",
},
{
key: "planThinkingEnabled",
label: "Enable thinking",
type: "checkbox" as const,
value: planThinkingEnabled,
},
...(showThinkingOption
? [
{
key: "planThinkingEnabled",
label: "Enable thinking",
type: "checkbox" as const,
value: planThinkingEnabled,
},
]
: []),
{ key: "spacer1", label: "", type: "spacer" as const, value: "" },
]
: [
@@ -217,12 +409,16 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
type: "editable" as const,
value: actModelId || "not set",
},
{
key: "actThinkingEnabled",
label: "Enable thinking",
type: "checkbox" as const,
value: actThinkingEnabled,
},
...(showThinkingOption
? [
{
key: "actThinkingEnabled",
label: "Enable thinking",
type: "checkbox" as const,
value: actThinkingEnabled,
},
]
: []),
]),
{
key: "separateModels",
@@ -338,6 +534,40 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
},
]
case "account":
// If loading, return empty (loading spinner shown in render)
if (isAccountLoading) {
return []
}
// If not logged in, show login option
if (!accountEmail) {
return [{ key: "login", label: "Sign in with Cline", type: "action", value: "" }]
}
// Logged in - show account info
const accountItems: ListItem[] = [
{ key: "email", label: "Email", type: "readonly", value: accountEmail },
{ key: "balance", label: "Credits", type: "readonly", value: formatBalance(accountBalance) },
]
// Organization selector - only show if user has organizations
if (accountOrganizations && accountOrganizations.length > 0) {
accountItems.push({
key: "organization",
label: "Organization",
type: "editable",
value: accountOrganization ? accountOrganization.name : "Personal",
})
} else {
accountItems.push({
key: "organization",
label: "Account",
type: "readonly",
value: "Personal",
})
}
accountItems.push({ key: "separator", label: "", type: "separator", value: "" })
accountItems.push({ key: "logout", label: "Sign out", type: "action", value: "" })
return accountItems
default:
return []
}
@@ -353,6 +583,11 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
features,
preferredLanguage,
telemetry,
isAccountLoading,
accountEmail,
accountBalance,
accountOrganization,
accountOrganizations,
])
// Reset selection when changing tabs
@@ -367,6 +602,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
setIsEnteringApiKey(false)
setPendingProvider(null)
setApiKeyValue("")
setIsPickingOrganization(false)
}, [])
// Ensure selected index is valid when items change
@@ -382,6 +618,23 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
if (!item || item.type === "readonly" || item.type === "separator" || item.type === "header" || item.type === "spacer")
return
if (item.type === "action") {
// Action items trigger their handler directly
if (item.key === "login") {
handleClineLogin()
return
}
if (item.key === "logout") {
handleClineLogout()
return
}
if (item.key === "viewAccount") {
handleTabChange("account")
return
}
return
}
if (item.type === "editable") {
// For provider field, use the provider picker
if (item.key === "provider") {
@@ -399,6 +652,11 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
setIsPickingLanguage(true)
return
}
// For organization field, use the organization picker
if (item.key === "organization" && accountOrganizations && accountOrganizations.length > 0) {
setIsPickingOrganization(true)
return
}
setEditValue(typeof item.value === "string" ? item.value : "")
setIsEditing(true)
return
@@ -488,7 +746,16 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
const newSettings = { ...autoApproveSettings, version: (autoApproveSettings.version ?? 1) + 1, actions: newActions }
setAutoApproveSettings(newSettings)
stateManager.setGlobalState("autoApprovalSettings", newSettings)
}, [items, selectedIndex, stateManager, autoApproveSettings, toggleFeature])
}, [
items,
selectedIndex,
stateManager,
autoApproveSettings,
toggleFeature,
handleClineLogin,
handleClineLogout,
accountOrganizations,
])
// Handle model selection from picker
const handleModelSelect = useCallback(
@@ -534,23 +801,8 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
// Wait for the callback
await openAiCodexOAuthManager.waitForCallback()
// Success - save configuration
const config: Record<string, string> = {
actModeApiProvider: "openai-codex",
planModeApiProvider: "openai-codex",
actModeApiModelId: openAiCodexDefaultModelId,
planModeApiModelId: openAiCodexDefaultModelId,
}
stateManager.setApiConfiguration(config)
await stateManager.flushPendingState()
// Rebuild API handler on active task if one exists
if (controller?.task) {
const currentMode = stateManager.getGlobalSettingsKey("mode")
const apiConfig = stateManager.getApiConfiguration()
controller.task.api = buildApiHandler({ ...apiConfig, ulid: controller.task.ulid }, currentMode)
}
// Success - apply provider config
await applyProviderConfig({ providerId: "openai-codex", controller })
setProvider("openai-codex")
setIsWaitingForCodexAuth(false)
} catch (error) {
@@ -558,11 +810,27 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
setCodexAuthError(error instanceof Error ? error.message : String(error))
setIsWaitingForCodexAuth(false)
}
}, [stateManager, controller])
}, [controller])
// Handle provider selection from picker
const handleProviderSelect = useCallback(
(providerId: string) => {
// Special handling for Cline - uses OAuth (but skip if already logged in)
if (providerId === "cline") {
setIsPickingProvider(false)
// Check if already logged in
const authInfo = AuthService.getInstance(controller).getInfo()
if (authInfo?.user?.email) {
// Already logged in - just set the provider
applyProviderConfig({ providerId: "cline", controller })
setProvider("cline")
} else {
// Not logged in - trigger OAuth
handleClineLogin()
}
return
}
// Special handling for OpenAI Codex - uses OAuth instead of API key
if (providerId === "openai-codex") {
setIsPickingProvider(false)
@@ -570,28 +838,34 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
return
}
// Special handling for Bedrock - needs multi-field configuration
if (providerId === "bedrock") {
setPendingProvider(providerId)
setIsPickingProvider(false)
setIsConfiguringBedrock(true)
return
}
// Check if this provider needs an API key
const keyField = ProviderToApiKeyMap[providerId as keyof typeof ProviderToApiKeyMap]
if (keyField) {
// Provider needs an API key - go to API key entry mode
// Pre-fill with existing key if configured
const apiConfig = stateManager.getApiConfiguration()
const fieldName = Array.isArray(keyField) ? keyField[0] : keyField
const existingKey = (apiConfig as Record<string, string>)[fieldName] || ""
setPendingProvider(providerId)
setApiKeyValue("")
setApiKeyValue(existingKey)
setIsPickingProvider(false)
setIsEnteringApiKey(true)
} else {
// Provider doesn't need an API key (rare) - just set it
applyProviderConfig({ providerId, controller })
setProvider(providerId)
stateManager.setGlobalState("actModeApiProvider", providerId)
stateManager.setGlobalState("planModeApiProvider", providerId)
const defaultModelId = getDefaultModelId(providerId)
if (defaultModelId) {
stateManager.setGlobalState("actModeApiModelId", defaultModelId)
stateManager.setGlobalState("planModeApiModelId", defaultModelId)
}
setIsPickingProvider(false)
}
},
[stateManager, startCodexAuth],
[stateManager, startCodexAuth, handleClineLogin, controller],
)
// Handle API key submission after provider selection
@@ -601,45 +875,55 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
return
}
// Build config object with provider, model, and API key
const config: Record<string, string> = {
actModeApiProvider: pendingProvider,
planModeApiProvider: pendingProvider,
apiProvider: pendingProvider,
}
// Add default model ID
const defaultModelId = getDefaultModelId(pendingProvider)
if (defaultModelId) {
config.actModeApiModelId = defaultModelId
config.planModeApiModelId = defaultModelId
}
// Add API key using provider-specific field
const keyField = ProviderToApiKeyMap[pendingProvider as keyof typeof ProviderToApiKeyMap]
if (keyField) {
const fields = Array.isArray(keyField) ? keyField : [keyField]
config[fields[0]] = submittedValue.trim()
}
// Save via StateManager (same pattern as AuthView)
stateManager.setApiConfiguration(config)
await stateManager.flushPendingState()
// Rebuild API handler on active task if one exists (matches extension behavior)
if (controller?.task) {
const currentMode = stateManager.getGlobalSettingsKey("mode")
const apiConfig = stateManager.getApiConfiguration()
controller.task.api = buildApiHandler({ ...apiConfig, ulid: controller.task.ulid }, currentMode)
}
// Update local state
await applyProviderConfig({ providerId: pendingProvider, apiKey: submittedValue.trim(), controller })
setProvider(pendingProvider)
setIsEnteringApiKey(false)
setPendingProvider(null)
setApiKeyValue("")
},
[pendingProvider, stateManager, controller],
[pendingProvider, controller],
)
// Handle Bedrock configuration complete
const handleBedrockComplete = useCallback(
(bedrockConfig: BedrockConfig) => {
const config: Record<string, unknown> = {
actModeApiProvider: "bedrock",
planModeApiProvider: "bedrock",
apiProvider: "bedrock",
awsAuthentication: bedrockConfig.awsAuthentication,
awsRegion: bedrockConfig.awsRegion,
awsUseCrossRegionInference: bedrockConfig.awsUseCrossRegionInference,
}
const defaultModelId = getDefaultModelId("bedrock")
if (defaultModelId) {
config.actModeApiModelId = defaultModelId
config.planModeApiModelId = defaultModelId
}
if (bedrockConfig.awsProfile !== undefined) config.awsProfile = bedrockConfig.awsProfile
if (bedrockConfig.awsAccessKey) config.awsAccessKey = bedrockConfig.awsAccessKey
if (bedrockConfig.awsSecretKey) config.awsSecretKey = bedrockConfig.awsSecretKey
if (bedrockConfig.awsSessionToken) config.awsSessionToken = bedrockConfig.awsSessionToken
stateManager.setApiConfiguration(config as Record<string, string>)
// Close Bedrock config first, then flush state async
setProvider("bedrock")
setIsConfiguringBedrock(false)
setPendingProvider(null)
// Flush state and rebuild API handler in background
stateManager.flushPendingState().then(() => {
if (controller?.task) {
const currentMode = stateManager.getGlobalSettingsKey("mode")
const apiConfig = stateManager.getApiConfiguration()
controller.task.api = buildApiHandler({ ...apiConfig, ulid: controller.task.ulid }, currentMode)
}
})
},
[stateManager, controller],
)
// Handle saving edited value
@@ -752,6 +1036,22 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
return
}
// Organization picker mode - escape to close, input is handled by OrganizationPicker
if (isPickingOrganization) {
if (key.escape) {
setIsPickingOrganization(false)
}
return
}
// Cline OAuth waiting mode - escape to cancel
if (isWaitingForClineAuth) {
if (key.escape) {
setIsWaitingForClineAuth(false)
}
return
}
if (isEditing) {
if (key.escape) {
setIsEditing(false)
@@ -796,7 +1096,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
return
}
},
{ isActive: isRawModeSupported && !isEnteringApiKey },
{ isActive: isRawModeSupported && !isEnteringApiKey && !isConfiguringBedrock },
)
// Render content
@@ -834,6 +1134,19 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
)
}
if (isConfiguringBedrock) {
return (
<BedrockSetup
isActive={isConfiguringBedrock}
onCancel={() => {
setIsConfiguringBedrock(false)
setPendingProvider(null)
}}
onComplete={handleBedrockComplete}
/>
)
}
if (isWaitingForCodexAuth) {
return (
<Box flexDirection="column">
@@ -879,6 +1192,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
</Text>
<Box marginTop={1}>
<ModelPicker
controller={controller}
isActive={isPickingModel}
onChange={() => {}}
onSubmit={handleModelSelect}
@@ -908,6 +1222,85 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
)
}
if (isPickingOrganization && accountOrganizations) {
return (
<Box flexDirection="column">
<Text bold color={COLORS.primaryBlue}>
Select Organization
</Text>
<Box marginTop={1}>
<OrganizationPicker
isActive={isPickingOrganization}
onSelect={handleOrganizationSelect}
organizations={accountOrganizations}
/>
</Box>
<Box marginTop={1}>
<Text color="gray">Arrows to navigate, Enter to select, Esc to cancel</Text>
</Box>
</Box>
)
}
if (isWaitingForClineAuth) {
return (
<Box flexDirection="column">
<Box>
<Text color={COLORS.primaryBlue}>
<Spinner type="dots" />
</Text>
<Text color="white"> Waiting for Cline sign-in...</Text>
</Box>
<Box marginTop={1}>
<Text color="gray">Complete sign-in in your browser.</Text>
</Box>
<Box marginTop={1}>
<Text color="gray">Esc to cancel</Text>
</Box>
</Box>
)
}
// Account tab - loading state
if (currentTab === "account" && isAccountLoading) {
return (
<Box>
<Text color={COLORS.primaryBlue}>
<Spinner type="dots" />
</Text>
<Text color="gray"> Loading account info...</Text>
</Box>
)
}
// Account tab - logged out state with pitch
if (currentTab === "account" && !accountEmail && !isAccountLoading) {
return (
<Box flexDirection="column">
<Text color="white">Sign in to access Cline features:</Text>
<Box flexDirection="column" marginTop={1}>
<Text color="gray"> - Free access to frontier AI models</Text>
<Text color="gray"> - Built-in web search capabilities</Text>
<Text color="gray"> - Team management and shared billing</Text>
</Box>
<Box marginTop={1}>
{items.map((item, idx) => {
const isSelected = idx === selectedIndex
return (
<Text key={item.key}>
<Text bold color={isSelected ? COLORS.primaryBlue : undefined}>
{isSelected ? "" : " "}{" "}
</Text>
<Text color={isSelected ? COLORS.primaryBlue : "white"}>{item.label}</Text>
{isSelected && <Text color="gray"> (Enter)</Text>}
</Text>
)
})}
</Box>
</Box>
)
}
if (isEditing) {
const item = items[selectedIndex]
return (
@@ -990,6 +1383,19 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
)
}
// Action item (button-like, no value display)
if (item.type === "action") {
return (
<Text key={item.key}>
<Text bold color={isSelected ? COLORS.primaryBlue : undefined}>
{isSelected ? "" : " "}{" "}
</Text>
<Text color={isSelected ? COLORS.primaryBlue : "white"}>{item.label}</Text>
{isSelected && <Text color="gray"> (Enter)</Text>}
</Text>
)
}
// Readonly or editable field
return (
<Text key={item.key}>
+1 -1
View File
@@ -319,7 +319,7 @@ export const WelcomeView: React.FC<WelcomeViewProps> = ({ onSubmit, onExit, cont
{/* Help text */}
<Box>
<Text color="gray">Enter to submit · @ to mention files · </Text>
<Text bold={escPressedOnce} color={escPressedOnce ? "white" : "gray"} dimColor={!escPressedOnce}>
<Text bold={escPressedOnce} color={escPressedOnce ? "white" : "gray"}>
{escPressedOnce ? "Press Esc again to exit" : "Esc to exit"}
</Text>
</Box>
+10
View File
@@ -15,6 +15,7 @@ interface TaskContextType {
setIsComplete: (complete: boolean) => void
lastError: string | null
setLastError: (error: string | null) => void
clearState: () => void
}
const TaskContext = createContext<TaskContextType | undefined>(undefined)
@@ -91,6 +92,14 @@ export const TaskContextProvider: React.FC<TaskContextProviderProps> = ({ contro
}
}, [controller])
// Force clear state (bypasses the empty messages check for intentional clears like /clear)
const clearState = () => {
setState({
clineMessages: [],
currentTaskItem: null,
} as unknown as Partial<ExtensionState>)
}
const value: TaskContextType = {
state,
controller,
@@ -98,6 +107,7 @@ export const TaskContextProvider: React.FC<TaskContextProviderProps> = ({ contro
setIsComplete,
lastError,
setLastError,
clearState,
}
return <TaskContext.Provider value={value}>{children}</TaskContext.Provider>
+81
View File
@@ -0,0 +1,81 @@
import { useStdout } from "ink"
import { useCallback, useEffect, useRef, useState } from "react"
/**
* Reactive terminal size hook with resize recovery.
*
* WHY THIS EXISTS:
* Ink tracks how many lines it rendered last frame (`previousLineCount` in log-update.js,
* `lastOutputHeight` in ink.js). On re-render it erases that many lines then writes new
* output. When the terminal resizes, text wrapping changes so the actual number of lines
* on screen no longer matches what Ink thinks it rendered. This causes cascading visual
* artifacts: old content doesn't get fully erased, and new content renders on top of it.
*
* We tried several approaches that didn't work:
* - Writing \x1b[2J\x1b[H before state update: Ink overwrites the clear with its own
* stale-count erasure immediately after.
* - Calling Ink's clear() via prependListener on resize: clear() itself uses the stale
* previousLineCount to erase, so it erases the wrong number of lines too.
* - Patching Ink's resized() to reset lastOutputHeight: The dynamic region renders
* cleanly but Static content (already printed to scrollback) is gone and Ink won't
* re-render it since it tracks which Static items have been rendered by key.
*
* WHAT WORKS (borrowed from Gemini CLI's approach):
* 1. Debounce resize events (300ms) so we wait until the user stops dragging
* 2. Clear the entire terminal including scrollback (\x1b[2J\x1b[3J\x1b[H)
* 3. Increment a `resizeKey` used as a React key on the content tree, forcing React
* to unmount and remount everything from scratch. This resets Ink's internal tracking
* AND re-renders Static content since the components are brand new instances.
*
* Gemini CLI does the same thing in AppContainer.tsx: debounce 300ms, then
* stdout.write(ansiEscapes.clearTerminal) + setHistoryRemountKey(prev => prev + 1).
*
* USAGE:
* - `columns`/`rows`: Current terminal dimensions, updated live during resize
* - `resizeKey`: Increments after resize settles. Use as a React `key` on the root
* content wrapper to force full remount.
*/
export function useTerminalSize() {
const { stdout } = useStdout()
const [size, setSize] = useState({
columns: process.stdout.columns || 80,
rows: process.stdout.rows || 24,
})
const [resizeKey, setResizeKey] = useState(0)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const refreshAfterResize = useCallback(() => {
// Clear terminal + scrollback to wipe stale content from old width
// \x1b[2J clears visible screen, \x1b[3J clears scrollback, \x1b[H moves cursor home
stdout?.write("\x1b[2J\x1b[3J\x1b[H")
// Increment key to force React remount
setResizeKey((prev) => prev + 1)
}, [stdout])
useEffect(() => {
function updateSize() {
setSize({
columns: process.stdout.columns || 80,
rows: process.stdout.rows || 24,
})
// Debounce: wait 300ms after last resize event to do full recovery
if (debounceRef.current) {
clearTimeout(debounceRef.current)
}
debounceRef.current = setTimeout(() => {
refreshAfterResize()
debounceRef.current = null
}, 300)
}
process.stdout.on("resize", updateSize)
return () => {
process.stdout.off("resize", updateSize)
if (debounceRef.current) {
clearTimeout(debounceRef.current)
}
}
}, [refreshAfterResize])
return { ...size, resizeKey }
}
+103 -12
View File
@@ -2,7 +2,6 @@
* Cline CLI - TypeScript implementation with React Ink
*/
import path from "node:path"
import { exit } from "node:process"
import type { ApiProvider } from "@shared/api"
import { Command } from "commander"
@@ -21,9 +20,11 @@ import { ErrorService } from "@/services/error/ErrorService"
import { initializeDistinctId } from "@/services/logging/distinctId"
import { telemetryService } from "@/services/telemetry"
import { Logger } from "@/shared/services/Logger"
import { Session } from "@/shared/services/Session"
import { getProviderModelIdKey, ProviderToApiKeyMap } from "@/shared/storage"
import { secretStorage } from "@/shared/storage/ClineSecretStorage"
import { version as CLI_VERSION } from "../package.json"
import { runAcpMode } from "./acp/index.js"
import { App } from "./components/App"
import { checkRawModeSupport } from "./context/StdinContext"
import { createCliHostBridgeProvider } from "./controllers"
@@ -33,14 +34,35 @@ import { restoreConsole } from "./utils/console"
import { calculateRobotTopRow, queryCursorPos } from "./utils/cursor-position"
import { printInfo, printWarning } from "./utils/display"
import { parseImagesFromInput, processImagePaths } from "./utils/parser"
import { CLINE_CLI_DIR, getCliBinaryPath } from "./utils/path"
import { readStdinIfPiped } from "./utils/piped"
import { runPlainTextTask } from "./utils/plain-text-task"
import { printSessionSummary } from "./utils/session-summary"
import { checkForUpdates } from "./utils/update"
import { initializeCliContext } from "./vscode-context"
import { window } from "./vscode-shim"
import { CLI_LOG_FILE, shutdownEvent, window } from "./vscode-shim"
// Track active context for graceful shutdown
let activeContext: CliContext | null = null
let isShuttingDown = false
// Track if we're in plain text mode (no Ink UI) - set by runTask when piped stdin detected
let isPlainTextMode = false
/**
* Wait for stdout to fully drain before exiting.
* Critical for piping - ensures data is flushed to the next command in the pipe.
*/
async function drainStdout(): Promise<void> {
return new Promise<void>((resolve) => {
// Check if stdout needs draining
if (process.stdout.writableNeedDrain) {
process.stdout.once("drain", resolve)
} else {
// Give a small delay to ensure any pending writes complete
setImmediate(resolve)
}
})
}
function setupSignalHandlers() {
const shutdown = async (signal: string) => {
@@ -50,7 +72,20 @@ function setupSignalHandlers() {
}
isShuttingDown = true
printWarning(`\n${signal} received, shutting down...`)
// Notify components to hide UI before shutdown
shutdownEvent.fire()
// Only clear Ink UI lines if we're not in plain text mode
// In plain text mode, there's no Ink UI to clear and the ANSI codes
// would corrupt the streaming output
if (!isPlainTextMode) {
// Clear several lines to remove the input field and footer from display
// Move cursor up and clear lines (input box + footer rows)
const linesToClear = 8 // Input box (3 lines with border) + footer (4-5 lines)
process.stdout.write(`\x1b[${linesToClear}A\x1b[J`)
}
printWarning(`${signal} received, shutting down...`)
try {
if (activeContext) {
@@ -65,6 +100,10 @@ function setupSignalHandlers() {
} catch {
// Best effort cleanup
}
// Print session summary before exit
printSessionSummary()
process.exit(0)
}
@@ -77,6 +116,7 @@ function setupSignalHandlers() {
const message = reason instanceof Error ? reason.message : String(reason)
// Silently ignore abort-related errors - they're expected during task cancellation
if (message.includes("aborted") || message.includes("abort")) {
Logger.info("Suppressed unhandled rejection due to abort:", message)
return
}
// For other unhandled rejections, log to file via Logger (if available)
@@ -115,12 +155,17 @@ async function initializeCli(options: InitOptions): Promise<CliContext> {
await ClineEndpoint.initialize()
await initializeDistinctId(extensionContext)
// Initialize/reset session tracking for this CLI run
Session.reset()
if (options.enableAuth) {
AuthHandler.getInstance().setEnabled(true)
}
const outputChannel = window.createOutputChannel("Cline CLI")
outputChannel.appendLine(`Cline CLI initialized. Data dir: ${DATA_DIR}, Extension dir: ${EXTENSION_DIR}`)
outputChannel.appendLine(
`Cline CLI initialized. Data dir: ${DATA_DIR}, Extension dir: ${EXTENSION_DIR}, Log dir: ${CLINE_CLI_DIR.log}`,
)
const logToChannel = (message: string) => outputChannel.appendLine(message)
HostProvider.initialize(
@@ -131,7 +176,7 @@ async function initializeCli(options: InitOptions): Promise<CliContext> {
createCliHostBridgeProvider(workspacePath),
logToChannel,
async () => (options.enableAuth ? AuthHandler.getInstance().getCallbackUrl() : ""),
async (name: string) => path.join(process.cwd(), name),
getCliBinaryPath,
EXTENSION_DIR,
DATA_DIR,
)
@@ -165,7 +210,10 @@ async function runInkApp(element: React.ReactElement, cleanup: () => Promise<voi
// Ink's incremental rendering tries to erase N lines based on previous output height,
// but when the terminal shrinks, this leaves artifacts. Gemini CLI only enables
// incrementalRendering when alternateBuffer is also enabled (which we don't use).
const { waitUntilExit, unmount } = render(element)
//
// exitOnCtrlC: false - We handle Ctrl+C ourselves so we can clean up UI before exiting.
// The app components listen for Ctrl+C via useInput and call their exit handlers.
const { waitUntilExit, unmount } = render(element, { exitOnCtrlC: false })
try {
await waitUntilExit()
@@ -194,6 +242,7 @@ async function runTask(
config?: string
thinking?: boolean
yolo?: boolean
timeout?: string
images?: string[]
json?: boolean
stdinWasPiped?: boolean
@@ -263,11 +312,14 @@ async function runTask(
// Detect if output is a TTY (interactive terminal) or redirected to a file/pipe
const isTTY = process.stdout.isTTY === true
// Use plain text mode when output is redirected, stdin was piped, or JSON mode is enabled
// Use plain text mode when output is redirected, stdin was piped, JSON mode is enabled, or --yolo flag is used
// Ink requires raw mode on stdin which isn't available when stdin is piped
// Note: we use the stdinWasPiped flag passed from the caller because process.stdin.isTTY
// may not be reliable after stdin has been consumed by readStdinIfPiped()
if (!isTTY || options.stdinWasPiped || options.json) {
if (!isTTY || options.stdinWasPiped || options.json || options.yolo) {
// Set flag so shutdown handler knows not to clear Ink UI lines
isPlainTextMode = true
// Check if auth is configured before attempting to run the task
// In plain text mode we can't show the interactive auth flow
const hasAuth = await isAuthConfigured()
@@ -279,7 +331,13 @@ async function runTask(
exit(1)
}
const reason = options.json ? "json" : options.stdinWasPiped ? "piped_stdin" : "redirected_output"
const reason = options.yolo
? "yolo_flag"
: options.json
? "json"
: options.stdinWasPiped
? "piped_stdin"
: "redirected_output"
telemetryService.captureHostEvent("plain_text_mode", reason)
// Plain text mode: no Ink rendering, just clean text output
const success = await runPlainTextTask({
@@ -288,12 +346,16 @@ async function runTask(
imageDataUrls: imageDataUrls.length > 0 ? imageDataUrls : undefined,
verbose: options.verbose,
jsonOutput: options.json,
timeoutSeconds: options.timeout ? parseInt(options.timeout, 10) : undefined,
})
// Cleanup
await ctx.controller.stateManager.flushPendingState()
await ctx.controller.dispose()
await ErrorService.get().dispose()
// Ensure stdout is fully drained before exiting - critical for piping
await drainStdout()
exit(success ? 0 : 1)
}
@@ -360,7 +422,6 @@ async function listHistory(options: { config?: string; limit?: number; page?: nu
await ctx.controller.dispose()
await ErrorService.get().dispose()
exit(0)
return
}
await runInkApp(
@@ -481,7 +542,8 @@ program
.argument("<prompt>", "The task prompt")
.option("-a, --act", "Run in act mode")
.option("-p, --plan", "Run in plan mode")
.option("-y, --yolo", "Enable yolo mode (auto-approve actions)")
.option("-y, --yolo", "Enable yes/yolo mode (auto-approve actions)")
.option("-t, --timeout <seconds>", "Timeout in seconds for yes/yolo mode (default: 600)")
.option("-m, --model <model>", "Model to use for the task")
.option("-v, --verbose", "Show verbose output")
.option("-c, --cwd <path>", "Working directory for the task")
@@ -522,6 +584,23 @@ program
.description("Show Cline CLI version number")
.action(() => printInfo(`Cline CLI version: ${CLI_VERSION}`))
program
.command("update")
.description("Check for updates and install if available")
.option("-v, --verbose", "Show verbose output")
.action(() => checkForUpdates(CLI_VERSION))
// Dev command with subcommands
const devCommand = program.command("dev").description("Developer tools and utilities")
devCommand
.command("log")
.description("Open the log file")
.action(async () => {
const { openExternal } = await import("@/utils/env")
await openExternal(CLI_LOG_FILE)
})
/**
* Check if the user has authentication configured.
* Returns true if they have either:
@@ -611,17 +690,29 @@ program
.option("-a, --act", "Run in act mode")
.option("-p, --plan", "Run in plan mode")
.option("-y, --yolo", "Enable yolo mode (auto-approve actions)")
.option("-t, --timeout <seconds>", "Timeout in seconds for yolo mode (default: 600)")
.option("-m, --model <model>", "Model to use for the task")
.option("-v, --verbose", "Show verbose output")
.option("-c, --cwd <path>", "Working directory")
.option("--config <path>", "Configuration directory")
.option("--thinking", "Enable extended thinking (1024 token budget)")
.option("--json", "Output messages as JSON instead of styled text")
.option("--acp", "Run in ACP (Agent Client Protocol) mode for editor integration")
.action(async (prompt, options) => {
// Check for ACP mode first - this takes precedence over everything else
if (options.acp) {
await runAcpMode({
config: options.config,
cwd: options.cwd,
verbose: options.verbose,
})
return
}
// Always check for piped stdin content
const stdinInput = await readStdinIfPiped()
// Combine stdin content with prompt argument
// If no prompt argument, check if input is piped via stdin
let effectivePrompt = prompt
if (stdinInput) {
if (effectivePrompt) {
+12
View File
@@ -458,3 +458,15 @@ export async function promptConfirmation(question: string): Promise<boolean> {
const answer = await promptUser(`${question} ${style.dim("(y/n)")}`)
return answer.toLowerCase() === "y" || answer.toLowerCase() === "yes"
}
/**
* Set the terminal session title using OSC escape sequence.
* Works in most modern terminal emulators (iTerm2, Terminal.app, GNOME Terminal, etc.)
*/
export function setTerminalTitle(title: string): void {
if (process.stdout.isTTY) {
const maxLength = 80
const truncated = title.length > maxLength ? title.slice(0, maxLength) + "..." : title
process.stdout.write(`\x1b]0;${truncated}\x07`)
}
}
+18
View File
@@ -0,0 +1,18 @@
/**
* Fuzzy search utility using fzf
*/
import { Fzf } from "fzf"
/**
* Filter items using fuzzy matching
* @param items - Array of items to filter
* @param query - Search query string
* @param selector - Function to extract searchable string from each item
* @returns Filtered and sorted items (best matches first)
*/
export function fuzzyFilter<T>(items: readonly T[], query: string, selector: (item: T) => string): T[] {
if (!query) return [...items]
const fzf = new Fzf(items, { selector })
return fzf.find(query).map((result) => result.item)
}
+35
View File
@@ -1,3 +1,4 @@
import { execFileSync } from "node:child_process"
import os from "node:os"
import path from "node:path"
@@ -9,3 +10,37 @@ export const CLINE_CLI_DIR = {
data,
log,
}
/**
* Find binary location for CLI.
* Uses 'which' (Unix) or 'where' (Windows) to locate binaries in the system PATH.
* This is needed for tools like ripgrep that the search_files tool uses.
*/
export async function getCliBinaryPath(name: string): Promise<string> {
// The only binary currently supported is ripgrep (rg)
if (!name.startsWith("rg")) {
throw new Error(`Binary '${name}' is not supported`)
}
const isWindows = process.platform === "win32"
const whichCommand = isWindows ? "where" : "which"
try {
const result = execFileSync(whichCommand, [name], {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
})
// 'which' returns the path, 'where' on Windows may return multiple lines
const binPath = result.trim().split("\n")[0].trim()
if (binPath) {
return binPath
}
} catch {
// Binary not found in PATH
}
throw new Error(
`Could not find '${name}' in system PATH. ` +
`Please install ripgrep: https://github.com/BurntSushi/ripgrep#installation`,
)
}
+41 -235
View File
@@ -1,41 +1,54 @@
import { Readable } from "node:stream"
import * as fs from "node:fs"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { readStdinIfPiped } from "./piped"
// Mock the fs module
vi.mock("node:fs", () => ({
readFileSync: vi.fn(),
}))
describe("readStdinIfPiped", () => {
let mockStdin: Readable & { isTTY?: boolean }
const mockReadFileSync = fs.readFileSync as ReturnType<typeof vi.fn>
let originalIsTTY: boolean | undefined
beforeEach(() => {
// Create a mock readable stream
mockStdin = new Readable({
read() {},
}) as Readable & { isTTY?: boolean }
// Mock process.stdin by stubbing its properties
vi.spyOn(process, "stdin", "get").mockReturnValue(mockStdin as any)
vi.clearAllMocks()
originalIsTTY = process.stdin.isTTY
})
afterEach(() => {
vi.restoreAllMocks()
// Restore original isTTY value
Object.defineProperty(process.stdin, "isTTY", {
value: originalIsTTY,
writable: true,
configurable: true,
})
})
function setTTY(value: boolean | undefined) {
Object.defineProperty(process.stdin, "isTTY", {
value,
writable: true,
configurable: true,
})
}
describe("TTY detection", () => {
it("should return null when stdin is a TTY (interactive terminal)", async () => {
mockStdin.isTTY = true
setTTY(true)
const result = await readStdinIfPiped()
expect(result).toBeNull()
expect(mockReadFileSync).not.toHaveBeenCalled()
})
it("should attempt to read when stdin is not a TTY (piped input)", async () => {
mockStdin.isTTY = false
// Simulate immediate end event (no data)
setImmediate(() => {
mockStdin.emit("end")
})
setTTY(false)
mockReadFileSync.mockReturnValue("")
const result = await readStdinIfPiped()
expect(result).toBeNull()
expect(mockReadFileSync).toHaveBeenCalledWith(0, "utf8")
})
})
@@ -112,14 +125,9 @@ describe("readStdinIfPiped", () => {
testCases.forEach(({ name, input, expected, description }) => {
it(`${name}${description ? ` - ${description}` : ""}`, async () => {
mockStdin.isTTY = false
// Simulate piped data
setImmediate(() => {
const data = Array.isArray(input) ? input.join("\n") : input
mockStdin.push(data)
mockStdin.push(null) // Signal end of stream
})
setTTY(false)
const data = Array.isArray(input) ? input.join("\n") : input
mockReadFileSync.mockReturnValue(data)
const result = await readStdinIfPiped()
expect(result).toBe(expected)
@@ -127,217 +135,19 @@ describe("readStdinIfPiped", () => {
})
})
describe("chunked data", () => {
it("should accumulate data from multiple chunks", async () => {
mockStdin.isTTY = false
setImmediate(() => {
mockStdin.emit("data", "chunk1 ")
mockStdin.emit("data", "chunk2 ")
mockStdin.emit("data", "chunk3")
mockStdin.emit("end")
})
const result = await readStdinIfPiped()
expect(result).toBe("chunk1 chunk2 chunk3")
})
it("should handle rapid successive chunks", async () => {
mockStdin.isTTY = false
setImmediate(() => {
for (let i = 0; i < 100; i++) {
mockStdin.emit("data", `${i} `)
}
mockStdin.emit("end")
})
const result = await readStdinIfPiped()
expect(result).toContain("0 ")
expect(result).toContain("99")
})
})
describe("timeout behavior", () => {
it("should timeout after 100ms if no data received", async () => {
mockStdin.isTTY = false
// Don't emit any events - let it timeout
const startTime = Date.now()
const result = await readStdinIfPiped()
const elapsed = Date.now() - startTime
expect(result).toBeNull()
expect(elapsed).toBeGreaterThanOrEqual(95) // Allow small margin
expect(elapsed).toBeLessThan(150)
})
it("should return data received before timeout", async () => {
mockStdin.isTTY = false
setTimeout(() => {
mockStdin.emit("data", "quick data")
// Don't emit end - let it timeout
}, 50)
const result = await readStdinIfPiped()
expect(result).toBe("quick data")
})
it("should not timeout if end event is received", async () => {
mockStdin.isTTY = false
// Delay end event but emit it before timeout
setTimeout(() => {
mockStdin.emit("data", "delayed data")
mockStdin.emit("end")
}, 50)
const result = await readStdinIfPiped()
expect(result).toBe("delayed data")
})
})
describe("error handling", () => {
it("should return null on stdin error", async () => {
mockStdin.isTTY = false
setImmediate(() => {
mockStdin.emit("error", new Error("stdin read error"))
it("should return null on fs.readFileSync error and fall back to async", async () => {
setTTY(false)
mockReadFileSync.mockImplementation(() => {
throw new Error("EAGAIN: resource temporarily unavailable")
})
// The async fallback will timeout since we can't easily mock process.stdin events
// But we can verify it doesn't throw
const result = await readStdinIfPiped()
// Result will be null because async path times out with no data
expect(result).toBeNull()
})
it("should handle error after partial data received", async () => {
mockStdin.isTTY = false
setImmediate(() => {
mockStdin.emit("data", "partial data")
mockStdin.emit("error", new Error("read error"))
})
const result = await readStdinIfPiped()
expect(result).toBeNull()
})
it("should clean up listeners on error", async () => {
mockStdin.isTTY = false
setImmediate(() => {
mockStdin.emit("error", new Error("test error"))
})
await readStdinIfPiped()
// Note: Implementation uses removeAllListeners() without event names
// which should remove all listeners, but in practice there may be one remaining
// This is acceptable behavior for error handling
expect(mockStdin.listenerCount("data")).toBeLessThanOrEqual(1)
expect(mockStdin.listenerCount("end")).toBeLessThanOrEqual(1)
expect(mockStdin.listenerCount("error")).toBeLessThanOrEqual(1)
})
})
describe("listener cleanup", () => {
it("should remove all listeners on successful completion", async () => {
mockStdin.isTTY = false
setImmediate(() => {
mockStdin.emit("data", "test data")
mockStdin.emit("end")
})
await readStdinIfPiped()
// Note: Implementation doesn't explicitly clean up listeners on normal end,
// so some listeners may remain attached. This is acceptable for one-time use.
expect(mockStdin.listenerCount("data")).toBeLessThanOrEqual(1)
expect(mockStdin.listenerCount("end")).toBeLessThanOrEqual(1)
expect(mockStdin.listenerCount("error")).toBeLessThanOrEqual(1)
})
it("should remove all listeners on timeout", async () => {
mockStdin.isTTY = false
// Let it timeout
await readStdinIfPiped()
// Verify listeners are cleaned up
expect(mockStdin.listenerCount("data")).toBe(0)
expect(mockStdin.listenerCount("end")).toBe(0)
expect(mockStdin.listenerCount("error")).toBe(0)
})
it("should clear timeout when data ends normally", async () => {
mockStdin.isTTY = false
const clearTimeoutSpy = vi.spyOn(global, "clearTimeout")
setImmediate(() => {
mockStdin.emit("data", "test")
mockStdin.emit("end")
})
await readStdinIfPiped()
expect(clearTimeoutSpy).toHaveBeenCalled()
})
it("should clear timeout when error occurs", async () => {
mockStdin.isTTY = false
const clearTimeoutSpy = vi.spyOn(global, "clearTimeout")
setImmediate(() => {
mockStdin.emit("error", new Error("test"))
})
await readStdinIfPiped()
expect(clearTimeoutSpy).toHaveBeenCalled()
})
})
describe("encoding", () => {
it("should handle UTF-8 encoded data", async () => {
mockStdin.isTTY = false
setImmediate(() => {
// The function sets utf8 encoding
mockStdin.setEncoding("utf8")
mockStdin.emit("data", "UTF-8: café ☕")
mockStdin.emit("end")
})
const result = await readStdinIfPiped()
expect(result).toBe("UTF-8: café ☕")
})
})
describe("stdin resume", () => {
it("should call resume on stdin when not TTY", async () => {
mockStdin.isTTY = false
const resumeSpy = vi.spyOn(mockStdin, "resume")
setImmediate(() => {
mockStdin.emit("end")
})
await readStdinIfPiped()
expect(resumeSpy).toHaveBeenCalled()
})
it("should not call resume when TTY", async () => {
mockStdin.isTTY = true
const resumeSpy = vi.spyOn(mockStdin, "resume")
const result = await readStdinIfPiped()
expect(result).toBeNull()
expect(resumeSpy).not.toHaveBeenCalled()
})
})
describe("real-world use cases", () => {
@@ -377,12 +187,8 @@ describe("readStdinIfPiped", () => {
useCases.forEach(({ name, input, expected }) => {
it(`should handle ${name}`, async () => {
mockStdin.isTTY = false
setImmediate(() => {
mockStdin.push(input)
mockStdin.push(null)
})
setTTY(false)
mockReadFileSync.mockReturnValue(input)
const result = await readStdinIfPiped()
expect(result).toBe(expected)
+39 -32
View File
@@ -1,7 +1,12 @@
import * as fs from "node:fs"
/**
* Read piped input from stdin (non-blocking)
*
* This function is designed to work with piped input, including chained commands:
* git diff | cline 'explain' | cline 'summarize'
*
* The challenge is that when chaining cline commands, the first command may take
* several seconds to complete, so we can't use a short timeout. Instead, we wait
* for EOF which signals that the previous command has finished writing.
*/
export async function readStdinIfPiped(): Promise<string | null> {
// Check if stdin is a TTY (interactive) or piped
@@ -9,39 +14,41 @@ export async function readStdinIfPiped(): Promise<string | null> {
return null
}
try {
// Use synchronous read for reliability with piped input
// fd 0 is stdin
const data = fs.readFileSync(0, "utf8")
return data.trim() || null
} catch {
// Fallback to async approach if sync read fails
return new Promise((resolve) => {
let data = ""
process.stdin.setEncoding("utf8")
// Use async approach - more reliable for piped input from other commands
// The synchronous readFileSync(0) can fail with EAGAIN when the pipe
// isn't ready yet (common when piping from another cline command)
return new Promise((resolve) => {
let data = ""
process.stdin.setEncoding("utf8")
// Set a timeout in case stdin is not actually providing data
const timeout = setTimeout(() => {
// For piped input, we wait for EOF (end event) which signals the
// previous command in the pipe has finished writing. We use a longer
// timeout as a safety net for cases where stdin is opened but never
// written to (e.g., some edge cases with file descriptors).
// 5 minutes should be more than enough for any reasonable pipeline.
const timeout = setTimeout(
() => {
process.stdin.removeAllListeners()
resolve(data.trim() || null)
}, 1000)
},
5 * 60 * 1000,
) // 5 minutes
process.stdin.on("data", (chunk) => {
data += chunk
})
process.stdin.on("end", () => {
clearTimeout(timeout)
resolve(data.trim() || null)
})
process.stdin.on("error", () => {
clearTimeout(timeout)
resolve(null)
})
// Resume stdin in case it's paused
process.stdin.resume()
process.stdin.on("data", (chunk) => {
data += chunk
})
}
process.stdin.on("end", () => {
clearTimeout(timeout)
resolve(data.trim() || null)
})
process.stdin.on("error", () => {
clearTimeout(timeout)
resolve(null)
})
// Resume stdin in case it's paused
process.stdin.resume()
})
}
+129 -168
View File
@@ -1,14 +1,20 @@
/**
* Plain-text task runner for non-TTY environments (piped output, file redirection)
* Outputs clean text without ANSI codes or Ink rendering
* Optimized for CI/CD and piping - only outputs the final completion result to stdout.
*
* Design goals:
* - stdout: Only the final completion result text (no prefix) - perfect for piping
* - stderr: Errors and verbose output (won't break pipes)
* - Enables workflows like: git diff | cline 'explain' | cline 'summarize'
*/
/* eslint-disable no-console */
// Console output is intentional here for plain text mode
import { registerPartialMessageCallback } from "@core/controller/ui/subscribeToPartialMessage"
import type { ClineMessage } from "@shared/ExtensionMessage"
import type { ClineMessage, ExtensionState } from "@shared/ExtensionMessage"
import type { Controller } from "@/core/controller"
import { getRequestRegistry } from "@/core/controller/grpc-handler"
import { subscribeToState } from "@/core/controller/state/subscribeToState"
export interface PlainTextTaskOptions {
controller: Controller
@@ -16,206 +22,161 @@ export interface PlainTextTaskOptions {
imageDataUrls?: string[]
verbose?: boolean
jsonOutput?: boolean
/** Timeout in seconds (default: 600 = 10 minutes) */
timeoutSeconds?: number
}
/**
* Run a task with plain text output (no Ink, no ANSI codes)
* Returns true if task completed successfully, false if error
*
* Output behavior:
* - Non-JSON mode: Only writes final completion_result text to stdout
* - JSON mode: Streams JSON lines to stdout as messages arrive (unchanged)
* - Verbose mode: Progress info goes to stderr
* - Errors: Always go to stderr
*/
export async function runPlainTextTask(options: PlainTextTaskOptions): Promise<boolean> {
const { controller, prompt, imageDataUrls, verbose, jsonOutput } = options
// Track completion state
let isComplete = false
let hasError = false
const processedMessages = new Map<number, number>() // index -> last output text length
let lastStreamingMessageIndex = -1 // track open streaming line that needs closing
// Subscribe to state updates
const originalPostState = controller.postStateToWebview.bind(controller)
const handleStateUpdate = async () => {
try {
const state = await controller.getStateToPostToWebview()
const messages = state.clineMessages || []
// Process new messages
for (let i = 0; i < messages.length; i++) {
const message = messages[i]
const currentTextLength = message.text?.length ?? 0
const lastOutputLength = processedMessages.get(i) ?? 0
// Skip if no new content to output
if (currentTextLength <= lastOutputLength) continue
// Close previous streaming line if we're moving to a different message
if (lastStreamingMessageIndex >= 0 && lastStreamingMessageIndex !== i && !jsonOutput) {
process.stdout.write("\n")
lastStreamingMessageIndex = -1
}
processedMessages.set(i, currentTextLength)
// Output the message
if (jsonOutput) {
process.stdout.write(JSON.stringify(message) + "\n")
} else {
const isStreaming = outputMessageAsText(message, verbose || false, lastOutputLength)
// Track streaming state for text messages
if (isStreaming) {
lastStreamingMessageIndex = i
} else {
lastStreamingMessageIndex = -1
}
}
// Check for completion
if (
message.say === "completion_result" ||
message.ask === "completion_result" ||
message.say === "error" ||
message.ask === "api_req_failed"
) {
isComplete = true
if (message.say === "error" || message.ask === "api_req_failed") {
hasError = true
}
}
}
// Close streaming line on completion
if (isComplete && lastStreamingMessageIndex >= 0 && !jsonOutput) {
process.stdout.write("\n")
lastStreamingMessageIndex = -1
}
} catch (error) {
if (jsonOutput) {
process.stdout.write(
JSON.stringify({ type: "error", message: error instanceof Error ? error.message : String(error) }) + "\n",
)
} else {
process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}` + "\n")
}
hasError = true
isComplete = true
}
}
// Override postStateToWebview to capture state updates
controller.postStateToWebview = async () => {
await originalPostState()
await handleStateUpdate()
}
// Subscribe to partial message updates (for streaming)
const unsubscribePartial = registerPartialMessageCallback(() => {
// Partial updates are handled via postStateToWebview
let completionResolve: () => void
let completionReject: (reason?: any) => void
const completionPromise = new Promise<void>((res, rej) => {
completionResolve = res
completionReject = rej
})
try {
// Get initial state
await handleStateUpdate()
let hasError = false
// Track which messages have been processed (by timestamp)
const processedMessages = new Map<number, string>()
// Helper to process a message and track completion state
const processMessage = (message: ClineMessage) => {
const ts = message.ts || 0
if (message.partial || processedMessages.has(ts)) {
return
}
// JSON mode: stream all messages to stdout (existing behavior)
if (jsonOutput) {
process.stdout.write(JSON.stringify(message) + "\n")
} else {
handleMessageForPipeMode(message, verbose || false)
}
processedMessages.set(ts, message.text ?? "")
// Check for completion (only on non-partial messages)
if (message.say === "completion_result" || message.ask === "completion_result") {
completionResolve()
} else if (message.say === "error" || message.ask === "api_req_failed") {
completionReject(message.text ?? "message.say error || message.ask api_req_failed")
}
}
const requestId = "cline-cli-plain-text-task"
subscribeToState(
controller,
{},
async ({ stateJson }) => {
try {
const state = JSON.parse(stateJson) as ExtensionState
for (const message of state.clineMessages ?? []) {
processMessage(message)
}
} catch (error) {
if (jsonOutput) {
process.stdout.write(
JSON.stringify({ type: "error", message: error instanceof Error ? error.message : String(error) }) + "\n",
)
} else {
process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}\n`)
}
completionReject(error)
}
},
requestId,
)
try {
// Start the task
await controller.initTask(prompt, imageDataUrls)
// Wait for completion with timeout
const timeout = 10 * 60 * 1000 // 10 minutes
const startTime = Date.now()
while (!isComplete && Date.now() - startTime < timeout) {
await new Promise((resolve) => setTimeout(resolve, 100))
}
if (!isComplete) {
// Close any open streaming line before error message
if (lastStreamingMessageIndex >= 0 && !jsonOutput) {
process.stdout.write("\n")
lastStreamingMessageIndex = -1
}
if (jsonOutput) {
process.stdout.write(JSON.stringify({ type: "error", message: "Task timeout" }) + "\n")
} else {
process.stderr.write("Error: Task timeout" + "\n")
}
hasError = true
}
const timeoutMs = (options.timeoutSeconds ?? 600) * 1000 // default 10 minutes
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), timeoutMs))
await Promise.race([completionPromise, timeoutPromise])
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error)
if (jsonOutput) {
process.stdout.write(
JSON.stringify({ type: "error", message: error instanceof Error ? error.message : String(error) }) + "\n",
)
process.stdout.write(JSON.stringify({ type: "error", message: errMsg }) + "\n")
} else {
process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}` + "\n")
process.stderr.write(`Error: ${errMsg}\n`)
}
hasError = true
} finally {
// Close any open streaming line
if (lastStreamingMessageIndex >= 0 && !jsonOutput) {
process.stdout.write("\n")
}
// Restore original postStateToWebview
controller.postStateToWebview = originalPostState
unsubscribePartial()
getRequestRegistry().cancelRequest(requestId)
}
// non json mode outputs only the final complete message
// (it should be the completion_result message)
if (!jsonOutput && !verbose) {
const msg = Array.from(processedMessages.entries())
.sort(([aTs], [bTs]) => aTs - bTs)
.map(([_, msg]) => msg)
.at(-1)
process.stdout.write(msg + "\n")
}
return !hasError
}
/**
* Format a Cline message as plain text
* @param previousLength - Length of text already output for this message (for streaming)
* @returns true if this is a streaming message (caller should track for newline), false otherwise
* Handle a message in pipe-optimized mode (non-JSON)
* - Assistant response text (say: "text") is passed to the callback for buffering
* - Errors go to stderr
* - Verbose output goes to stderr
* - Nothing else goes to stdout (stdout is reserved for final result only)
*/
function outputMessageAsText(message: ClineMessage, verbose: boolean, previousLength: number = 0): boolean {
const timestamp = new Date(message.ts || Date.now()).toLocaleTimeString()
function handleMessageForPipeMode(message: ClineMessage, verbose: boolean): void {
const fullText = message.text ?? ""
if (!fullText) {
// Skip partial messages without text
return false
}
// For streaming text continuations, output only new content
if (previousLength > 0 && message.type === "say" && message.say === "text") {
process.stdout.write(fullText.slice(previousLength))
return true // Still streaming
}
if (message.type === "say") {
if (message.say === "task") {
process.stdout.write(`[${timestamp}] Task: ${fullText}\n`)
} else if (message.say === "text") {
// First output of text message - write prefix but no newline (streaming)
process.stdout.write(`[${timestamp}] ${fullText}`)
return true // Streaming - newline will be added when stream ends
} else if (message.say === "completion_result" && fullText) {
process.stdout.write(`[${timestamp}] Completed: ${fullText}\n`)
} else if (message.say === "error") {
process.stderr.write(`[${timestamp}] Error: ${fullText}\n`)
} else if (message.say === "api_req_started") {
if (verbose) {
process.stdout.write(`[${timestamp}] API request started\n`)
}
} else if (message.say === "api_req_finished") {
if (verbose) {
process.stdout.write(`[${timestamp}] API request finished\n`)
}
if (message.say === "error") {
// Errors always go to stderr
process.stderr.write(`Error: ${fullText}\n`)
} else if (verbose) {
process.stdout.write(`[${timestamp}] ${message.say}: ${fullText}\n`)
// Verbose output goes to stderr so it doesn't interfere with piped stdout
if (message.say === "task") {
process.stderr.write(`${fullText}\n`)
} else if (message.say === "text" && fullText) {
process.stderr.write(`${fullText}\n`)
} else if (message.say === "api_req_started") {
process.stderr.write(`API request started\n`)
} else if (message.say === "api_req_finished") {
process.stderr.write(`API request finished\n`)
} else if (message.say === "completion_result" && fullText) {
process.stderr.write(`${fullText}\n`)
} else if (fullText) {
process.stderr.write(`${message.say}: ${fullText}\n`)
}
}
} else if (message.type === "ask") {
if (message.ask === "completion_result") {
process.stdout.write(`[${timestamp}] Task completed\n`)
} else if (message.ask === "api_req_failed") {
process.stderr.write(`[${timestamp}] API request failed: ${fullText}\n`)
if (message.ask === "api_req_failed") {
// Errors always go to stderr
process.stderr.write(`Error: API request failed: ${fullText}\n`)
} else if (message.ask === "tool" || message.ask === "command" || message.ask === "browser_action_launch") {
// These require approval - in non-interactive mode, warn the user
process.stderr.write(`[${timestamp}] Waiting for approval (use --yolo for auto-approve): ${message.ask}\n`)
// These require approval - warn via stderr
process.stderr.write(`Waiting for approval (use --yolo for auto-approve): ${message.ask}\n`)
} else if (verbose) {
process.stdout.write(`[${timestamp}] Question: ${fullText}\n`)
// Verbose output goes to stderr
if (message.ask === "plan_mode_respond" || message.ask === "act_mode_respond") {
if (fullText) {
process.stderr.write(`${fullText}\n`)
}
} else if (message.ask === "completion_result") {
process.stderr.write(`Task completed\n`)
} else if (fullText) {
process.stderr.write(`Question: ${fullText}\n`)
}
}
}
return false
}
+63
View File
@@ -0,0 +1,63 @@
/**
* Shared utility for applying provider configuration
* Used by both AuthView (onboarding) and SettingsPanelContent (settings)
*/
import { ProviderToApiKeyMap } from "@shared/storage"
import { buildApiHandler } from "@/core/api"
import type { Controller } from "@/core/controller"
import { StateManager } from "@/core/storage/StateManager"
import { getDefaultModelId } from "../components/ModelPicker"
export interface ApplyProviderConfigOptions {
providerId: string
apiKey?: string
modelId?: string // Override default model
baseUrl?: string // For OpenAI-compatible providers
controller?: Controller
}
/**
* Apply provider configuration to state and rebuild API handler if needed
*/
export async function applyProviderConfig(options: ApplyProviderConfigOptions): Promise<void> {
const { providerId, apiKey, modelId, baseUrl, controller } = options
const stateManager = StateManager.get()
const config: Record<string, string> = {
actModeApiProvider: providerId,
planModeApiProvider: providerId,
}
// Add model ID (use provided or fall back to default)
const finalModelId = modelId || getDefaultModelId(providerId)
if (finalModelId) {
config.actModeApiModelId = finalModelId
config.planModeApiModelId = finalModelId
}
// Add API key if provided (maps to provider-specific field like anthropicApiKey, openAiApiKey, etc.)
if (apiKey) {
const keyField = ProviderToApiKeyMap[providerId as keyof typeof ProviderToApiKeyMap]
if (keyField) {
const fields = Array.isArray(keyField) ? keyField : [keyField]
config[fields[0]] = apiKey
}
}
// Add base URL if provided (for OpenAI-compatible providers)
if (baseUrl) {
config.openAiBaseUrl = baseUrl
}
// Save via StateManager
stateManager.setApiConfiguration(config)
await stateManager.flushPendingState()
// Rebuild API handler on active task if one exists
if (controller?.task) {
const currentMode = stateManager.getGlobalSettingsKey("mode")
const apiConfig = stateManager.getApiConfiguration()
controller.task.api = buildApiHandler({ ...apiConfig, ulid: controller.task.ulid }, currentMode)
}
}
+108
View File
@@ -0,0 +1,108 @@
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`
}
// ANSI color codes
const GRAY = "\x1b[90m"
const GREEN = "\x1b[32m"
const RED = "\x1b[31m"
const BOLD = "\x1b[1m"
const RESET = "\x1b[0m"
/**
* Print session summary to stdout using plain text (not Ink).
* Used during shutdown when Ink may not have time to render.
*/
export function printSessionSummary(): void {
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
}
const startTime = session.formatTime(session.getStartTime())
const endTime = session.formatTime(session.getEndTime())
const sessionTimeStr = `${startTime}${endTime}`
const lines = [
"",
"┌─────────────────────────────────────────────────────────┐",
`${BOLD}Interaction Summary${RESET}`,
"├─────────────────────────────────────────────────────────┤",
`${GRAY}Session ID:${RESET} ${stats.sessionId.padEnd(42)}`,
`${GRAY}Session Time:${RESET} ${sessionTimeStr.padEnd(42)}`,
`${GRAY}Tool Calls:${RESET} ${stats.totalToolCalls} ( ${GREEN}${stats.successfulToolCalls}${RESET} ${RED}${stats.failedToolCalls}${RESET} )`.padEnd(
70,
) + "│",
`${GRAY}Success Rate:${RESET} ${session.getSuccessRate().toFixed(1)}%`.padEnd(60) + "│",
"├─────────────────────────────────────────────────────────┤",
`${BOLD}Performance${RESET}`,
`${GRAY}Wall Time:${RESET} ${formatDuration(wallTimeMs).padEnd(42)}`,
`${GRAY}Agent Active:${RESET} ${formatDuration(agentActiveMs).padEnd(42)}`,
`${GRAY} » API Time:${RESET} ${formatDuration(stats.apiTimeMs)} ${GRAY}(${formatPercent(stats.apiTimeMs, agentActiveMs)})${RESET}`.padEnd(
60,
) + "│",
`${GRAY} » Tool Time:${RESET} ${formatDuration(stats.toolTimeMs)} ${GRAY}(${formatPercent(stats.toolTimeMs, agentActiveMs)})${RESET}`.padEnd(
60,
) + "│",
"├─────────────────────────────────────────────────────────┤",
`${BOLD}Resources${RESET}`,
`${GRAY}Memory (RSS):${RESET} ${formatBytes(stats.resources.rss).padEnd(42)}`,
`${GRAY}Peak Memory:${RESET} ${formatBytes(stats.peakMemoryBytes).padEnd(42)}`,
`${GRAY}Heap Used:${RESET} ${formatBytes(stats.resources.heapUsed)} ${GRAY}/ ${formatBytes(stats.resources.heapTotal)}${RESET}`.padEnd(
60,
) + "│",
`${GRAY}CPU Time:${RESET} ${formatDuration(stats.resources.userCpuMs + stats.resources.systemCpuMs)} ${GRAY}(user: ${formatDuration(stats.resources.userCpuMs)}, sys: ${formatDuration(stats.resources.systemCpuMs)})${RESET}`.padEnd(
60,
) + "│",
"└─────────────────────────────────────────────────────────┘",
"",
]
process.stdout.write(lines.join("\n"))
}
+13 -8
View File
@@ -4,6 +4,7 @@
*/
import type { SlashCommandInfo } from "@shared/proto/cline/slash"
import { fuzzyFilter } from "./fuzzy-search"
export interface SlashQueryInfo {
inSlashMode: boolean
@@ -48,23 +49,27 @@ export function sortCommandsWorkflowsFirst(commands: SlashCommandInfo[]): SlashC
/**
* Extract slash command query from input text.
* Returns info about whether we're in slash mode and what the query is.
* Takes cursor position to only examine text before cursor (matching webview behavior).
*/
export function extractSlashQuery(text: string): SlashQueryInfo {
// Find the last slash in the text
const slashIndex = text.lastIndexOf("/")
export function extractSlashQuery(text: string, cursorPosition?: number): SlashQueryInfo {
// Use text up to cursor position (or full text if no cursor position provided)
const beforeCursor = cursorPosition !== undefined ? text.slice(0, cursorPosition) : text
// Find the last slash before cursor
const slashIndex = beforeCursor.lastIndexOf("/")
if (slashIndex === -1) {
return { inSlashMode: false, query: "", slashIndex: -1 }
}
// Slash must be at start or preceded by whitespace
const charBeforeSlash = slashIndex > 0 ? text[slashIndex - 1] : null
const charBeforeSlash = slashIndex > 0 ? beforeCursor[slashIndex - 1] : null
if (charBeforeSlash !== null && !/\s/.test(charBeforeSlash)) {
return { inSlashMode: false, query: "", slashIndex: -1 }
}
// Get text after the slash
const textAfterSlash = text.slice(slashIndex + 1)
// Get text after slash (up to cursor)
const textAfterSlash = beforeCursor.slice(slashIndex + 1)
// If there's whitespace after slash, we're not in slash mode anymore
if (/\s/.test(textAfterSlash)) {
@@ -87,13 +92,13 @@ export function extractSlashQuery(text: string): SlashQueryInfo {
}
/**
* Filter commands that match the query prefix (case-insensitive)
* Filter commands using fuzzy matching
*/
export function filterCommands(commands: SlashCommandInfo[], query: string): SlashCommandInfo[] {
if (!query) {
return commands
}
return commands.filter((cmd) => cmd.name.toLowerCase().startsWith(query.toLowerCase()))
return fuzzyFilter(commands, query, (cmd) => cmd.name)
}
/**
+106
View File
@@ -0,0 +1,106 @@
import { spawn } from "node:child_process"
import { exit } from "node:process"
import { fetch } from "@/shared/net"
import { printInfo, printWarning } from "./display"
/**
* Check for updates and install if available
*/
export async function checkForUpdates(currentVersion: string, options?: { verbose?: boolean }) {
printInfo("Checking for updates...")
try {
// Fetch latest version from npm registry
const response = await fetch("https://registry.npmjs.org/cline/latest")
if (!response.ok && response.statusText !== "OK") {
printWarning(`Failed to check for updates: ${response.statusText}`)
exit(1)
}
const data = (await response.json()) as { version: string }
const latestVersion = data.version
if (options?.verbose) {
printInfo(`Current version: ${currentVersion}`)
printInfo(`Latest version: ${latestVersion}`)
}
// Compare versions
if (latestVersion === currentVersion) {
printInfo(`You are already on the latest version (${currentVersion})`)
exit(0)
}
// Check if current is newer (dev version)
if (compareVersions(currentVersion, latestVersion) > 0) {
printInfo(`You are already on a newer version ${currentVersion} (latest: ${latestVersion})`)
exit(0)
}
printInfo(`New version available: ${latestVersion} (current: ${currentVersion})`)
// Ask user to confirm update
const userConfirmed = new Promise<boolean>((resolve) => {
process.stdout.write("Do you want to update now? (y/N): ")
process.stdin.setEncoding("utf-8")
process.stdin.once("data", (dataBuff) => {
const input = dataBuff.toString().trim().toLowerCase()
resolve(input === "y" || input === "yes")
})
})
if (!(await userConfirmed)) {
exit(0)
}
printInfo("Installing update...")
// Run npm install -g cline@latest
const npmProcess = spawn("npm", ["install", "-g", "cline@latest"], {
stdio: "inherit",
shell: true,
// Ensures the process uses the same environment
env: process.env,
detached: false,
windowsHide: true,
})
npmProcess.on("close", (code) => {
if (code === 0) {
printInfo(`Successfully updated to version ${latestVersion}`)
exit(0)
} else {
printWarning("Update failed. Please try running: npm install -g cline@latest")
exit(1)
}
})
npmProcess.on("error", (err) => {
printWarning(`Failed to run npm install: ${err.message}`)
printInfo("Please try running manually: npm install -g cline@latest")
exit(1)
})
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
printWarning(`Error checking for updates: ${message}`)
exit(1)
}
}
/**
* Compare two semantic version strings
* Returns: 1 if v1 > v2, -1 if v1 < v2, 0 if equal
*/
function compareVersions(v1: string, v2: string): number {
const parts1 = v1.split(".").map(Number)
const parts2 = v2.split(".").map(Number)
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
const part1 = parts1[i] || 0
const part2 = parts2[i] || 0
if (part1 > part2) return 1
if (part1 < part2) return -1
}
return 0
}
+8 -1
View File
@@ -116,10 +116,17 @@ function hashString(str: string): string {
return Math.abs(hash).toString(16).substring(0, 8)
}
export interface CliContextResult {
extensionContext: ClineExtensionContext
DATA_DIR: string
EXTENSION_DIR: string
WORKSPACE_STORAGE_DIR: string
}
/**
* Initialize the VSCode-like context for CLI mode
*/
export function initializeCliContext(config: CliContextConfig = {}) {
export function initializeCliContext(config: CliContextConfig = {}): CliContextResult {
const CLINE_DIR = config.clineDir || process.env.CLINE_DIR || path.join(os.homedir(), ".cline")
const DATA_DIR = path.join(CLINE_DIR, SETTINGS_SUBFOLDER)
+15 -3
View File
@@ -12,6 +12,8 @@ import { CLINE_CLI_DIR } from "./utils/path"
export { URI } from "vscode-uri"
export { ClineFileStorage } from "@/shared/storage"
export const CLI_LOG_FILE = path.join(CLINE_CLI_DIR.log, "cline-cli.1.log")
/**
* Safely read and parse a JSON file, returning a default value on failure
*/
@@ -103,14 +105,14 @@ const outputChannelLoggers = new Map<string, Logger>()
function getOutputChannelLogger(channelName: string): Logger {
let logger = outputChannelLoggers.get(channelName)
if (!logger) {
const logFileName = (channelName.trim() || "output").replace(/[^a-zA-Z0-9._-]+/g, "-")
const transport = pino.transport({
target: "pino-roll",
options: {
file: path.join(CLINE_CLI_DIR.log, logFileName),
name: channelName,
file: CLI_LOG_FILE.replace(".1", ""),
mkdir: true,
frequency: "daily",
limit: { count: 2 },
limit: { count: 5 },
},
})
logger = pino({ timestamp: pino.stdTimeFunctions.isoTime }, transport)
@@ -335,3 +337,13 @@ export type Memento = any
export type SecretStorage = any
// biome-ignore lint/correctness/noUnusedVariables: placeholder
export type Extension<T> = any
// ============================================================================
// Shutdown event for graceful cleanup
// ============================================================================
/**
* Event emitter for app shutdown notification.
* Components can listen to this to clean up UI before process exit.
*/
export const shutdownEvent = new EventEmitter<void>()
+15 -15
View File
@@ -16,54 +16,54 @@
"noImplicitReturns": true,
"noUnusedLocals": false,
"resolveJsonModule": true,
"rootDir": ".",
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"target": "es2022",
"useDefineForClassFields": true,
"useUnknownInCatchVariables": false,
"ignoreDeprecations": "6.0",
"baseUrl": "..",
"paths": {
"@/*": [
"src/*"
"../src/*"
],
"@api/*": [
"src/core/api/*"
"../src/core/api/*"
],
"@core/*": [
"src/core/*"
"../src/core/*"
],
"@generated/*": [
"src/generated/*"
"../src/generated/*"
],
"@hosts/*": [
"src/hosts/*"
"../src/hosts/*"
],
"@integrations/*": [
"src/integrations/*"
"../src/integrations/*"
],
"@packages/*": [
"src/packages/*"
"../src/packages/*"
],
"@services/*": [
"src/services/*"
"../src/services/*"
],
"@shared/*": [
"src/shared/*"
"../src/shared/*"
],
"@utils/*": [
"src/utils/*"
"../src/utils/*"
]
},
"rootDir": "..",
"outDir": "dist"
},
"include": [
"src/**/*"
"src/**/*",
"esbuild.mts"
],
"exclude": [
"node_modules",
"dist"
"dist",
"*.tgz"
]
}
+10
View File
@@ -163,6 +163,7 @@
"version": "2.0.0",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.13.1",
"aws4fetch": "^1.0.20",
"chalk": "^5.3.0",
"commander": "^12.1.0",
@@ -207,6 +208,15 @@
"url": "https://dotenvx.com"
}
},
"node_modules/@agentclientprotocol/sdk": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.13.1.tgz",
"integrity": "sha512-6byvu+F/xc96GBkdAx4hq6/tB3vT63DSBO4i3gYCz8nuyZMerVFna2Gkhm8EHNpZX0J9DjUxzZCW+rnHXUg0FA==",
"license": "Apache-2.0",
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/@alcalzone/ansi-tokenize": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.3.tgz",
+1 -1
View File
@@ -66,7 +66,7 @@ export interface ToolUse {
/**
* The call / response ID this tool use is associated with.
*/
call_id?: string // optional call ID for tracking tool use calls
call_id: string // optional call ID for tracking tool use calls
/**
* Thought signature associated with this tool use, used by Gemini
*/
@@ -1,4 +1,5 @@
import { ClineDefaultTool, toolUseNames } from "@shared/tools"
import { nanoid } from "nanoid"
import { AssistantMessageContent, TextStreamContent, ToolParamName, ToolUse, toolParamNames } from "." // Assuming types are defined in index.ts or a similar file
// parseAssistantmessageV1 removed in https://github.com/cline/cline/pull/5425
@@ -175,6 +176,8 @@ export function parseAssistantMessageV2(assistantMessage: string): AssistantMess
name: toolName,
params: {},
partial: true, // Assume partial until closing tag is found
call_id: nanoid(8),
isNativeToolCall: false,
}
currentToolUseStart = currentCharIndex + 1 // Tool content starts after the opening tag
startedNewTool = true
+2
View File
@@ -39,6 +39,7 @@ import { BannerCardData } from "@/shared/cline/banner"
import { getAxiosSettings } from "@/shared/net"
import { ShowMessageType } from "@/shared/proto/host/window"
import { Logger } from "@/shared/services/Logger"
import { Session } from "@/shared/services/Session"
import { getLatestAnnouncementId } from "@/utils/announcements"
import { getCwd, getDesktopDir } from "@/utils/path"
import { PromptRegistry } from "../prompts/system-prompt"
@@ -118,6 +119,7 @@ export class Controller {
}
constructor(readonly context: vscode.ExtensionContext) {
Session.reset() // Reset session on controller initialization
PromptRegistry.getInstance() // Ensure prompts and tools are registered
this.stateManager = StateManager.get()
StateManager.get().registerCallbacks({
+18 -12
View File
@@ -1,5 +1,6 @@
import type { ToolUse } from "@core/assistant-message"
import { JSONParser } from "@streamparser/json"
import { nanoid } from "nanoid"
import { McpHub } from "@/services/mcp/McpHub"
import { CLINE_MCP_TOOL_IDENTIFIER } from "@/shared/mcp"
import {
@@ -8,6 +9,7 @@ import {
ClineAssistantToolUseBlock,
ClineReasoningDetailParam,
} from "@/shared/messages/content"
import { Session } from "@/shared/services/Session"
import { ClineDefaultTool } from "@/shared/tools"
export interface PendingToolUse {
@@ -17,7 +19,7 @@ export interface PendingToolUse {
parsedInput?: unknown
signature?: string
jsonParser?: JSONParser
call_id?: string
call_id: string
}
interface ToolUseDeltaBlock {
@@ -225,18 +227,8 @@ class ToolUseHandler {
this.pendingToolUses.clear()
}
private createPendingToolUse(id: string, name: string, call_id?: string): PendingToolUse {
private createPendingToolUse(id: string, name: string, callId?: string): PendingToolUse {
const jsonParser = new JSONParser()
const pending: PendingToolUse = {
id,
name,
input: "",
parsedInput: undefined,
jsonParser,
call_id,
signature: undefined,
}
jsonParser.onValue = (info: any) => {
if (info.stack.length === 0 && info.value && typeof info.value === "object") {
pending.parsedInput = info.value
@@ -245,7 +237,21 @@ class ToolUseHandler {
jsonParser.onError = () => {}
const pending: PendingToolUse = {
id,
name,
input: "",
parsedInput: undefined,
jsonParser,
// Ensure call_id is always set for tracking
call_id: callId || id || nanoid(8),
signature: undefined,
}
this.pendingToolUses.set(id, pending)
// Initialize tool call in session tracking
Session.get().updateToolCall(pending.call_id, pending.name)
return pending
}
+25 -11
View File
@@ -96,6 +96,7 @@ import {
import { ApiFormat } from "@/shared/proto/cline/models"
import { ShowMessageType } from "@/shared/proto/index.host"
import { Logger } from "@/shared/services/Logger"
import { Session } from "@/shared/services/Session"
import { isClineCliInstalled, isCliSubagentContext } from "@/utils/cli-detector"
import { RuleContextBuilder } from "../context/instructions/user-instructions/RuleContextBuilder"
import { ensureLocalClineDirExists } from "../context/instructions/user-instructions/rule-helpers"
@@ -739,10 +740,14 @@ export class Task {
if (partial) {
if (isUpdatingPreviousPartial) {
// existing partial message, so update it
lastMessage.text = text
lastMessage.images = images
lastMessage.files = files
lastMessage.partial = partial
const lastIndex = this.messageStateHandler.getClineMessages().length - 1
await this.messageStateHandler.updateClineMessage(lastIndex, {
text,
images,
files,
partial,
})
const protoMessage = convertClineMessageToProto(lastMessage)
await sendPartialMessageEvent(protoMessage)
return undefined
@@ -768,14 +773,15 @@ export class Task {
if (isUpdatingPreviousPartial) {
// this is the complete version of a previously partial message, so replace the partial with the complete version
this.taskState.lastMessageTs = lastMessage.ts
// lastMessage.ts = sayTs
lastMessage.text = text
lastMessage.images = images
lastMessage.files = files // Ensure files is updated
lastMessage.partial = false
const lastIndex = this.messageStateHandler.getClineMessages().length - 1
// updateClineMessage emits the change event and saves to disk
await this.messageStateHandler.updateClineMessage(lastIndex, {
text,
images,
files,
partial: false,
})
// instead of streaming partialMessage events, we do a save and post like normal to persist to disk
await this.messageStateHandler.saveClineMessagesAndUpdateHistory()
// await this.postStateToWebview()
const protoMessage = convertClineMessageToProto(lastMessage)
await sendPartialMessageEvent(protoMessage) // more performant than an entire postStateToWebview
@@ -2145,6 +2151,7 @@ export class Task {
}
}
await this.toolExecutor.executeTool(block)
Session.get().updateToolCall(block.call_id, block.name)
break
}
@@ -2487,6 +2494,8 @@ export class Task {
} = { cacheWriteTokens: 0, cacheReadTokens: 0, inputTokens: 0, outputTokens: 0, totalCost: undefined }
const abortStream = async (cancelReason: ClineApiReqCancelReason, streamingFailedMessage?: string) => {
Session.get().finalizeRequest()
if (this.diffViewProvider.isEditing) {
await this.diffViewProvider.revertChanges() // closes diff view
}
@@ -2587,6 +2596,9 @@ export class Task {
this.taskState.isStreaming = true
let didReceiveUsageChunk = false
// Track API call time for session statistics
Session.get().startApiCall()
try {
for await (const chunk of stream) {
switch (chunk.type) {
@@ -2756,6 +2768,8 @@ export class Task {
}
} finally {
this.taskState.isStreaming = false
// End API call tracking for session statistics
Session.get().endApiCall()
}
// Finalize any remaining tool calls at the end of the stream
+72 -1
View File
@@ -1,4 +1,5 @@
import CheckpointTracker from "@integrations/checkpoints/CheckpointTracker"
import { EventEmitter } from "events"
import getFolderSize from "get-folder-size"
import Mutex from "p-mutex"
import { findLastIndex } from "@/shared/array"
@@ -13,6 +14,28 @@ import { getCwd, getDesktopDir } from "@/utils/path"
import { ensureTaskDirectoryExists, saveApiConversationHistory, saveClineMessages } from "../storage/disk"
import { TaskState } from "./TaskState"
// Event types for clineMessages changes
export type ClineMessageChangeType = "add" | "update" | "delete" | "set"
export interface ClineMessageChange {
type: ClineMessageChangeType
/** The full array after the change */
messages: ClineMessage[]
/** The affected index (for add/update/delete) */
index?: number
/** The new/updated message (for add/update) */
message?: ClineMessage
/** The old message before change (for update/delete) */
previousMessage?: ClineMessage
/** The entire previous array (for set) */
previousMessages?: ClineMessage[]
}
// Strongly-typed event emitter interface
export interface MessageStateHandlerEvents {
clineMessagesChanged: [change: ClineMessageChange]
}
interface MessageStateHandlerParams {
taskId: string
ulid: string
@@ -22,7 +45,7 @@ interface MessageStateHandlerParams {
checkpointManagerErrorMessage?: string
}
export class MessageStateHandler {
export class MessageStateHandler extends EventEmitter<MessageStateHandlerEvents> {
private apiConversationHistory: ClineStorageMessage[] = []
private clineMessages: ClineMessage[] = []
private taskIsFavorited: boolean
@@ -39,6 +62,7 @@ export class MessageStateHandler {
private stateMutex = new Mutex()
constructor(params: MessageStateHandlerParams) {
super()
this.taskId = params.taskId
this.ulid = params.ulid
this.taskState = params.taskState
@@ -46,6 +70,13 @@ export class MessageStateHandler {
this.updateTaskHistory = params.updateTaskHistory
}
/**
* Emit a clineMessagesChanged event with the change details
*/
private emitClineMessagesChanged(change: ClineMessageChange): void {
this.emit("clineMessagesChanged", change)
}
setCheckpointTracker(tracker: CheckpointTracker | undefined) {
this.checkpointTracker = tracker
}
@@ -72,7 +103,13 @@ export class MessageStateHandler {
}
setClineMessages(newMessages: ClineMessage[]) {
const previousMessages = this.clineMessages
this.clineMessages = newMessages
this.emitClineMessagesChanged({
type: "set",
messages: this.clineMessages,
previousMessages,
})
}
/**
@@ -166,7 +203,14 @@ export class MessageStateHandler {
// it's important that apiConversationHistory is initialized before we add cline messages
message.conversationHistoryIndex = this.apiConversationHistory.length - 1 // NOTE: this is the index of the last added message which is the user message, and once the clinemessages have been presented we update the apiconversationhistory with the completed assistant message. This means when resetting to a message, we need to +1 this index to get the correct assistant message that this tool use corresponds to
message.conversationHistoryDeletedRange = this.taskState.conversationHistoryDeletedRange
const index = this.clineMessages.length
this.clineMessages.push(message)
this.emitClineMessagesChanged({
type: "add",
messages: this.clineMessages,
index,
message,
})
await this.saveClineMessagesAndUpdateHistoryInternal()
})
}
@@ -177,7 +221,13 @@ export class MessageStateHandler {
*/
async overwriteClineMessages(newMessages: ClineMessage[]) {
return await this.withStateLock(async () => {
const previousMessages = this.clineMessages
this.clineMessages = newMessages
this.emitClineMessagesChanged({
type: "set",
messages: this.clineMessages,
previousMessages,
})
await this.saveClineMessagesAndUpdateHistoryInternal()
})
}
@@ -192,9 +242,20 @@ export class MessageStateHandler {
throw new Error(`Invalid message index: ${index}`)
}
// Capture previous state before mutation
const previousMessage = { ...this.clineMessages[index] }
// Apply updates to the message
Object.assign(this.clineMessages[index], updates)
this.emitClineMessagesChanged({
type: "update",
messages: this.clineMessages,
index,
previousMessage,
message: this.clineMessages[index],
})
// Save changes and update history
await this.saveClineMessagesAndUpdateHistoryInternal()
})
@@ -210,9 +271,19 @@ export class MessageStateHandler {
throw new Error(`Invalid message index: ${index}`)
}
// Capture the message before deletion
const previousMessage = this.clineMessages[index]
// Remove the message at the specified index
this.clineMessages.splice(index, 1)
this.emitClineMessagesChanged({
type: "delete",
messages: this.clineMessages,
index,
previousMessage,
})
// Save changes and update history
await this.saveClineMessagesAndUpdateHistoryInternal()
})
+8 -3
View File
@@ -495,11 +495,16 @@ export class OpenAiCodexOAuthManager {
}
/**
* Check if the user is authenticated
* Check if the user has stored credentials (i.e. has completed auth).
* This intentionally does NOT attempt a token refresh so that transient
* network failures or expired-but-refreshable tokens don't cause the
* CLI to bounce the user back to the onboarding flow.
*/
async isAuthenticated(): Promise<boolean> {
const token = await this.getAccessToken()
return token !== null
if (!this.credentials) {
await this.loadCredentials()
}
return this.credentials !== null
}
/**
+27
View File
@@ -63,3 +63,30 @@ export function getApiMetrics(messages: ClineMessage[]): ApiMetrics {
return result
}
/**
* Gets the total token count from the last API request.
*
* This is used for context window progress display - it shows how much of the
* context window is used in the current/most recent request, not cumulative totals.
*
* @param messages - An array of ClineMessage objects to process.
* @returns The total tokens (tokensIn + tokensOut + cacheWrites + cacheReads) from the last api_req_started message, or 0 if none found.
*/
export function getLastApiReqTotalTokens(messages: ClineMessage[]): number {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]
if (msg.type === "say" && msg.say === "api_req_started" && msg.text) {
try {
const { tokensIn, tokensOut, cacheWrites, cacheReads } = JSON.parse(msg.text)
const total = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
if (total > 0) {
return total
}
} catch {
// Ignore JSON parse errors, continue searching
}
}
}
return 0
}
+265
View File
@@ -0,0 +1,265 @@
import { nanoid } from "nanoid"
export interface ToolCallRecord {
name: string
success?: boolean
startTime: number
lastUpdateTime: number
}
export interface ResourceUsage {
// Memory (in bytes)
heapUsed: number
heapTotal: number
external: number
rss: number // Resident Set Size - total memory allocated for the process
// CPU time (in milliseconds)
userCpuMs: number
systemCpuMs: number
}
export interface SessionStats {
sessionId: string
// Tool calls
totalToolCalls: number
successfulToolCalls: number
failedToolCalls: number
// Timing
sessionStartTime: number
apiTimeMs: number
toolTimeMs: number
// Resources
resources: ResourceUsage
peakMemoryBytes: number
}
/**
* Session singleton for tracking current session statistics.
* Used by CLI to display interaction summary.
*/
export class Session {
private static instance: Session | null = null
private sessionId: string
private sessionStartTime: number
private toolCalls: ToolCallRecord[] = []
private apiTimeMs: number = 0
private toolTimeMs: number = 0
// Track in-flight operations
private currentApiCallStart: number | null = null
private inFlightToolCalls: Map<string, ToolCallRecord> = new Map()
// Resource tracking
private initialCpuUsage: NodeJS.CpuUsage
private peakMemoryBytes: number = 0
private constructor() {
this.sessionId = nanoid(10)
this.sessionStartTime = Date.now()
this.initialCpuUsage = process.cpuUsage()
this.updatePeakMemory()
}
/**
* Update peak memory if current usage is higher.
*/
private updatePeakMemory(): void {
const memUsage = process.memoryUsage()
if (memUsage.rss > this.peakMemoryBytes) {
this.peakMemoryBytes = memUsage.rss
}
}
/**
* Get current resource usage for this process.
*/
getResourceUsage(): ResourceUsage {
this.updatePeakMemory()
const memUsage = process.memoryUsage()
const cpuUsage = process.cpuUsage(this.initialCpuUsage)
return {
heapUsed: memUsage.heapUsed,
heapTotal: memUsage.heapTotal,
external: memUsage.external,
rss: memUsage.rss,
// cpuUsage returns microseconds, convert to milliseconds
userCpuMs: cpuUsage.user / 1000,
systemCpuMs: cpuUsage.system / 1000,
}
}
/**
* Get the singleton instance, creating it if necessary.
*/
static get(): Session {
if (!Session.instance) {
Session.instance = new Session()
}
return Session.instance
}
/**
* Reset the session (creates a new session with fresh ID and stats).
*/
static reset(): Session {
Session.instance = new Session()
return Session.instance
}
/**
* Get the current session ID.
*/
getSessionId(): string {
return this.sessionId
}
/**
* Record the start of an API call.
*/
startApiCall(): void {
this.currentApiCallStart = Date.now()
}
/**
* Record the end of an API call.
*/
endApiCall(): void {
if (this.currentApiCallStart !== null) {
this.apiTimeMs += Date.now() - this.currentApiCallStart
this.currentApiCallStart = null
}
}
/**
* Update a tool call - starts tracking if new, updates lastUpdateTime if existing.
* @param callId - Unique identifier for this tool call
* @param toolName - The name of the tool (required when starting a new call)
* @param success - Optional success status (only set when finalizing)
*/
updateToolCall(callId: string, toolName: string, success?: boolean): void {
const now = Date.now()
const existing = this.inFlightToolCalls.get(callId)
if (existing) {
// Update existing tool call
existing.lastUpdateTime = now
if (success !== undefined) {
existing.success = success
}
return
}
// Start tracking new tool call
this.inFlightToolCalls.set(callId, {
name: toolName,
startTime: now,
lastUpdateTime: now,
})
}
/**
* Add API time directly (useful when timing is tracked elsewhere).
*/
addApiTime(ms: number): void {
this.apiTimeMs += ms
}
/**
* Finalize a request - moves all in-flight tool calls to completed and calculates durations.
* Call this when an API request completes to close out all pending tool calls.
*/
finalizeRequest(): void {
for (const [callId, record] of this.inFlightToolCalls) {
const duration = record.lastUpdateTime - record.startTime
this.toolTimeMs += duration
this.toolCalls.push({
name: record.name,
success: record.success,
startTime: record.startTime,
lastUpdateTime: record.lastUpdateTime,
})
this.inFlightToolCalls.delete(callId)
}
}
/**
* Get all session statistics.
* Includes in-flight tool calls in the totals using their lastUpdateTime as end time.
*/
getStats(): SessionStats {
this.finalizeRequest()
// Combine completed and in-flight for totals
const allToolCalls = this.toolCalls
const successful = allToolCalls.filter((t) => t.success === true).length
const failed = allToolCalls.filter((t) => t.success === false).length
return {
sessionId: this.sessionId,
totalToolCalls: allToolCalls.length,
successfulToolCalls: successful,
failedToolCalls: failed,
sessionStartTime: this.sessionStartTime,
apiTimeMs: this.apiTimeMs,
toolTimeMs: this.toolTimeMs,
resources: this.getResourceUsage(),
peakMemoryBytes: this.peakMemoryBytes,
}
}
/**
* Get the wall time (time since session started) in milliseconds.
*/
getWallTimeMs(): number {
return Date.now() - this.sessionStartTime
}
/**
* Get the session start time as a Date object.
*/
getStartTime(): Date {
return new Date(this.sessionStartTime)
}
/**
* Get the current time (session end time) as a Date object.
*/
getEndTime(): Date {
return new Date()
}
/**
* Format a timestamp for display (e.g., "2:34:56 PM").
*/
formatTime(date: Date): string {
return date.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
second: "2-digit",
hour12: true,
})
}
/**
* Get the agent active time (API time + tool time) in milliseconds.
* Includes in-flight tool calls.
*/
getAgentActiveTimeMs(): number {
const stats = this.getStats()
return this.apiTimeMs + stats.toolTimeMs
}
/**
* Get the success rate as a percentage (0-100).
* Includes in-flight tool calls.
*/
getSuccessRate(): number {
const stats = this.getStats()
if (stats.totalToolCalls === 0) {
return 0
}
return (stats.successfulToolCalls / stats.totalToolCalls) * 100
}
}
+12
View File
@@ -67,4 +67,16 @@ export const CLI_ONLY_COMMANDS: SlashCommand[] = [
section: "default",
cliCompatible: true,
},
{
name: "history",
description: "Browse and search task history",
section: "default",
cliCompatible: true,
},
{
name: "clear",
description: "Clear the current task and start fresh",
section: "default",
cliCompatible: true,
},
]
+26
View File
@@ -0,0 +1,26 @@
import type { ApiProvider } from "@shared/api"
/**
* Filters OpenRouter model IDs based on provider-specific rules.
* For Cline provider: excludes :free models (except Minimax and Devstral models)
* For OpenRouter/Vercel: excludes cline/ prefixed models
* @param modelIds Array of model IDs to filter
* @param provider The current API provider
* @returns Filtered array of model IDs
*/
export function filterOpenRouterModelIds(modelIds: string[], provider: ApiProvider): string[] {
if (provider === "cline") {
// For Cline provider: exclude :free models, but keep Minimax and Devstral models
return modelIds.filter((id) => {
// Keep all Minimax and devstral models regardless of :free suffix
if (id.toLowerCase().includes("minimax-m2") || id.toLowerCase().includes("devstral-2512")) {
return true
}
// Filter out other :free models
return !id.includes(":free")
})
}
// For OpenRouter and Vercel AI Gateway providers: exclude Cline-specific models
return modelIds.filter((id) => !id.startsWith("cline/"))
}
+2 -22
View File
@@ -1,10 +1,8 @@
import { findLast } from "@shared/array"
import { combineApiRequests } from "@shared/combineApiRequests"
import { combineCommandSequences } from "@shared/combineCommandSequences"
import { combineErrorRetryMessages } from "@shared/combineErrorRetryMessages"
import { combineHookSequences } from "@shared/combineHookSequences"
import type { ClineApiReqInfo, ClineMessage } from "@shared/ExtensionMessage"
import { getApiMetrics } from "@shared/getApiMetrics"
import { getApiMetrics, getLastApiReqTotalTokens } from "@shared/getApiMetrics"
import { BooleanRequest, StringRequest } from "@shared/proto/cline/common"
import { useCallback, useEffect, useMemo } from "react"
import { useMount } from "react-use"
@@ -70,25 +68,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
// has to be after api_req_finished are all reduced into api_req_started messages
const apiMetrics = useMemo(() => getApiMetrics(modifiedMessages), [modifiedMessages])
const lastApiReqTotalTokens = useMemo(() => {
const getTotalTokensFromApiReqMessage = (msg: ClineMessage) => {
if (!msg.text) {
return 0
}
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(msg.text)
return (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
}
const lastApiReqMessage = findLast(modifiedMessages, (msg) => {
if (msg.say !== "api_req_started") {
return false
}
return getTotalTokensFromApiReqMessage(msg) > 0
})
if (!lastApiReqMessage) {
return undefined
}
return getTotalTokensFromApiReqMessage(lastApiReqMessage)
}, [modifiedMessages])
const lastApiReqTotalTokens = useMemo(() => getLastApiReqTotalTokens(modifiedMessages) || undefined, [modifiedMessages])
// Use custom hooks for state management
const chatState = useChatState(messages)
@@ -811,30 +811,8 @@ export async function syncModeConfigurations(
await handleFieldsChange(updates)
}
/**
* Filters OpenRouter model IDs based on provider-specific rules.
* For Cline provider: excludes :free models (except Minimax models)
* For OpenRouter/Vercel: excludes cline/ prefixed models
* @param modelIds Array of model IDs to filter
* @param provider The current API provider
* @returns Filtered array of model IDs
*/
export function filterOpenRouterModelIds(modelIds: string[], provider: ApiProvider): string[] {
if (provider === "cline") {
// For Cline provider: exclude :free models, but keep Minimax models
return modelIds.filter((id) => {
// Keep all Minimax and devstral models regardless of :free suffix
if (id.toLowerCase().includes("minimax-m2") || id.toLowerCase().includes("devstral-2512")) {
return true
}
// Filter out other :free models
return !id.includes(":free")
})
}
// For OpenRouter and Vercel AI Gateway providers: exclude Cline-specific models
return modelIds.filter((id) => !id.startsWith("cline/"))
}
// Re-export from shared module for backwards compatibility
export { filterOpenRouterModelIds } from "@shared/utils/model-filters"
// Helper to get provider-specific configuration info and empty state guidance
export const getProviderInfo = (