mirror of
https://github.com/cline/cline.git
synced 2026-09-04 11:44:01 +08:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9485c05839 | |||
| 28152c6d06 | |||
| 7a97ce57ba | |||
| 70c602ebb7 | |||
| 023162611d | |||
| acb32ef95e | |||
| e94c4727a2 | |||
| 54d136df5d | |||
| a4131e57d8 | |||
| bef543c37c | |||
| 484a98a8a5 | |||
| 683dd9a741 | |||
| 0d437f71b0 | |||
| fc77c0faea | |||
| 12c85c4233 | |||
| d6b7a1ab41 | |||
| 3926e7b404 | |||
| 5ba4314b9a | |||
| 9ccad7f764 | |||
| 3562f54dbf | |||
| d427d5d76a | |||
| 072e2887b0 | |||
| 9c71a6f021 | |||
| 7627a382aa | |||
| 978c633c90 | |||
| e807b520e0 | |||
| f3a3f30db5 | |||
| f1c7934064 | |||
| 1d42da5248 | |||
| 7f3974a827 | |||
| d40ab56aff | |||
| bbdf445db7 | |||
| d992a3bf21 | |||
| ace95988f8 | |||
| 54726f1677 | |||
| 6308fef0a9 | |||
| b3fc79b8ce | |||
| ff05ec3bbe |
@@ -1,19 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
echo "PostToolUse running inside local cline/.clinerules/hooks/ directory"
|
||||
|
||||
input=$(cat)
|
||||
echo $input | jq .
|
||||
|
||||
for i in {1..5}; do
|
||||
sleep 1
|
||||
echo "$i"
|
||||
done
|
||||
|
||||
cat <<EOF
|
||||
{
|
||||
"cancel": false,
|
||||
"contextModification": "PostToolUse response from the local cline/.clinerules/hooks/ directory.",
|
||||
"errorMessage": "PostToolUse hook custom errorMessage"
|
||||
}
|
||||
EOF
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
echo "PreToolUse running inside local cline/.clinerules/hooks/ directory"
|
||||
|
||||
input=$(cat)
|
||||
echo $input | jq .
|
||||
|
||||
for i in {1..5}; do
|
||||
sleep 1
|
||||
echo "$i"
|
||||
done
|
||||
|
||||
cat <<EOF
|
||||
{
|
||||
"cancel": false,
|
||||
"contextModification": "PreToolUse response from the local cline/.clinerules/hooks/ directory.",
|
||||
"errorMessage": "PreToolUse hook custom errorMessage"
|
||||
}
|
||||
EOF
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
echo "TaskCancel running inside local cline/.clinerules/hooks/ directory"
|
||||
|
||||
input=$(cat)
|
||||
echo $input | jq .
|
||||
|
||||
for i in {1..5}; do
|
||||
sleep 1
|
||||
echo "$i"
|
||||
done
|
||||
|
||||
cat <<EOF
|
||||
{
|
||||
"cancel": false,
|
||||
"contextModification": "TaskCancel response from the local cline/.clinerules/hooks/ directory.",
|
||||
"errorMessage": "TaskCancel hook custom errorMessage"
|
||||
}
|
||||
EOF
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
echo "TaskResume running inside local cline/.clinerules/hooks/ directory"
|
||||
|
||||
input=$(cat)
|
||||
echo $input | jq .
|
||||
|
||||
for i in {1..5}; do
|
||||
sleep 1
|
||||
echo "$i"
|
||||
done
|
||||
|
||||
cat <<EOF
|
||||
{
|
||||
"cancel": false,
|
||||
"contextModification": "TaskResume response from the local cline/.clinerules/hooks/ directory.",
|
||||
"errorMessage": "TaskResume hook custom errorMessage"
|
||||
}
|
||||
EOF
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
echo "TaskStart running inside local cline/.clinerules/hooks/ directory"
|
||||
|
||||
input=$(cat)
|
||||
echo $input | jq .
|
||||
|
||||
for i in {1..5}; do
|
||||
sleep 1
|
||||
echo "$i"
|
||||
done
|
||||
|
||||
cat <<EOF
|
||||
{
|
||||
"cancel": false,
|
||||
"contextModification": "TaskStart response from the local cline/.clinerules/hooks/ directory.",
|
||||
"errorMessage": "TaskStart hook custom errorMessage"
|
||||
}
|
||||
EOF
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
echo "UserPromptSubmit running inside local cline/.clinerules/hooks/ directory"
|
||||
|
||||
input=$(cat)
|
||||
echo $input | jq .
|
||||
|
||||
for i in {1..5}; do
|
||||
sleep 1
|
||||
echo "$i"
|
||||
done
|
||||
|
||||
cat <<EOF
|
||||
{
|
||||
"cancel": false,
|
||||
"contextModification": "UserPromptSubmit response from the local cline/.clinerules/hooks/ directory.",
|
||||
"errorMessage": "UserPromptSubmit hook custom errorMessage"
|
||||
}
|
||||
EOF
|
||||
@@ -1,4 +1,5 @@
|
||||
demo.gif filter=lfs diff=lfs merge=lfs -text
|
||||
assets/docs/demo.gif filter=lfs diff=lfs merge=lfs -text
|
||||
webview-ui/src/assets/cline_kanban_demo.mp4 filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
* text=auto eol=lf
|
||||
|
||||
@@ -24,6 +24,8 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: true
|
||||
|
||||
- name: Check for recent commits
|
||||
run: |
|
||||
@@ -49,6 +51,14 @@ jobs:
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
|
||||
- name: Verify LFS media assets are resolved
|
||||
run: |
|
||||
FILE="webview-ui/src/assets/cline_kanban_demo.mp4"
|
||||
if grep -q "git-lfs.github.com/spec/v1" "$FILE"; then
|
||||
echo "Error: $FILE is still a Git LFS pointer in CI checkout"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Publish Extension as Pre-release
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
|
||||
@@ -43,6 +43,7 @@ jobs:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
lfs: true
|
||||
|
||||
- name: Resolve Release Tag
|
||||
id: resolve_tag
|
||||
@@ -133,6 +134,14 @@ jobs:
|
||||
fi
|
||||
echo "Tag and package version match: $TAG"
|
||||
|
||||
- name: Verify LFS media assets are resolved
|
||||
run: |
|
||||
FILE="webview-ui/src/assets/cline_kanban_demo.mp4"
|
||||
if grep -q "git-lfs.github.com/spec/v1" "$FILE"; then
|
||||
echo "Error: $FILE is still a Git LFS pointer in CI checkout"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Package and Publish Extension
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# Changelog
|
||||
|
||||
## [3.75.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Latency improvements for remote workspaces
|
||||
|
||||
### Fixed
|
||||
|
||||
- Stabilize flaky hooks tests
|
||||
|
||||
### Changed
|
||||
|
||||
- Remove example hooks in favor of reading the docs
|
||||
|
||||
## [3.74.0]
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# cline
|
||||
|
||||
## [2.9.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Latency improvements for remote workspaces
|
||||
|
||||
## [2.8.2]
|
||||
|
||||
### Fixed
|
||||
- Use `kanban@latest` in `cline kanban` to always fetch the newest version
|
||||
|
||||
## [2.8.1]
|
||||
|
||||
### Added
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cline",
|
||||
"version": "2.8.1",
|
||||
"version": "2.11.0",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"main": "dist/lib.mjs",
|
||||
"types": "dist/lib.d.ts",
|
||||
|
||||
@@ -1492,13 +1492,12 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
if (item.type === "header") {
|
||||
// Show static robot frame in header (first frame, looking straight ahead)
|
||||
return (
|
||||
<Box flexDirection="column" key="header">
|
||||
<Box flexDirection="column" key="header" marginBottom={1}>
|
||||
<StaticRobotFrame />
|
||||
<Text> </Text>
|
||||
<Text bold color="white">
|
||||
{centerText("What can I do for you?")}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -56,7 +56,13 @@ export interface ObjectEditorState {
|
||||
editValue: string
|
||||
}
|
||||
|
||||
export const EXCLUDED_KEYS = new Set(["taskHistory", "primaryRootIndex", "welcomeViewCompleted", "isNewUser"])
|
||||
export const EXCLUDED_KEYS = new Set([
|
||||
"taskHistory",
|
||||
"primaryRootIndex",
|
||||
"welcomeViewCompleted",
|
||||
"isNewUser",
|
||||
"cliKanbanMigrationAnnouncementShown",
|
||||
])
|
||||
|
||||
export const EDITABLE_TYPES: Set<ValueType> = new Set(["string", "number", "boolean", "object"])
|
||||
export const MAX_VISIBLE = 12
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Rotating feature tips shown during thinking/acting phases.
|
||||
* Appears after a brief delay and cycles through tips to educate users
|
||||
* about Cline features while they wait.
|
||||
*/
|
||||
|
||||
import { Box, Text } from "ink"
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react"
|
||||
|
||||
interface FeatureTipItem {
|
||||
text: string
|
||||
}
|
||||
|
||||
const FEATURE_TIPS: FeatureTipItem[] = [
|
||||
{
|
||||
text: 'Enable "Double-Check Completion" in settings to have Cline verify its work before finishing a task.',
|
||||
},
|
||||
{
|
||||
text: "Add a .clinerules file to your project root to give Cline project-specific instructions.",
|
||||
},
|
||||
{
|
||||
text: "Press Tab to switch between Plan and Act mode — plan an approach before Cline takes action.",
|
||||
},
|
||||
{
|
||||
text: "Use @ in the chat input to add files, folders, or URLs as context for your task.",
|
||||
},
|
||||
{
|
||||
text: "Set up MCP Servers to give Cline access to external tools and APIs.",
|
||||
},
|
||||
{
|
||||
text: "Cline creates checkpoints after changes — you can always restore to a previous state.",
|
||||
},
|
||||
{
|
||||
text: "Use /compact to condense long conversations and free up context window space.",
|
||||
},
|
||||
{
|
||||
text: "Enable auto-approve for read-only tools like file reads to speed up exploration.",
|
||||
},
|
||||
{
|
||||
text: "Use /settings to configure your API provider and model without leaving the terminal.",
|
||||
},
|
||||
{
|
||||
text: "You can pass images with --images flag or paste image file paths in the chat.",
|
||||
},
|
||||
{
|
||||
text: "Cline can browse websites — ask it to test your local dev server in the browser.",
|
||||
},
|
||||
{
|
||||
text: "Use /reportbug to quickly file a GitHub issue with diagnostic context included.",
|
||||
},
|
||||
{
|
||||
text: "Try 'npm i -g cline' to manage tasks on a Kankan board — orchestrate coding agents across worktrees.",
|
||||
},
|
||||
{
|
||||
text: "Use Shift+Tab to toggle auto-approve all — let Cline work uninterrupted on trusted tasks.",
|
||||
},
|
||||
{
|
||||
text: "Press Up/Down arrows in an empty input to browse your previous task prompts.",
|
||||
},
|
||||
{
|
||||
text: "Type / to see all available commands — /history, /compact, /settings, and more.",
|
||||
},
|
||||
{
|
||||
text: "Use /skills to browse and attach reusable skill files that guide Cline's behavior.",
|
||||
},
|
||||
{
|
||||
text: 'You can disable these tips in /settings → Features → "Feature tips".',
|
||||
},
|
||||
]
|
||||
|
||||
const SHOW_DELAY_MS = 2000
|
||||
const CYCLE_INTERVAL_MS = 8000
|
||||
|
||||
/**
|
||||
* Shows rotating feature tips below the thinking indicator.
|
||||
* Appears after a brief delay and cycles through tips while Cline is thinking/acting.
|
||||
*/
|
||||
export const FeatureTip: React.FC = React.memo(() => {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const [tipIndex, setTipIndex] = useState(Math.floor(Math.random() * FEATURE_TIPS.length))
|
||||
const cycleTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const showTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const currentTip = FEATURE_TIPS[tipIndex]
|
||||
|
||||
const advanceTip = useCallback(() => {
|
||||
setTipIndex((prev) => (prev + 1) % FEATURE_TIPS.length)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
showTimerRef.current = setTimeout(() => {
|
||||
setIsVisible(true)
|
||||
cycleTimerRef.current = setInterval(advanceTip, CYCLE_INTERVAL_MS)
|
||||
}, SHOW_DELAY_MS)
|
||||
|
||||
return () => {
|
||||
if (showTimerRef.current) {
|
||||
clearTimeout(showTimerRef.current)
|
||||
}
|
||||
if (cycleTimerRef.current) {
|
||||
clearInterval(cycleTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [advanceTip])
|
||||
|
||||
if (!isVisible) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Box paddingLeft={1}>
|
||||
<Text color="gray">
|
||||
💡 <Text bold>Tip:</Text> {currentTip.text}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { render } from "ink-testing-library"
|
||||
import { createElement } from "react"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { KanbanMigrationView } from "./KanbanMigrationView"
|
||||
|
||||
describe("KanbanMigrationView", () => {
|
||||
it("renders the migration options", () => {
|
||||
const onSelect = vi.fn()
|
||||
const { lastFrame } = render(createElement(KanbanMigrationView, { isRawModeSupported: true, onSelect }))
|
||||
|
||||
expect(lastFrame()).toContain("Cline is moving out of the terminal. Introducing Cline Kanban.")
|
||||
expect(lastFrame()).toContain("Open the new experience")
|
||||
expect(lastFrame()).toContain("Launch Cline Kanban and start there by default.")
|
||||
expect(lastFrame()).toContain("cline --tui")
|
||||
expect(lastFrame()).toContain("Close and rerun with cline --tui if you want the old CLI.")
|
||||
expect(lastFrame()).toContain("Exit")
|
||||
})
|
||||
|
||||
it("selects the highlighted option with Enter", () => {
|
||||
const onSelect = vi.fn()
|
||||
const { stdin } = render(createElement(KanbanMigrationView, { isRawModeSupported: true, onSelect }))
|
||||
|
||||
stdin.write("\r")
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith("kanban")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Box, Text, useApp, useInput } from "ink"
|
||||
import React, { useMemo, useState } from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { StdinProvider, useStdinContext } from "../context/StdinContext"
|
||||
import { isEnterKey } from "../utils/input"
|
||||
import { type KanbanMigrationAction } from "../utils/kanban"
|
||||
import { StaticRobotFrame } from "./AsciiMotionCli"
|
||||
import { ErrorBoundary } from "./ErrorBoundary"
|
||||
|
||||
interface KanbanMigrationViewProps {
|
||||
isRawModeSupported: boolean
|
||||
onSelect: (action: KanbanMigrationAction) => void
|
||||
}
|
||||
|
||||
interface MigrationMenuItem {
|
||||
label: string
|
||||
description: string
|
||||
value: KanbanMigrationAction
|
||||
}
|
||||
|
||||
const InternalKanbanMigrationView: React.FC<Pick<KanbanMigrationViewProps, "onSelect">> = ({ onSelect }) => {
|
||||
const { exit } = useApp()
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const items = useMemo<MigrationMenuItem[]>(
|
||||
() => [
|
||||
{
|
||||
label: "Open the new experience",
|
||||
description: "Launch Cline Kanban and start there by default.",
|
||||
value: "kanban",
|
||||
},
|
||||
{
|
||||
label: "Exit",
|
||||
description: "Close and rerun with cline --tui if you want the old CLI.",
|
||||
value: "exit",
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (key.escape) {
|
||||
onSelect("exit")
|
||||
exit()
|
||||
} else if (key.upArrow) {
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1))
|
||||
} else if (key.downArrow) {
|
||||
setSelectedIndex((prev) => (prev < items.length - 1 ? prev + 1 : 0))
|
||||
} else if (isEnterKey(input, key)) {
|
||||
onSelect(items[selectedIndex].value)
|
||||
exit()
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported },
|
||||
)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width="100%">
|
||||
<StaticRobotFrame />
|
||||
<Text> </Text>
|
||||
<Text bold color="white">
|
||||
Cline is moving out of the terminal. Introducing Cline Kanban.
|
||||
</Text>
|
||||
<Text color="gray">A board for orchestrating coding agents across worktrees, right from your browser.</Text>
|
||||
<Text> </Text>
|
||||
{items.map((item, index) => {
|
||||
const isSelected = index === selectedIndex
|
||||
return (
|
||||
<Box flexDirection="column" key={item.value} marginBottom={1}>
|
||||
<Text color={isSelected ? COLORS.primaryBlue : undefined}>
|
||||
{isSelected ? "❯ " : " "}
|
||||
{item.label}
|
||||
</Text>
|
||||
<Text color="gray"> {item.description}</Text>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
<Text> </Text>
|
||||
<Text color="gray">Use arrow keys to navigate, Enter to select, Esc or Ctrl+C to exit</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export const KanbanMigrationView: React.FC<KanbanMigrationViewProps> = ({ isRawModeSupported, onSelect }) => {
|
||||
const { exit } = useApp()
|
||||
|
||||
return (
|
||||
<ErrorBoundary exit={exit}>
|
||||
<StdinProvider isRawModeSupported={isRawModeSupported}>
|
||||
<InternalKanbanMigrationView onSelect={onSelect} />
|
||||
</StdinProvider>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
@@ -129,6 +129,12 @@ const FEATURE_SETTINGS = {
|
||||
label: "Double-check completion",
|
||||
description: "Reject first completion attempt and require re-verification",
|
||||
},
|
||||
showFeatureTips: {
|
||||
stateKey: "showFeatureTips",
|
||||
default: true,
|
||||
label: "Feature tips",
|
||||
description: "Show tips during thinking phases",
|
||||
},
|
||||
} as const
|
||||
|
||||
type FeatureKey = keyof typeof FEATURE_SETTINGS
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import React, { useEffect, useMemo, useState } from "react"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { FeatureTip } from "./FeatureTip"
|
||||
|
||||
interface ThinkingIndicatorProps {
|
||||
mode?: "act" | "plan"
|
||||
@@ -52,6 +54,7 @@ const ShimmerText: React.FC<{ text: string; color: string; shimmerPos: number }>
|
||||
}
|
||||
|
||||
export const ThinkingIndicator: React.FC<ThinkingIndicatorProps> = ({ mode = "act", startTime, onCancel }) => {
|
||||
const showFeatureTips = StateManager.get().getGlobalSettingsKey("showFeatureTips") ?? true
|
||||
const message = mode === "plan" ? "Planning" : "Acting"
|
||||
const color = mode === "plan" ? "yellow" : COLORS.primaryBlue
|
||||
|
||||
@@ -118,9 +121,12 @@ export const ThinkingIndicator: React.FC<ThinkingIndicatorProps> = ({ mode = "ac
|
||||
}, [startTime, elapsedMs])
|
||||
|
||||
return (
|
||||
<Box paddingLeft={1}>
|
||||
<ShimmerText color={color} shimmerPos={shimmerPos} text={fullText} />
|
||||
{elapsedStr && <Text color="gray"> ({elapsedStr} · esc to interrupt)</Text>}
|
||||
<Box flexDirection="column">
|
||||
<Box paddingLeft={1}>
|
||||
<ShimmerText color={color} shimmerPos={shimmerPos} text={fullText} />
|
||||
{elapsedStr && <Text color="gray"> ({elapsedStr} · esc to interrupt)</Text>}
|
||||
</Box>
|
||||
{showFeatureTips && <FeatureTip />}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,333 +0,0 @@
|
||||
/**
|
||||
* Welcome view component
|
||||
* Shows an interactive prompt when user starts cline without a command
|
||||
* Supports file mentions with @
|
||||
*/
|
||||
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import type { ApiProvider } from "@/shared/api"
|
||||
import { getProviderDefaultModelId, getProviderModelIdKey, Mode, SettingsKey } from "@/shared/storage"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import {
|
||||
checkAndWarnRipgrepMissing,
|
||||
extractMentionQuery,
|
||||
type FileSearchResult,
|
||||
getRipgrepInstallInstructions,
|
||||
insertMention,
|
||||
searchWorkspaceFiles,
|
||||
} from "../utils/file-search"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
import { parseImagesFromInput } from "../utils/parser"
|
||||
import { AccountInfoView } from "./AccountInfoView"
|
||||
import { FileMentionMenu } from "./FileMentionMenu"
|
||||
|
||||
interface WelcomeViewProps {
|
||||
onSubmit: (prompt: string, imagePaths: string[]) => void
|
||||
onExit?: () => void
|
||||
controller?: any
|
||||
}
|
||||
|
||||
// ASCII art Cline logo
|
||||
const CLINE_LOGO = [
|
||||
" ::::::: ",
|
||||
" ::::::::: ",
|
||||
" ::::::::::::::::: ",
|
||||
" ::::::::::::::::::::::: ",
|
||||
" ::::::::::::::::::::::::: ",
|
||||
" ::::::::::::::::::::::::::: ",
|
||||
" ::::::: ::::::: ::::::: ",
|
||||
" ::::::: ::::: ::::::: ",
|
||||
":::::::: ::::: ::::::::",
|
||||
":::::::: ::::: ::::::::",
|
||||
" ::::::: ::::: ::::::: ",
|
||||
" ::::::: ::::::: ::::::: ",
|
||||
" ::::::::::::::::::::::::::: ",
|
||||
" ::::::::::::::::::::::::: ",
|
||||
" ::::::::::::::::::::::: ",
|
||||
" :::::::::::::::: ",
|
||||
]
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 150
|
||||
const RIPGREP_WARNING_DURATION_MS = 5000
|
||||
const MAX_SEARCH_RESULTS = 15
|
||||
|
||||
export const WelcomeView: React.FC<WelcomeViewProps> = ({ onSubmit, onExit, controller }) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const [textInput, setTextInput] = useState("")
|
||||
const [fileResults, setFileResults] = useState<FileSearchResult[]>([])
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [showRipgrepWarning, setShowRipgrepWarning] = useState(false)
|
||||
const [escPressedOnce, setEscPressedOnce] = useState(false)
|
||||
const [mode, setMode] = useState<Mode>(() => {
|
||||
const stateManager = StateManager.get()
|
||||
return stateManager.getGlobalSettingsKey("mode") || "act"
|
||||
})
|
||||
|
||||
const provider = useMemo(() => {
|
||||
const stateManager = StateManager.get()
|
||||
const mode = stateManager.getGlobalSettingsKey("mode") as string
|
||||
const providerKey = mode === "act" ? "actModeApiProvider" : "planModeApiProvider"
|
||||
const currentProvider = stateManager.getGlobalSettingsKey(providerKey) as string
|
||||
return currentProvider || "cline"
|
||||
}, [controller])
|
||||
|
||||
// Get model ID based on current mode and provider
|
||||
// Different providers use different state keys (e.g., cline uses actModeOpenRouterModelId)
|
||||
const modelId = useMemo(() => {
|
||||
const stateManager = StateManager.get()
|
||||
const modelKey = getProviderModelIdKey(provider as ApiProvider, mode)
|
||||
return (
|
||||
(stateManager.getGlobalSettingsKey(modelKey as SettingsKey) as string) ||
|
||||
getProviderDefaultModelId(provider as ApiProvider)
|
||||
)
|
||||
}, [mode, provider])
|
||||
|
||||
const toggleMode = useCallback(() => {
|
||||
const newMode: Mode = mode === "act" ? "plan" : "act"
|
||||
setMode(newMode)
|
||||
const stateManager = StateManager.get()
|
||||
stateManager.setGlobalState("mode", newMode)
|
||||
}, [mode])
|
||||
|
||||
const refs = useRef({
|
||||
searchTimeout: null as NodeJS.Timeout | null,
|
||||
lastQuery: "",
|
||||
hasCheckedRipgrep: false,
|
||||
})
|
||||
|
||||
const { prompt, imagePaths } = parseImagesFromInput(textInput)
|
||||
|
||||
const mentionInfo = useMemo(() => extractMentionQuery(textInput), [textInput])
|
||||
|
||||
const workspacePath = useMemo(() => {
|
||||
try {
|
||||
const root = controller?.getWorkspaceManagerSync?.()?.getPrimaryRoot?.()
|
||||
if (root?.path) {
|
||||
return root.path
|
||||
}
|
||||
} catch {
|
||||
// Fallback to cwd
|
||||
}
|
||||
return process.cwd()
|
||||
}, [controller])
|
||||
|
||||
// Search for files when in mention mode
|
||||
useEffect(() => {
|
||||
const { current: r } = refs
|
||||
|
||||
if (!mentionInfo.inMentionMode) {
|
||||
setFileResults([])
|
||||
setSelectedIndex(0)
|
||||
if (r.searchTimeout) {
|
||||
clearTimeout(r.searchTimeout)
|
||||
r.searchTimeout = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check for ripgrep on first mention trigger
|
||||
if (!r.hasCheckedRipgrep) {
|
||||
r.hasCheckedRipgrep = true
|
||||
if (checkAndWarnRipgrepMissing()) {
|
||||
setShowRipgrepWarning(true)
|
||||
setTimeout(() => setShowRipgrepWarning(false), RIPGREP_WARNING_DURATION_MS)
|
||||
}
|
||||
}
|
||||
|
||||
const { query } = mentionInfo
|
||||
if (query === r.lastQuery) {
|
||||
return
|
||||
}
|
||||
r.lastQuery = query
|
||||
|
||||
if (r.searchTimeout) {
|
||||
clearTimeout(r.searchTimeout)
|
||||
}
|
||||
setIsSearching(true)
|
||||
|
||||
r.searchTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const results = await searchWorkspaceFiles(query, workspacePath, MAX_SEARCH_RESULTS)
|
||||
setFileResults(results)
|
||||
setSelectedIndex(0)
|
||||
} catch {
|
||||
setFileResults([])
|
||||
} finally {
|
||||
setIsSearching(false)
|
||||
}
|
||||
}, SEARCH_DEBOUNCE_MS)
|
||||
|
||||
return () => {
|
||||
if (r.searchTimeout) {
|
||||
clearTimeout(r.searchTimeout)
|
||||
}
|
||||
}
|
||||
}, [mentionInfo.inMentionMode, mentionInfo.query, workspacePath])
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
// Filter out mouse escape sequences
|
||||
if (isMouseEscapeSequence(input)) {
|
||||
return
|
||||
}
|
||||
|
||||
const inMenu = mentionInfo.inMentionMode && fileResults.length > 0
|
||||
|
||||
// Menu navigation
|
||||
if (inMenu) {
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex((i) => (i > 0 ? i - 1 : fileResults.length - 1))
|
||||
return
|
||||
}
|
||||
if (key.downArrow) {
|
||||
setSelectedIndex((i) => (i < fileResults.length - 1 ? i + 1 : 0))
|
||||
return
|
||||
}
|
||||
if (key.tab || key.return) {
|
||||
const file = fileResults[selectedIndex]
|
||||
if (file) {
|
||||
setTextInput(insertMention(textInput, mentionInfo.atIndex, file.path))
|
||||
setFileResults([])
|
||||
setSelectedIndex(0)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (key.escape) {
|
||||
setFileResults([])
|
||||
setSelectedIndex(0)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Normal input handling
|
||||
if (key.tab && !mentionInfo.inMentionMode) {
|
||||
toggleMode()
|
||||
return
|
||||
}
|
||||
if (key.return && !mentionInfo.inMentionMode) {
|
||||
if (prompt.trim() || imagePaths.length > 0) {
|
||||
onSubmit(prompt.trim(), imagePaths)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (key.escape && !mentionInfo.inMentionMode) {
|
||||
if (escPressedOnce) {
|
||||
onExit?.()
|
||||
} else {
|
||||
setEscPressedOnce(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (key.backspace || key.delete) {
|
||||
setTextInput((prev) => prev.slice(0, -1))
|
||||
setEscPressedOnce(false)
|
||||
return
|
||||
}
|
||||
if (input && !key.ctrl && !key.meta && !key.upArrow && !key.downArrow && !key.tab) {
|
||||
setTextInput((prev) => prev + input)
|
||||
setEscPressedOnce(false)
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported },
|
||||
)
|
||||
|
||||
const borderColor = mode === "act" ? "blue" : "yellow"
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width="100%">
|
||||
{/* Account/Provider info at top */}
|
||||
{controller && (
|
||||
<Box marginBottom={1}>
|
||||
<AccountInfoView controller={controller} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Cline logo - centered */}
|
||||
<Box alignItems="center" flexDirection="column">
|
||||
{CLINE_LOGO.map((line, idx) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: static array that never changes
|
||||
<Text color="white" key={idx}>
|
||||
{line}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Main prompt - centered, bold */}
|
||||
<Box justifyContent="center" marginTop={1}>
|
||||
<Text bold color="white">
|
||||
What can I do for you?
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Ripgrep warning if needed */}
|
||||
{showRipgrepWarning && (
|
||||
<Box marginTop={1}>
|
||||
<Text color="yellow">⚠ ripgrep not found - file search will be slower. </Text>
|
||||
<Text color="gray">Install: {getRipgrepInstallInstructions()}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Input field with border */}
|
||||
<Box
|
||||
borderColor={borderColor}
|
||||
borderStyle="round"
|
||||
flexDirection="row"
|
||||
marginTop={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
width="100%">
|
||||
<Text>{textInput}</Text>
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
|
||||
{/* Model ID and Mode toggle row */}
|
||||
<Box justifyContent="space-between" width="100%">
|
||||
{/* Model ID on left */}
|
||||
<Text color="gray">{modelId}</Text>
|
||||
|
||||
{/* Mode toggle on right */}
|
||||
<Box gap={1}>
|
||||
<Box>
|
||||
<Text bold={mode === "plan"} color={mode === "plan" ? "yellow" : "gray"}>
|
||||
{mode === "plan" ? "●" : "○"} Plan
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text bold={mode === "act"} color={mode === "act" ? "blue" : "gray"}>
|
||||
{mode === "act" ? "●" : "○"} Act
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color="gray">(Tab)</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* File mention menu - below input */}
|
||||
{mentionInfo.inMentionMode && (
|
||||
<FileMentionMenu
|
||||
isLoading={isSearching}
|
||||
query={mentionInfo.query}
|
||||
results={fileResults}
|
||||
selectedIndex={selectedIndex}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Attached images */}
|
||||
{imagePaths.length > 0 && (
|
||||
<Text color="magenta">
|
||||
📎 {imagePaths.length} image{imagePaths.length > 1 ? "s" : ""} attached
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Help text */}
|
||||
<Box>
|
||||
<Text color="gray">Enter to submit · @ to mention files · </Text>
|
||||
<Text bold={escPressedOnce} color={escPressedOnce ? "white" : "gray"}>
|
||||
{escPressedOnce ? "Press Esc again to exit" : "Esc to exit"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -79,7 +79,7 @@ export class CliDiffServiceClient implements DiffServiceClientInterface {
|
||||
* CLI implementation of EnvService - handles environment operations
|
||||
*/
|
||||
export class CliEnvServiceClient implements EnvServiceClientInterface {
|
||||
private clipboardContent: string = ""
|
||||
private clipboardContent = ""
|
||||
|
||||
private getTelemetrySetting(): proto.host.Setting {
|
||||
// Read from StateManager - defaults to ENABLED if not set or "unset"
|
||||
@@ -102,6 +102,8 @@ export class CliEnvServiceClient implements EnvServiceClientInterface {
|
||||
version: CLI_VERSION,
|
||||
platform: "Cline CLI - Node.js",
|
||||
clineType: ClineClient.Cli,
|
||||
// remoteName is intentionally omitted — the CLI runs locally on the user's machine.
|
||||
// If CLI-in-container scenarios arise, populate this field to enable remote cadence tuning.
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+56
-36
@@ -11,6 +11,22 @@ import { captureUnhandledException } from "."
|
||||
describe("CLI Commands", () => {
|
||||
let program: Command
|
||||
|
||||
function getCommand(name: string): Command {
|
||||
const command = program.commands.find((candidate) => candidate.name() === name)
|
||||
if (!command) {
|
||||
throw new Error(`Missing command: ${name}`)
|
||||
}
|
||||
return command
|
||||
}
|
||||
|
||||
function getSubcommand(commandName: string, subcommandName: string): Command {
|
||||
const subcommand = getCommand(commandName).commands.find((candidate) => candidate.name() === subcommandName)
|
||||
if (!subcommand) {
|
||||
throw new Error(`Missing subcommand: ${commandName} ${subcommandName}`)
|
||||
}
|
||||
return subcommand
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a fresh program instance for each test
|
||||
program = new Command()
|
||||
@@ -80,7 +96,7 @@ describe("CLI Commands", () => {
|
||||
|
||||
program
|
||||
.command("kanban")
|
||||
.description("Run npx kanban --agent cline")
|
||||
.description("Run kanban")
|
||||
.action(() => {})
|
||||
|
||||
// Default command for interactive mode
|
||||
@@ -97,7 +113,8 @@ describe("CLI Commands", () => {
|
||||
.option("--auto-condense", "Enable AI-powered context compaction instead of mechanical truncation")
|
||||
.option("--hooks-dir <path>", "Additional hooks directory")
|
||||
.option("--auto-approve-all", "Enable auto-approve all")
|
||||
.option("--kanban", "Run npx kanban --agent cline")
|
||||
.option("--kanban", "Run kanban")
|
||||
.option("--tui", "Open the legacy terminal UI instead of the kanban experience")
|
||||
.action(() => {})
|
||||
})
|
||||
|
||||
@@ -114,119 +131,119 @@ describe("CLI Commands", () => {
|
||||
})
|
||||
|
||||
it("should parse --act flag", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--act"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().act).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse --plan flag", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--plan"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().plan).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse --yolo flag", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--yolo"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().yolo).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse --auto-approve-all flag", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--auto-approve-all"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().autoApproveAll).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse --model option", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--model", "claude-sonnet-4-20250514"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().model).toBe("claude-sonnet-4-20250514")
|
||||
})
|
||||
|
||||
it("should parse --images option with multiple paths", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--images", "/path/to/img1.png", "/path/to/img2.jpg"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().images).toEqual(["/path/to/img1.png", "/path/to/img2.jpg"])
|
||||
})
|
||||
|
||||
it("should parse --verbose flag", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--verbose"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().verbose).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse --cwd option", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--cwd", "/some/path"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().cwd).toBe("/some/path")
|
||||
})
|
||||
|
||||
it("should parse --config option", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--config", "/custom/config"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().config).toBe("/custom/config")
|
||||
})
|
||||
|
||||
it("should parse --thinking flag", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--thinking"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().thinking).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse --thinking with token budget", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--thinking", "8000"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().thinking).toBe("8000")
|
||||
})
|
||||
|
||||
it("should parse --reasoning-effort option", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--reasoning-effort", "high"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().reasoningEffort).toBe("high")
|
||||
})
|
||||
|
||||
it("should parse --max-consecutive-mistakes option", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--max-consecutive-mistakes", "999"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().maxConsecutiveMistakes).toBe("999")
|
||||
})
|
||||
|
||||
it("should parse --hooks-dir option", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--hooks-dir", "/tmp/hooks"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().hooksDir).toBe("/tmp/hooks")
|
||||
})
|
||||
|
||||
it("should parse --double-check-completion flag", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--double-check-completion"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().doubleCheckCompletion).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse --auto-condense flag", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--auto-condense"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().autoCondense).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse short flags", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "-a", "-v", "-m", "gpt-4"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().act).toBe(true)
|
||||
@@ -237,26 +254,26 @@ describe("CLI Commands", () => {
|
||||
|
||||
describe("history command", () => {
|
||||
it("should have default limit of 10", () => {
|
||||
const historyCmd = program.commands.find((c) => c.name() === "history")!
|
||||
const historyCmd = getCommand("history")
|
||||
historyCmd.parse([], { from: "user" })
|
||||
expect(historyCmd.opts().limit).toBe("10")
|
||||
})
|
||||
|
||||
it("should have default page of 1", () => {
|
||||
const historyCmd = program.commands.find((c) => c.name() === "history")!
|
||||
const historyCmd = getCommand("history")
|
||||
historyCmd.parse([], { from: "user" })
|
||||
expect(historyCmd.opts().page).toBe("1")
|
||||
})
|
||||
|
||||
it("should parse --limit option", () => {
|
||||
const historyCmd = program.commands.find((c) => c.name() === "history")!
|
||||
const historyCmd = getCommand("history")
|
||||
const args = ["--limit", "20"]
|
||||
historyCmd.parse(args, { from: "user" })
|
||||
expect(historyCmd.opts().limit).toBe("20")
|
||||
})
|
||||
|
||||
it("should parse --page option", () => {
|
||||
const historyCmd = program.commands.find((c) => c.name() === "history")!
|
||||
const historyCmd = getCommand("history")
|
||||
const args = ["--page", "3"]
|
||||
historyCmd.parse(args, { from: "user" })
|
||||
expect(historyCmd.opts().page).toBe("3")
|
||||
@@ -269,7 +286,7 @@ describe("CLI Commands", () => {
|
||||
})
|
||||
|
||||
it("should parse short flags", () => {
|
||||
const historyCmd = program.commands.find((c) => c.name() === "history")!
|
||||
const historyCmd = getCommand("history")
|
||||
const args = ["-n", "5", "-p", "2"]
|
||||
historyCmd.parse(args, { from: "user" })
|
||||
expect(historyCmd.opts().limit).toBe("5")
|
||||
@@ -284,7 +301,7 @@ describe("CLI Commands", () => {
|
||||
})
|
||||
|
||||
it("should parse --config option", () => {
|
||||
const configCmd = program.commands.find((c) => c.name() === "config")!
|
||||
const configCmd = getCommand("config")
|
||||
const args = ["--config", "/custom/path"]
|
||||
configCmd.parse(args, { from: "user" })
|
||||
expect(configCmd.opts().config).toBe("/custom/path")
|
||||
@@ -305,35 +322,35 @@ describe("CLI Commands", () => {
|
||||
})
|
||||
|
||||
it("should parse --provider option", () => {
|
||||
const authCmd = program.commands.find((c) => c.name() === "auth")!
|
||||
const authCmd = getCommand("auth")
|
||||
const args = ["--provider", "openai"]
|
||||
authCmd.parse(args, { from: "user" })
|
||||
expect(authCmd.opts().provider).toBe("openai")
|
||||
})
|
||||
|
||||
it("should parse --apikey option", () => {
|
||||
const authCmd = program.commands.find((c) => c.name() === "auth")!
|
||||
const authCmd = getCommand("auth")
|
||||
const args = ["--apikey", "sk-test-key"]
|
||||
authCmd.parse(args, { from: "user" })
|
||||
expect(authCmd.opts().apikey).toBe("sk-test-key")
|
||||
})
|
||||
|
||||
it("should parse --modelid option", () => {
|
||||
const authCmd = program.commands.find((c) => c.name() === "auth")!
|
||||
const authCmd = getCommand("auth")
|
||||
const args = ["--modelid", "gpt-4"]
|
||||
authCmd.parse(args, { from: "user" })
|
||||
expect(authCmd.opts().modelid).toBe("gpt-4")
|
||||
})
|
||||
|
||||
it("should parse --baseurl option", () => {
|
||||
const authCmd = program.commands.find((c) => c.name() === "auth")!
|
||||
const authCmd = getCommand("auth")
|
||||
const args = ["--baseurl", "https://api.example.com"]
|
||||
authCmd.parse(args, { from: "user" })
|
||||
expect(authCmd.opts().baseurl).toBe("https://api.example.com")
|
||||
})
|
||||
|
||||
it("should parse short flags", () => {
|
||||
const authCmd = program.commands.find((c) => c.name() === "auth")!
|
||||
const authCmd = getCommand("auth")
|
||||
const args = ["-p", "anthropic", "-k", "key123", "-m", "claude-sonnet-4-20250514"]
|
||||
authCmd.parse(args, { from: "user" })
|
||||
expect(authCmd.opts().provider).toBe("anthropic")
|
||||
@@ -354,15 +371,13 @@ describe("CLI Commands", () => {
|
||||
})
|
||||
|
||||
it("should default mcp add type to stdio", () => {
|
||||
const mcpCmd = program.commands.find((c) => c.name() === "mcp")!
|
||||
const addCmd = mcpCmd.commands.find((c) => c.name() === "add")!
|
||||
const addCmd = getSubcommand("mcp", "add")
|
||||
addCmd.parse(["kanban", "--", "kanban", "mcp"], { from: "user" })
|
||||
expect(addCmd.opts().type).toBe("stdio")
|
||||
})
|
||||
|
||||
it("should parse mcp add type option", () => {
|
||||
const mcpCmd = program.commands.find((c) => c.name() === "mcp")!
|
||||
const addCmd = mcpCmd.commands.find((c) => c.name() === "add")!
|
||||
const addCmd = getSubcommand("mcp", "add")
|
||||
addCmd.parse(["linear", "https://mcp.linear.app/mcp", "--type", "http"], { from: "user" })
|
||||
expect(addCmd.opts().type).toBe("http")
|
||||
})
|
||||
@@ -423,6 +438,11 @@ describe("CLI Commands", () => {
|
||||
program.parse(["node", "cli", "--kanban"])
|
||||
expect(program.opts().kanban).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse --tui flag", () => {
|
||||
program.parse(["node", "cli", "--tui"])
|
||||
expect(program.opts().tui).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("command structure", () => {
|
||||
@@ -437,8 +457,8 @@ describe("CLI Commands", () => {
|
||||
})
|
||||
|
||||
it("should have correct aliases", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const historyCmd = program.commands.find((c) => c.name() === "history")!
|
||||
const taskCmd = getCommand("task")
|
||||
const historyCmd = getCommand("history")
|
||||
expect(taskCmd.aliases()).toContain("t")
|
||||
expect(historyCmd.aliases()).toContain("h")
|
||||
})
|
||||
|
||||
+182
-15
@@ -2,7 +2,7 @@
|
||||
* Cline CLI - TypeScript implementation with React Ink
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process"
|
||||
import type { ChildProcess } from "node:child_process"
|
||||
import { exit } from "node:process"
|
||||
import type { ApiProvider } from "@shared/api"
|
||||
import { Command } from "commander"
|
||||
@@ -28,6 +28,7 @@ import { isOpenaiReasoningEffort, OPENAI_REASONING_EFFORT_OPTIONS, type OpenaiRe
|
||||
import { version as CLI_VERSION } from "../package.json"
|
||||
import { runAcpMode } from "./acp/index.js"
|
||||
import { App } from "./components/App"
|
||||
import { KanbanMigrationView } from "./components/KanbanMigrationView"
|
||||
import { checkRawModeSupport } from "./context/StdinContext"
|
||||
import { createCliHostBridgeProvider } from "./controllers"
|
||||
import { CliCommentReviewController } from "./controllers/CliCommentReviewController"
|
||||
@@ -35,6 +36,20 @@ import { CliWebviewProvider } from "./controllers/CliWebviewProvider"
|
||||
import { isAuthConfigured } from "./utils/auth"
|
||||
import { restoreConsole, suppressConsoleUnlessVerbose } from "./utils/console"
|
||||
import { printInfo, printWarning } from "./utils/display"
|
||||
import {
|
||||
forwardSignalToKanbanProcess,
|
||||
isKanbanCommandAvailable,
|
||||
KANBAN_LAUNCH_COMMAND,
|
||||
KANBAN_SHUTDOWN_TIMEOUT_MS,
|
||||
type KanbanMigrationAction,
|
||||
LEGACY_TUI_FLAG,
|
||||
markKanbanMigrationAnnouncementShown,
|
||||
resolveKanbanInstallCommand,
|
||||
shouldLaunchKanbanByDefault,
|
||||
shouldShowKanbanMigrationAnnouncementForCurrentUser,
|
||||
spawnKanbanInstallProcess,
|
||||
spawnKanbanProcess,
|
||||
} from "./utils/kanban"
|
||||
import { addMcpServerShortcut, type McpAddOptions } from "./utils/mcp"
|
||||
import { selectOutputMode } from "./utils/mode-selection"
|
||||
import { parseImagesFromInput, processImagePaths } from "./utils/parser"
|
||||
@@ -59,6 +74,7 @@ interface TaskOptions {
|
||||
act?: boolean
|
||||
plan?: boolean
|
||||
kanban?: boolean
|
||||
tui?: boolean
|
||||
model?: string
|
||||
verbose?: boolean
|
||||
cwd?: string
|
||||
@@ -251,25 +267,72 @@ function getPlainTextModeReason(options: TaskOptions): string {
|
||||
return getModeSelection(options).reason
|
||||
}
|
||||
|
||||
function getNpxCommand(): string {
|
||||
return process.platform === "win32" ? "npx.cmd" : "npx"
|
||||
}
|
||||
function runKanbanAlias(spawnOptions?: Parameters<typeof spawnKanbanProcess>[0]): void {
|
||||
const launchKanban = () => {
|
||||
const child = spawnKanbanProcess(spawnOptions)
|
||||
activeKanbanProcess = child
|
||||
|
||||
function runKanbanAlias(): void {
|
||||
const child = spawn(getNpxCommand(), ["-y", "kanban", "--agent", "cline"], {
|
||||
stdio: "inherit",
|
||||
})
|
||||
child.on("error", (error) => {
|
||||
clearActiveKanbanProcess()
|
||||
const errorMessage = error instanceof Error ? ` ${error.message}` : ""
|
||||
printWarning(`Failed to run '${KANBAN_LAUNCH_COMMAND}'.${errorMessage}`)
|
||||
exit(1)
|
||||
})
|
||||
|
||||
child.on("error", () => {
|
||||
printWarning("Failed to run 'npx kanban --agent cline'. Make sure npx is installed and available in PATH.")
|
||||
child.on("close", (code, signal) => {
|
||||
clearActiveKanbanProcess()
|
||||
exit(resolveProcessExitCode(code, signal))
|
||||
})
|
||||
}
|
||||
|
||||
if (isKanbanCommandAvailable()) {
|
||||
launchKanban()
|
||||
return
|
||||
}
|
||||
|
||||
const installCommand = resolveKanbanInstallCommand()
|
||||
if (!installCommand) {
|
||||
printWarning(
|
||||
`'${KANBAN_LAUNCH_COMMAND}' not found and no supported package manager was detected in PATH (npm, pnpm, bun). Install Kanban globally and try again.`,
|
||||
)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
const installProcess = spawnKanbanInstallProcess(installCommand)
|
||||
|
||||
installProcess.on("error", (error) => {
|
||||
const errorMessage = error instanceof Error ? ` ${error.message}` : ""
|
||||
printWarning(`Failed to run '${installCommand.displayCommand}'.${errorMessage}`)
|
||||
exit(1)
|
||||
})
|
||||
|
||||
child.on("close", (code) => {
|
||||
exit(code ?? 1)
|
||||
installProcess.on("close", (code, signal) => {
|
||||
const installExitCode = resolveProcessExitCode(code, signal)
|
||||
if (installExitCode !== 0) {
|
||||
printWarning(`Failed to install Kanban automatically. Please run '${installCommand.displayCommand}' manually.`)
|
||||
exit(installExitCode)
|
||||
}
|
||||
|
||||
launchKanban()
|
||||
})
|
||||
}
|
||||
|
||||
async function showKanbanMigrationView(): Promise<KanbanMigrationAction> {
|
||||
let selectedAction: KanbanMigrationAction = "exit"
|
||||
|
||||
await runInkApp(
|
||||
React.createElement(KanbanMigrationView, {
|
||||
isRawModeSupported: checkRawModeSupport(),
|
||||
onSelect: (action: KanbanMigrationAction) => {
|
||||
selectedAction = action
|
||||
},
|
||||
}),
|
||||
async () => {},
|
||||
)
|
||||
|
||||
return selectedAction
|
||||
}
|
||||
|
||||
async function addMcpServer(name: string, targetOrCommand: string[] = [], options: McpAddOptions): Promise<void> {
|
||||
try {
|
||||
const result = await addMcpServerShortcut(name, targetOrCommand, options)
|
||||
@@ -347,6 +410,62 @@ 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
|
||||
let activeKanbanProcess: ChildProcess | null = null
|
||||
let activeKanbanShutdownTimer: NodeJS.Timeout | null = null
|
||||
|
||||
function clearActiveKanbanProcess(): void {
|
||||
activeKanbanProcess = null
|
||||
if (activeKanbanShutdownTimer) {
|
||||
clearTimeout(activeKanbanShutdownTimer)
|
||||
activeKanbanShutdownTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function requestKanbanProcessShutdown(signal: NodeJS.Signals): void {
|
||||
if (!activeKanbanProcess) {
|
||||
return
|
||||
}
|
||||
|
||||
forwardSignalToKanbanProcess({
|
||||
child: activeKanbanProcess,
|
||||
signal,
|
||||
})
|
||||
|
||||
if (activeKanbanShutdownTimer) {
|
||||
clearTimeout(activeKanbanShutdownTimer)
|
||||
}
|
||||
|
||||
if (signal === "SIGKILL") {
|
||||
activeKanbanShutdownTimer = null
|
||||
return
|
||||
}
|
||||
|
||||
activeKanbanShutdownTimer = setTimeout(() => {
|
||||
if (!activeKanbanProcess) {
|
||||
return
|
||||
}
|
||||
forwardSignalToKanbanProcess({
|
||||
child: activeKanbanProcess,
|
||||
signal: "SIGKILL",
|
||||
})
|
||||
}, KANBAN_SHUTDOWN_TIMEOUT_MS)
|
||||
activeKanbanShutdownTimer.unref?.()
|
||||
}
|
||||
|
||||
function resolveProcessExitCode(code: number | null, signal: NodeJS.Signals | null): number {
|
||||
if (code !== null) {
|
||||
return code
|
||||
}
|
||||
|
||||
switch (signal) {
|
||||
case "SIGINT":
|
||||
return 130
|
||||
case "SIGTERM":
|
||||
return 143
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for stdout to fully drain before exiting.
|
||||
@@ -402,6 +521,17 @@ function onUnhandledException(reason: unknown, context: string) {
|
||||
|
||||
function setupSignalHandlers() {
|
||||
const shutdown = async (signal: string) => {
|
||||
if (activeKanbanProcess) {
|
||||
if (isShuttingDown) {
|
||||
requestKanbanProcessShutdown("SIGKILL")
|
||||
return
|
||||
}
|
||||
|
||||
isShuttingDown = true
|
||||
requestKanbanProcessShutdown(signal === "SIGTERM" ? "SIGTERM" : "SIGINT")
|
||||
return
|
||||
}
|
||||
|
||||
if (isShuttingDown) {
|
||||
// Force exit on second signal
|
||||
process.exit(1)
|
||||
@@ -899,7 +1029,10 @@ program
|
||||
.option("-v, --verbose", "Show verbose output")
|
||||
.action(() => checkForUpdates(CLI_VERSION))
|
||||
|
||||
program.command("kanban").description("Run npx kanban --agent cline").action(runKanbanAlias)
|
||||
program
|
||||
.command("kanban")
|
||||
.description(`Run ${KANBAN_LAUNCH_COMMAND}`)
|
||||
.action(() => runKanbanAlias())
|
||||
|
||||
// Dev command with subcommands
|
||||
const devCommand = program.command("dev").description("Developer tools and utilities")
|
||||
@@ -1050,17 +1183,23 @@ program
|
||||
.option("--auto-condense", "Enable AI-powered context compaction instead of mechanical truncation")
|
||||
.option("--hooks-dir <path>", "Path to additional hooks directory for runtime hook injection")
|
||||
.option("--acp", "Run in ACP (Agent Client Protocol) mode for editor integration")
|
||||
.option("--kanban", "Run npx kanban --agent cline")
|
||||
.option("--kanban", `Run ${KANBAN_LAUNCH_COMMAND}`)
|
||||
.option("--tui", "Open the legacy terminal UI instead of the kanban experience")
|
||||
.option("-T, --taskId <id>", "Resume an existing task by ID")
|
||||
.option("--continue", "Resume the most recent task from the current working directory")
|
||||
.action(async (prompt, options) => {
|
||||
if (options.kanban && options.tui) {
|
||||
printWarning(`Use either --kanban or ${LEGACY_TUI_FLAG}, not both.`)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
if (options.kanban) {
|
||||
if (prompt) {
|
||||
printWarning("Use --kanban without a prompt.")
|
||||
exit(1)
|
||||
}
|
||||
|
||||
runKanbanAlias()
|
||||
runKanbanAlias({ cwd: options.cwd })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1084,6 +1223,34 @@ program
|
||||
// stdinInput has content means stdin was piped with data
|
||||
const stdinWasPiped = stdinInput !== null
|
||||
|
||||
if (
|
||||
shouldLaunchKanbanByDefault({
|
||||
prompt,
|
||||
stdinWasPiped,
|
||||
taskId: options.taskId,
|
||||
continue: options.continue,
|
||||
tui: options.tui,
|
||||
})
|
||||
) {
|
||||
let migrationAction: "kanban" | "exit" = "kanban"
|
||||
const ctx = await initializeCli({ ...options, enableAuth: true })
|
||||
try {
|
||||
if (await shouldShowKanbanMigrationAnnouncementForCurrentUser()) {
|
||||
migrationAction = await showKanbanMigrationView()
|
||||
await markKanbanMigrationAnnouncementShown()
|
||||
}
|
||||
} finally {
|
||||
await disposeCliContext(ctx)
|
||||
}
|
||||
|
||||
if (migrationAction === "exit") {
|
||||
exit(0)
|
||||
}
|
||||
|
||||
runKanbanAlias({ cwd: options.cwd })
|
||||
return
|
||||
}
|
||||
|
||||
if (options.taskId && options.continue) {
|
||||
printWarning("Use either --taskId or --continue, not both.")
|
||||
exit(1)
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import {
|
||||
buildKanbanInstallSpawnOptions,
|
||||
buildKanbanSpawnOptions,
|
||||
forwardSignalToKanbanProcess,
|
||||
hasUsedLegacyCli,
|
||||
isKanbanCommandAvailable,
|
||||
resolveKanbanInstallCommand,
|
||||
shouldDetachKanbanProcess,
|
||||
shouldLaunchKanbanByDefault,
|
||||
shouldShowKanbanMigrationAnnouncement,
|
||||
} from "./kanban"
|
||||
|
||||
describe("shouldLaunchKanbanByDefault", () => {
|
||||
it("launches kanban for a bare interactive run", () => {
|
||||
expect(
|
||||
shouldLaunchKanbanByDefault({
|
||||
stdinWasPiped: false,
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it("does not launch kanban when a prompt is provided", () => {
|
||||
expect(
|
||||
shouldLaunchKanbanByDefault({
|
||||
prompt: "fix the tests",
|
||||
stdinWasPiped: false,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it("does not launch kanban when stdin is piped", () => {
|
||||
expect(
|
||||
shouldLaunchKanbanByDefault({
|
||||
stdinWasPiped: true,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it("does not launch kanban when the legacy tui is requested", () => {
|
||||
expect(
|
||||
shouldLaunchKanbanByDefault({
|
||||
stdinWasPiped: false,
|
||||
tui: true,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("hasUsedLegacyCli", () => {
|
||||
it("treats task history as legacy usage", () => {
|
||||
expect(
|
||||
hasUsedLegacyCli({
|
||||
taskHistoryCount: 1,
|
||||
isNewUser: true,
|
||||
welcomeViewCompleted: undefined,
|
||||
hasConfiguredAuth: false,
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it("treats configured auth as legacy usage", () => {
|
||||
expect(
|
||||
hasUsedLegacyCli({
|
||||
taskHistoryCount: 0,
|
||||
isNewUser: true,
|
||||
welcomeViewCompleted: undefined,
|
||||
hasConfiguredAuth: true,
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it("skips the announcement for fresh installs", () => {
|
||||
expect(
|
||||
hasUsedLegacyCli({
|
||||
taskHistoryCount: 0,
|
||||
isNewUser: true,
|
||||
welcomeViewCompleted: undefined,
|
||||
hasConfiguredAuth: false,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("kanban process launch", () => {
|
||||
it("detaches the kanban process on unix-like platforms", () => {
|
||||
expect(shouldDetachKanbanProcess("darwin")).toBe(true)
|
||||
expect(shouldDetachKanbanProcess("linux")).toBe(true)
|
||||
})
|
||||
|
||||
it("keeps the kanban process attached on windows", () => {
|
||||
expect(shouldDetachKanbanProcess("win32")).toBe(false)
|
||||
})
|
||||
|
||||
it("uses a detached process group by default on unix-like platforms", () => {
|
||||
expect(buildKanbanSpawnOptions({}, "darwin")).toMatchObject({
|
||||
stdio: "inherit",
|
||||
detached: true,
|
||||
})
|
||||
})
|
||||
|
||||
it("does not detach the process on windows", () => {
|
||||
expect(buildKanbanSpawnOptions({}, "win32")).toMatchObject({
|
||||
stdio: "inherit",
|
||||
detached: false,
|
||||
})
|
||||
})
|
||||
|
||||
it("enables shell mode on windows for command launches", () => {
|
||||
expect(buildKanbanSpawnOptions({}, "win32")).toMatchObject({
|
||||
shell: true,
|
||||
})
|
||||
})
|
||||
|
||||
it("does not set shell mode on unix-like platforms", () => {
|
||||
expect(buildKanbanSpawnOptions({}, "darwin")).not.toHaveProperty("shell")
|
||||
})
|
||||
})
|
||||
|
||||
describe("kanban command availability", () => {
|
||||
it("returns false when PATH is empty", () => {
|
||||
expect(isKanbanCommandAvailable({ PATH: "" }, "darwin")).toBe(false)
|
||||
})
|
||||
|
||||
it("detects the kanban command in PATH", () => {
|
||||
const tempDirectory = mkdtempSync(join(tmpdir(), "kanban-cli-test-"))
|
||||
const commandPath = join(tempDirectory, process.platform === "win32" ? "kanban.cmd" : "kanban")
|
||||
writeFileSync(commandPath, process.platform === "win32" ? "@echo off\r\necho ok\r\n" : "#!/bin/sh\necho ok\n")
|
||||
if (process.platform !== "win32") {
|
||||
chmodSync(commandPath, 0o755)
|
||||
}
|
||||
|
||||
try {
|
||||
expect(isKanbanCommandAvailable({ PATH: tempDirectory }, process.platform)).toBe(true)
|
||||
} finally {
|
||||
rmSync(tempDirectory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("kanban install process launch", () => {
|
||||
it("does not detach the install process on unix-like platforms", () => {
|
||||
expect(buildKanbanInstallSpawnOptions({}, "darwin")).toMatchObject({
|
||||
stdio: "inherit",
|
||||
detached: false,
|
||||
})
|
||||
})
|
||||
|
||||
it("enables shell mode on windows for npm.cmd launches", () => {
|
||||
expect(buildKanbanInstallSpawnOptions({}, "win32")).toMatchObject({
|
||||
shell: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("kanban installer resolution", () => {
|
||||
it("prefers npm when available", () => {
|
||||
const tempDirectory = mkdtempSync(join(tmpdir(), "kanban-installer-test-"))
|
||||
writeFileSync(join(tempDirectory, "npm"), "#!/bin/sh\necho npm\n")
|
||||
writeFileSync(join(tempDirectory, "pnpm"), "#!/bin/sh\necho pnpm\n")
|
||||
writeFileSync(join(tempDirectory, "bun"), "#!/bin/sh\necho bun\n")
|
||||
chmodSync(join(tempDirectory, "npm"), 0o755)
|
||||
chmodSync(join(tempDirectory, "pnpm"), 0o755)
|
||||
chmodSync(join(tempDirectory, "bun"), 0o755)
|
||||
|
||||
try {
|
||||
expect(resolveKanbanInstallCommand({ PATH: tempDirectory }, "darwin")?.packageManager).toBe("npm")
|
||||
} finally {
|
||||
rmSync(tempDirectory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("falls back to pnpm when npm is unavailable", () => {
|
||||
const tempDirectory = mkdtempSync(join(tmpdir(), "kanban-installer-test-"))
|
||||
writeFileSync(join(tempDirectory, "pnpm"), "#!/bin/sh\necho pnpm\n")
|
||||
chmodSync(join(tempDirectory, "pnpm"), 0o755)
|
||||
|
||||
try {
|
||||
const installer = resolveKanbanInstallCommand({ PATH: tempDirectory }, "darwin")
|
||||
expect(installer?.packageManager).toBe("pnpm")
|
||||
expect(installer?.displayCommand).toBe("pnpm add -g kanban@latest")
|
||||
} finally {
|
||||
rmSync(tempDirectory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("falls back to bun when npm and pnpm are unavailable", () => {
|
||||
const tempDirectory = mkdtempSync(join(tmpdir(), "kanban-installer-test-"))
|
||||
writeFileSync(join(tempDirectory, "bun"), "#!/bin/sh\necho bun\n")
|
||||
chmodSync(join(tempDirectory, "bun"), 0o755)
|
||||
|
||||
try {
|
||||
const installer = resolveKanbanInstallCommand({ PATH: tempDirectory }, "darwin")
|
||||
expect(installer?.packageManager).toBe("bun")
|
||||
expect(installer?.displayCommand).toBe("bun add -g kanban@latest")
|
||||
} finally {
|
||||
rmSync(tempDirectory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("returns null when no supported package manager is available", () => {
|
||||
expect(resolveKanbanInstallCommand({ PATH: "" }, "darwin")).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("forwardSignalToKanbanProcess", () => {
|
||||
it("signals the detached kanban process group on unix-like platforms", () => {
|
||||
const killProcess = vi.fn()
|
||||
const child = {
|
||||
pid: 4321,
|
||||
kill: vi.fn(),
|
||||
}
|
||||
|
||||
forwardSignalToKanbanProcess({
|
||||
child,
|
||||
signal: "SIGINT",
|
||||
platform: "darwin",
|
||||
killProcess,
|
||||
})
|
||||
|
||||
expect(killProcess).toHaveBeenCalledWith(-4321, "SIGINT")
|
||||
expect(child.kill).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("signals the child process directly on windows", () => {
|
||||
const killProcess = vi.fn()
|
||||
const child = {
|
||||
pid: 4321,
|
||||
kill: vi.fn(),
|
||||
}
|
||||
|
||||
forwardSignalToKanbanProcess({
|
||||
child,
|
||||
signal: "SIGTERM",
|
||||
platform: "win32",
|
||||
killProcess,
|
||||
})
|
||||
|
||||
expect(killProcess).not.toHaveBeenCalled()
|
||||
expect(child.kill).toHaveBeenCalledWith("SIGTERM")
|
||||
})
|
||||
})
|
||||
|
||||
describe("shouldShowKanbanMigrationAnnouncement", () => {
|
||||
it("shows the announcement once for legacy users", () => {
|
||||
expect(
|
||||
shouldShowKanbanMigrationAnnouncement({
|
||||
announcementShown: false,
|
||||
hasUsedLegacyCli: true,
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it("does not show the announcement twice", () => {
|
||||
expect(
|
||||
shouldShowKanbanMigrationAnnouncement({
|
||||
announcementShown: true,
|
||||
hasUsedLegacyCli: true,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,246 @@
|
||||
import { type ChildProcess, type SpawnOptions, spawn } from "node:child_process"
|
||||
import { accessSync, constants as fsConstants } from "node:fs"
|
||||
import { delimiter, extname, join } from "node:path"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { checkAnyProviderConfigured } from "./auth"
|
||||
|
||||
export const KANBAN_LAUNCH_COMMAND = "kanban"
|
||||
export const KANBAN_SHUTDOWN_TIMEOUT_MS = 10_000
|
||||
export const LEGACY_TUI_FLAG = "--tui"
|
||||
export type KanbanMigrationAction = "kanban" | "exit"
|
||||
type KanbanInstaller = "npm" | "pnpm" | "bun"
|
||||
|
||||
interface KanbanInstallCommand {
|
||||
packageManager: KanbanInstaller
|
||||
command: string
|
||||
args: readonly string[]
|
||||
displayCommand: string
|
||||
}
|
||||
|
||||
interface SignalableKanbanProcess {
|
||||
pid?: number
|
||||
kill: (signal?: NodeJS.Signals | number) => boolean
|
||||
}
|
||||
|
||||
function getKanbanCommand(platform: NodeJS.Platform = process.platform): string {
|
||||
return platform === "win32" ? "kanban.cmd" : "kanban"
|
||||
}
|
||||
|
||||
function getPackageManagerCommand(packageManager: KanbanInstaller, platform: NodeJS.Platform = process.platform): string {
|
||||
if (platform !== "win32") {
|
||||
return packageManager
|
||||
}
|
||||
|
||||
return packageManager === "bun" ? "bun" : `${packageManager}.cmd`
|
||||
}
|
||||
|
||||
const KANBAN_INSTALL_COMMANDS: ReadonlyArray<Omit<KanbanInstallCommand, "displayCommand">> = [
|
||||
{
|
||||
packageManager: "npm",
|
||||
command: "npm",
|
||||
args: ["install", "-g", "kanban@latest"],
|
||||
},
|
||||
{
|
||||
packageManager: "pnpm",
|
||||
command: "pnpm",
|
||||
args: ["add", "-g", "kanban@latest"],
|
||||
},
|
||||
{
|
||||
packageManager: "bun",
|
||||
command: "bun",
|
||||
args: ["add", "-g", "kanban@latest"],
|
||||
},
|
||||
]
|
||||
|
||||
function toDisplayCommand(command: string, args: readonly string[]): string {
|
||||
return `${command} ${args.join(" ")}`
|
||||
}
|
||||
|
||||
export function shouldDetachKanbanProcess(platform: NodeJS.Platform = process.platform): boolean {
|
||||
return platform !== "win32"
|
||||
}
|
||||
|
||||
export function buildKanbanSpawnOptions(options: SpawnOptions = {}, platform: NodeJS.Platform = process.platform): SpawnOptions {
|
||||
return {
|
||||
stdio: "inherit",
|
||||
detached: shouldDetachKanbanProcess(platform),
|
||||
...(platform === "win32" ? { shell: true } : {}),
|
||||
...options,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildKanbanInstallSpawnOptions(
|
||||
options: SpawnOptions = {},
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): SpawnOptions {
|
||||
return {
|
||||
stdio: "inherit",
|
||||
detached: false,
|
||||
...(platform === "win32" ? { shell: true } : {}),
|
||||
...options,
|
||||
}
|
||||
}
|
||||
|
||||
export function spawnKanbanProcess(options: SpawnOptions = {}): ChildProcess {
|
||||
return spawn(getKanbanCommand(), [], buildKanbanSpawnOptions(options))
|
||||
}
|
||||
|
||||
export function spawnKanbanInstallProcess(installCommand: KanbanInstallCommand, options: SpawnOptions = {}): ChildProcess {
|
||||
return spawn(
|
||||
getPackageManagerCommand(installCommand.packageManager),
|
||||
[...installCommand.args],
|
||||
buildKanbanInstallSpawnOptions(options),
|
||||
)
|
||||
}
|
||||
|
||||
function getPathEntries(env: NodeJS.ProcessEnv): string[] {
|
||||
const pathValue = env.PATH ?? env.Path ?? env.path
|
||||
if (!pathValue) {
|
||||
return []
|
||||
}
|
||||
|
||||
return pathValue
|
||||
.split(delimiter)
|
||||
.map((entry) => entry.trim().replace(/^"(.*)"$/u, "$1"))
|
||||
.filter((entry) => entry.length > 0)
|
||||
}
|
||||
|
||||
function pathExists(candidatePath: string, platform: NodeJS.Platform): boolean {
|
||||
try {
|
||||
if (platform === "win32") {
|
||||
accessSync(candidatePath, fsConstants.F_OK)
|
||||
} else {
|
||||
accessSync(candidatePath, fsConstants.X_OK)
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function isCommandAvailable(
|
||||
command: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): boolean {
|
||||
const commandHasExtension = extname(command).length > 0
|
||||
const pathExtensions =
|
||||
platform === "win32" ? (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").filter((ext) => ext.length > 0) : []
|
||||
|
||||
for (const pathEntry of getPathEntries(env)) {
|
||||
const commandPath = join(pathEntry, command)
|
||||
if (pathExists(commandPath, platform)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!commandHasExtension && platform === "win32") {
|
||||
for (const extension of pathExtensions) {
|
||||
if (pathExists(`${commandPath}${extension}`, platform)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function isKanbanCommandAvailable(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): boolean {
|
||||
return isCommandAvailable(getKanbanCommand(platform), env, platform)
|
||||
}
|
||||
|
||||
export function resolveKanbanInstallCommand(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): KanbanInstallCommand | null {
|
||||
for (const installCommand of KANBAN_INSTALL_COMMANDS) {
|
||||
if (isCommandAvailable(installCommand.command, env, platform)) {
|
||||
return {
|
||||
...installCommand,
|
||||
displayCommand: toDisplayCommand(installCommand.command, installCommand.args),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function forwardSignalToKanbanProcess(options: {
|
||||
child: SignalableKanbanProcess
|
||||
signal: NodeJS.Signals
|
||||
platform?: NodeJS.Platform
|
||||
killProcess?: (pid: number, signal: NodeJS.Signals | number) => boolean
|
||||
}): void {
|
||||
if (options.child.pid == null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (shouldDetachKanbanProcess(options.platform)) {
|
||||
try {
|
||||
;(options.killProcess ?? process.kill)(-options.child.pid, options.signal)
|
||||
return
|
||||
} catch (error) {
|
||||
if (error && typeof error === "object" && "code" in error && error.code === "ESRCH") {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
options.child.kill(options.signal)
|
||||
}
|
||||
|
||||
export function shouldLaunchKanbanByDefault(options: {
|
||||
prompt?: string
|
||||
stdinWasPiped: boolean
|
||||
taskId?: string
|
||||
continue?: boolean
|
||||
tui?: boolean
|
||||
}): boolean {
|
||||
return !options.prompt && !options.stdinWasPiped && !options.taskId && !options.continue && !options.tui
|
||||
}
|
||||
|
||||
export function hasUsedLegacyCli(options: {
|
||||
taskHistoryCount: number
|
||||
isNewUser: boolean
|
||||
welcomeViewCompleted: boolean | undefined
|
||||
hasConfiguredAuth: boolean
|
||||
}): boolean {
|
||||
return (
|
||||
options.taskHistoryCount > 0 ||
|
||||
options.isNewUser === false ||
|
||||
options.welcomeViewCompleted !== undefined ||
|
||||
options.hasConfiguredAuth
|
||||
)
|
||||
}
|
||||
|
||||
export function shouldShowKanbanMigrationAnnouncement(options: {
|
||||
announcementShown: boolean
|
||||
hasUsedLegacyCli: boolean
|
||||
}): boolean {
|
||||
return !options.announcementShown && options.hasUsedLegacyCli
|
||||
}
|
||||
|
||||
export async function shouldShowKanbanMigrationAnnouncementForCurrentUser(): Promise<boolean> {
|
||||
const stateManager = StateManager.get()
|
||||
const hasConfiguredAuth = await checkAnyProviderConfigured()
|
||||
const hasUsedLegacy = hasUsedLegacyCli({
|
||||
taskHistoryCount: stateManager.getGlobalStateKey("taskHistory")?.length ?? 0,
|
||||
isNewUser: stateManager.getGlobalStateKey("isNewUser"),
|
||||
welcomeViewCompleted: stateManager.getGlobalStateKey("welcomeViewCompleted"),
|
||||
hasConfiguredAuth,
|
||||
})
|
||||
|
||||
return shouldShowKanbanMigrationAnnouncement({
|
||||
announcementShown: stateManager.getGlobalStateKey("cliKanbanMigrationAnnouncementShown"),
|
||||
hasUsedLegacyCli: hasUsedLegacy,
|
||||
})
|
||||
}
|
||||
|
||||
export async function markKanbanMigrationAnnouncementShown(): Promise<void> {
|
||||
const stateManager = StateManager.get()
|
||||
stateManager.setGlobalState("cliKanbanMigrationAnnouncementShown", true)
|
||||
await stateManager.flushPendingState()
|
||||
}
|
||||
@@ -24,7 +24,7 @@ Your App → Cline API (api.cline.bot) → Anthropic / OpenAI / Google / etc
|
||||
<Card title="Chat Completions" icon="message" href="/api/chat-completions">
|
||||
Full endpoint reference with request schemas, streaming, and tool calling.
|
||||
</Card>
|
||||
<Card title="SDK Examples" icon="code" href="/api/sdk-examples">
|
||||
<Card title="Code Examples" icon="code" href="/api/sdk-examples">
|
||||
Ready-to-copy examples for Python, Node.js, curl, and the Cline CLI.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: "SDK Examples"
|
||||
sidebarTitle: "SDK Examples"
|
||||
title: "Code Examples"
|
||||
sidebarTitle: "Code Examples"
|
||||
description: "Use the Cline API from Python, Node.js, curl, the Cline CLI, and the VS Code extension."
|
||||
---
|
||||
|
||||
|
||||
+109
-15
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: "Cline SDK"
|
||||
sidebarTitle: "SDK (Programmatic Use)"
|
||||
description: "Embed Cline as a programmable coding agent in your Node.js applications using an ACP-compatible TypeScript API."
|
||||
---
|
||||
|
||||
@@ -180,13 +181,15 @@ await agent.prompt({
|
||||
|
||||
#### Stop Reasons
|
||||
|
||||
`prompt()` resolves with a `stopReason`:
|
||||
`prompt()` resolves with a `stopReason`. The ACP `StopReason` type defines the full set of possible values:
|
||||
|
||||
| Value | Meaning |
|
||||
|-------|---------|
|
||||
| `"end_turn"` | Agent finished normally (completed task or waiting for user input) |
|
||||
| `"error"` | An error occurred |
|
||||
|
||||
> **Note:** Cline currently returns `"end_turn"` or `"error"`. Other `StopReason` values like `"max_tokens"` or `"cancelled"` are part of the ACP type but may not be produced by the current implementation.
|
||||
|
||||
### Streaming Events
|
||||
|
||||
Subscribe to real-time output via `ClineSessionEmitter`. Each session has its own emitter.
|
||||
@@ -267,9 +270,8 @@ Each permission request includes an array of `PermissionOption` objects:
|
||||
| `kind` | Meaning |
|
||||
|--------|---------|
|
||||
| `allow_once` | Approve this single operation |
|
||||
| `allow_always` | Approve and remember for future operations |
|
||||
| `allow_always` | Approve and remember for future operations (sent for commands, tools, MCP servers) |
|
||||
| `reject_once` | Deny this single operation |
|
||||
| `reject_always` | Deny and remember for future operations |
|
||||
|
||||
**Important:** If no permission handler is set, all tool calls are rejected for safety.
|
||||
|
||||
@@ -319,7 +321,29 @@ await agent.authenticate({ methodId: "openai-codex-oauth" })
|
||||
|
||||
Both methods open a browser window for the OAuth flow and block until authentication completes (5-minute timeout for Cline OAuth).
|
||||
|
||||
For BYO (bring-your-own) API key providers, configure the key through the cline config directory before creating a session. The `authenticate()` call is not needed for BYO providers. We plan to support more auth providers in the near future.
|
||||
For BYO (bring-your-own) API key providers, you can pre-configure credentials using the Cline CLI before using the SDK:
|
||||
|
||||
```bash
|
||||
# Configure an Anthropic API key (default directory: ~/.cline/data/)
|
||||
cline auth -p anthropic -k "sk-ant-..." -m anthropic/claude-sonnet-4-20250514
|
||||
|
||||
# Configure an OpenRouter API key
|
||||
cline auth -p openrouter -k "sk-or-..." -m openrouter/anthropic/claude-sonnet-4
|
||||
```
|
||||
|
||||
This writes credentials to `~/.cline/data/`. Once configured, the SDK will use these credentials automatically — no `authenticate()` call needed.
|
||||
|
||||
**Using a custom directory:** If you specify a custom `clineDir` when creating `ClineAgent`, you must use the same path with `--config` when running `cline auth`:
|
||||
|
||||
```typescript
|
||||
// SDK code using custom directory
|
||||
const agent = new ClineAgent({ clineDir: "/custom/path" })
|
||||
```
|
||||
|
||||
```bash
|
||||
# CLI auth command must use the same path
|
||||
cline auth -p anthropic -k "sk-ant-..." -m anthropic/claude-sonnet-4-20250514 --config /custom/path
|
||||
```
|
||||
|
||||
### Cancellation
|
||||
|
||||
@@ -343,6 +367,8 @@ interface ClineAgentOptions {
|
||||
debug?: boolean
|
||||
/** Custom Cline config directory (default: ~/.cline) */
|
||||
clineDir?: string
|
||||
/** Additional runtime hooks directory */
|
||||
hooksDir?: string
|
||||
}
|
||||
```
|
||||
|
||||
@@ -368,13 +394,13 @@ const response = await agent.initialize({
|
||||
|
||||
// Response includes:
|
||||
{
|
||||
protocolVersion: "0.9.0",
|
||||
protocolVersion: 1,
|
||||
agentCapabilities: {
|
||||
loadSession: true,
|
||||
promptCapabilities: { image: true, audio: false, embeddedContext: true },
|
||||
mcpCapabilities: { http: true, sse: false }
|
||||
},
|
||||
agentInfo: { name: "cline", version: "2.2.3" },
|
||||
agentInfo: { name: "cline", version: "<installed_version>" },
|
||||
authMethods: [
|
||||
{ id: "cline-oauth", name: "Sign in with Cline", description: "..." },
|
||||
{ id: "openai-codex-oauth", name: "Sign in with ChatGPT", description: "..." }
|
||||
@@ -382,6 +408,24 @@ const response = await agent.initialize({
|
||||
}
|
||||
```
|
||||
|
||||
#### Client Capabilities
|
||||
|
||||
The `clientCapabilities` object in `initialize()` declares what your environment supports. It is part of the ACP protocol handshake.
|
||||
|
||||
| Capability | Type | Description |
|
||||
|------------|------|-------------|
|
||||
| `fs.readTextFile` | `boolean` | Client supports file read requests |
|
||||
| `fs.writeTextFile` | `boolean` | Client supports file write requests |
|
||||
| `terminal` | `boolean` | Client supports terminal command execution |
|
||||
|
||||
**When using `ClineAgent` directly (SDK use)**, the agent always uses standalone providers for file operations and terminal commands — it reads/writes files and runs shell commands on the local machine regardless of what you pass here. Simply pass `{}`:
|
||||
|
||||
```typescript
|
||||
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} })
|
||||
```
|
||||
|
||||
These capabilities only affect behavior when `ClineAgent` is used through the `AcpAgent` stdio wrapper (e.g., IDE integrations), where an ACP connection delegates operations back to the client.
|
||||
|
||||
#### `newSession(params): Promise<NewSessionResponse>`
|
||||
|
||||
Create a new conversation session.
|
||||
@@ -411,8 +455,8 @@ const session = await agent.newSession({
|
||||
currentModeId: "act"
|
||||
},
|
||||
models: {
|
||||
currentModelId: "anthropic/claude-sonnet-4-5-20241022",
|
||||
availableModels: [{ modelId: "anthropic/claude-3-5-sonnet-20241022", name: "..." }]
|
||||
currentModelId: "anthropic/claude-sonnet-4-20250514",
|
||||
availableModels: [{ modelId: "anthropic/claude-sonnet-4-20250514", name: "claude-sonnet-4-20250514" } /* ... */]
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -487,11 +531,18 @@ await agent.shutdown()
|
||||
|
||||
#### `setPermissionHandler(handler)`
|
||||
|
||||
Set a callback to handle tool permission requests.
|
||||
Set a callback to handle tool permission requests. The handler receives a `RequestPermissionRequest` and must return a `Promise<RequestPermissionResponse>`.
|
||||
|
||||
```typescript
|
||||
agent.setPermissionHandler((request, resolve) => {
|
||||
resolve({ outcome: { outcome: "selected", optionId: "allow_once" } })
|
||||
agent.setPermissionHandler(async (request) => {
|
||||
// request.toolCall — details about what the agent wants to do
|
||||
// request.options — available choices (allow_once, reject_once, etc.)
|
||||
const allow = request.options.find(o => o.kind === "allow_once")
|
||||
return {
|
||||
outcome: allow
|
||||
? { outcome: "selected", optionId: allow.optionId }
|
||||
: { outcome: "cancelled" }
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
@@ -513,13 +564,45 @@ for (const [sessionId, session] of agent.sessions) {
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
SDK methods throw standard JavaScript errors. Key error scenarios:
|
||||
|
||||
| Method | Error | Cause |
|
||||
|--------|-------|-------|
|
||||
| `newSession()` | `RequestError` (auth required) | No credentials configured — call `authenticate()` or pre-configure via CLI |
|
||||
| `prompt()` | `Error("Session not found")` | Invalid `sessionId` |
|
||||
| `prompt()` | `Error("already processing")` | Called `prompt()` while a previous prompt is still running on the same session |
|
||||
| `unstable_setSessionModel()` | `Error("Invalid modelId format")` | Model ID must be `"provider/modelId"` format (e.g., `"anthropic/claude-sonnet-4-20250514"`) |
|
||||
| `authenticate()` | `Error("Unknown authentication method")` | Invalid `methodId` — use `"cline-oauth"` or `"openai-codex-oauth"` |
|
||||
| `authenticate()` | `Error("Authentication timed out")` | OAuth flow not completed within 5 minutes |
|
||||
|
||||
```typescript
|
||||
try {
|
||||
const { sessionId } = await agent.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
} catch (error) {
|
||||
if (error.message?.includes("auth")) {
|
||||
// Need to authenticate first
|
||||
await agent.authenticate({ methodId: "cline-oauth" })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Session-level errors during `prompt()` execution are emitted on the session emitter rather than thrown:
|
||||
|
||||
```typescript
|
||||
emitter.on("error", (err) => {
|
||||
console.error("Session error:", err.message)
|
||||
})
|
||||
```
|
||||
|
||||
## Full Example: Auto-Approve Agent
|
||||
|
||||
```typescript
|
||||
import { ClineAgent } from "cline";
|
||||
|
||||
async function runTask(taskPrompt: string, cwd: string) {
|
||||
const agent = new ClineAgent({ clineDir: "/Users/maxpaulus/.cline" });
|
||||
const agent = new ClineAgent({ clineDir: "/path/to/.cline" });
|
||||
|
||||
await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
@@ -642,23 +725,34 @@ All types are re-exported from the `cline` package. Key types:
|
||||
|------|-------------|
|
||||
| `ClineAgent` | Main agent class |
|
||||
| `ClineSessionEmitter` | Typed event emitter for session events |
|
||||
| `ClineAgentOptions` | Constructor options |
|
||||
| `ClineAgentOptions` | Constructor options (`debug`, `clineDir`, `hooksDir`) |
|
||||
| `ClineAcpSession` | Session metadata (read-only) |
|
||||
| `ClineSessionEvents` | Event name → handler signature map |
|
||||
| `PermissionHandler` | `(request, resolve) => void` callback |
|
||||
| `PermissionResolver` | `(response) => void` callback |
|
||||
| `AcpSessionStatus` | Session lifecycle enum: `Idle`, `Processing`, `Cancelled` |
|
||||
| `AcpSessionState` | Session state tracking (status, pending tool calls) |
|
||||
| `PermissionHandler` | `(request: RequestPermissionRequest) => Promise<RequestPermissionResponse>` |
|
||||
| `RequestPermissionRequest` | Permission request details (sessionId, toolCall, options) |
|
||||
| `RequestPermissionResponse` | Permission response with outcome |
|
||||
| `PermissionOption` | Permission choice (`kind`, `optionId`, `name`) |
|
||||
| `SessionUpdate` | Union of all session update types |
|
||||
| `SessionUpdateType` | Discriminator values (`"agent_message_chunk"`, `"tool_call"`, etc.) |
|
||||
| `SessionUpdatePayload` | Typed payload for a given `SessionUpdateType` |
|
||||
| `SessionModelState` | Current model and available models |
|
||||
| `ToolCall` | Tool call details (id, title, kind, status, content) |
|
||||
| `ToolCallUpdate` | Partial update to an existing tool call |
|
||||
| `ToolCallStatus` | `"pending" \| "in_progress" \| "completed" \| "failed"` |
|
||||
| `ToolKind` | `"read" \| "edit" \| "delete" \| "execute" \| "search" \| ...` |
|
||||
| `StopReason` | `"end_turn" \| "cancelled" \| "error" \| "max_tokens" \| ...` |
|
||||
| `ContentBlock` | `TextContent \| ImageContent \| AudioContent \| ...` |
|
||||
| `TextContent` / `ImageContent` / `AudioContent` | Individual content block types |
|
||||
| `McpServer` | MCP server configuration (stdio, http) |
|
||||
| `ModelInfo` | Model metadata (`modelId`, `name`) |
|
||||
| `PromptRequest` / `PromptResponse` | Prompt call types |
|
||||
| `NewSessionRequest` / `NewSessionResponse` | Session creation types |
|
||||
| `InitializeRequest` / `InitializeResponse` | Initialization types |
|
||||
| `SetSessionModeRequest` / `SetSessionModeResponse` | Mode switching types |
|
||||
| `SetSessionModelRequest` / `SetSessionModelResponse` | Model switching types |
|
||||
| `TranslatedMessage` | Result of translating a Cline message to ACP updates |
|
||||
|
||||
See the [ACP Schema](https://agentclientprotocol.com/protocol/schema) for the full type definitions.
|
||||
|
||||
|
||||
+16
-1
@@ -101,6 +101,7 @@
|
||||
"pages": [
|
||||
"cline-cli/overview",
|
||||
"cline-cli/installation",
|
||||
"cline-sdk/overview",
|
||||
"cline-cli/interactive-mode",
|
||||
{
|
||||
"group": "Headless Mode",
|
||||
@@ -116,7 +117,6 @@
|
||||
},
|
||||
"cline-cli/configuration",
|
||||
"cline-cli/acp-editor-integrations",
|
||||
"cline-sdk/overview",
|
||||
"cline-cli/cli-reference"
|
||||
]
|
||||
},
|
||||
@@ -332,6 +332,21 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tab": "Kanban",
|
||||
"icon": "table-columns",
|
||||
"groups": [
|
||||
{
|
||||
"group": "Cline Kanban",
|
||||
"pages": [
|
||||
"kanban/overview",
|
||||
"kanban/getting-started",
|
||||
"kanban/core-workflow",
|
||||
"kanban/features"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tab": "Learn",
|
||||
"icon": "graduation-cap",
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
title: "Core Workflow"
|
||||
description: "The end-to-end workflow for using Cline Kanban: create tasks, run agents, review changes, and ship"
|
||||
---
|
||||
|
||||
This guide walks through the typical Kanban workflow from start to finish.
|
||||
|
||||
## 1. Create Tasks
|
||||
|
||||
There are two ways to add tasks to the board:
|
||||
|
||||
- **Manually** — click the add button and write a task description
|
||||
- **Via sidebar chat** — open the sidebar chat and ask the agent to break down a piece of work into tasks. The agent can create cards, link them together, and start work directly on the board.
|
||||
|
||||
Each card on the board represents a discrete unit of work for an agent to complete.
|
||||
|
||||
## 2. Link Tasks
|
||||
|
||||
Link cards together to create dependency chains:
|
||||
|
||||
- **⌘ + click** (Mac) / **Ctrl + click** (Windows/Linux) a card to link it to another task
|
||||
- When a linked card completes and is moved to trash, the next linked task **automatically starts**
|
||||
|
||||
Combined with auto-commit, this enables fully autonomous chains where one task's output feeds into the next without manual intervention.
|
||||
|
||||
## 3. Start Tasks
|
||||
|
||||
Hit the **play button** on a card to start it. Here's what happens:
|
||||
|
||||
1. Kanban creates an **ephemeral git worktree** for the task — an isolated copy of your repo where the agent can make changes without affecting your main working directory or other tasks
|
||||
2. Gitignored files like `node_modules` are **symlinked** from your main repo into the worktree, avoiding slow reinstalls for each task
|
||||
3. The agent starts working in its own terminal within that worktree
|
||||
4. The card displays the agent's **latest message or tool call** so you can monitor progress from the board
|
||||
|
||||
Multiple tasks run in parallel, each in their own worktree, so agents never create merge conflicts with each other.
|
||||
|
||||
<Warning>
|
||||
Symlinks work well for gitignored files that agents don't need to modify (like `node_modules`). If your workflow requires agents to modify gitignored files, be aware that changes will affect the symlink target (your main repo's copy).
|
||||
</Warning>
|
||||
|
||||
## 4. Review Changes
|
||||
|
||||
Click a card to open the detail view, which shows:
|
||||
|
||||
- **The agent's TUI** — the full text interface showing the agent's conversation and actions
|
||||
- **A diff of all changes** in that worktree compared to your base branch
|
||||
|
||||
The diff viewer includes a **checkpoint system** — you can see diffs scoped to specific message ranges, not just the full cumulative diff. This makes it easier to understand what changed and when.
|
||||
|
||||
### Inline Comments
|
||||
|
||||
Click on any line in the diff to leave a comment. Comments are sent back to the agent as feedback, letting you steer its work without rewriting the task description. This is useful for corrections like "use a different approach here" or "this edge case isn't handled."
|
||||
|
||||
## 5. Ship It
|
||||
|
||||
When you're satisfied with the changes, you have two options:
|
||||
|
||||
- **Commit** — merges the worktree changes into a commit on your base branch
|
||||
- **Open PR** — creates a new branch and opens a pull request
|
||||
|
||||
In both cases, Kanban sends a dynamic prompt to the agent to handle the operation. The agent converts the worktree into the appropriate git action and **intelligently handles merge conflicts** if the base branch has moved since the worktree was created.
|
||||
|
||||
## 6. Clean Up
|
||||
|
||||
After shipping, move the card to **trash** to clean up the ephemeral worktree and free disk space.
|
||||
|
||||
<Tip>
|
||||
If you need to resume work on a trashed card later, Kanban provides a **resume ID** for each task. You can use this to pick up where you left off.
|
||||
</Tip>
|
||||
|
||||
## Workflow Summary
|
||||
|
||||
| Step | Action | What Happens |
|
||||
|------|--------|-------------|
|
||||
| Create | Add card or use sidebar chat | Task card appears on the board |
|
||||
| Link | ⌘ + click to connect cards | Dependency chain is established |
|
||||
| Start | Hit play on a card | Ephemeral worktree is created, agent begins work |
|
||||
| Monitor | Watch card status on board | Latest agent message/tool call shown on card |
|
||||
| Review | Click card to see diff | Full diff with checkpoints and inline commenting |
|
||||
| Ship | Click Commit or Open PR | Agent handles merge into base branch or creates PR |
|
||||
| Clean up | Move to trash | Worktree is removed, resume ID saved |
|
||||
@@ -0,0 +1,120 @@
|
||||
---
|
||||
title: "Features"
|
||||
description: "Detailed overview of Cline Kanban features: worktrees, auto-commit, task linking, diff viewer, git interface, and more"
|
||||
---
|
||||
|
||||
<Warning>
|
||||
Kanban is a **research preview**. Some features described here use experimental capabilities. Expect changes.
|
||||
</Warning>
|
||||
|
||||
## Ephemeral Worktrees
|
||||
|
||||
Every task card runs in its own [git worktree](https://git-scm.com/docs/git-worktree) — an isolated checkout of your repository. This is the foundation that enables parallel agent execution:
|
||||
|
||||
- Each agent works in its own directory with its own terminal
|
||||
- Changes in one worktree don't affect other worktrees or your main working directory
|
||||
- No merge conflicts between agents running simultaneously
|
||||
- Worktrees are cleaned up when you move a card to trash
|
||||
|
||||
### Symlinked Dependencies
|
||||
|
||||
When creating a worktree, Kanban symlinks gitignored files (like `node_modules`) from your main repo rather than copying or reinstalling them. This avoids the overhead of running `npm install` for every task.
|
||||
|
||||
<Warning>
|
||||
Symlinks point back to the original files in your main repo. This works well for dependencies that agents don't modify, but if an agent does modify a symlinked file, the change affects the original too.
|
||||
</Warning>
|
||||
|
||||
## Auto-Commit
|
||||
|
||||
When enabled, agents automatically commit their changes to the worktree branch as they work. This creates a trail of incremental commits rather than one large diff at the end.
|
||||
|
||||
Auto-commit can be toggled in the Kanban settings.
|
||||
|
||||
## Auto-PR
|
||||
|
||||
When enabled alongside auto-commit, agents can automatically create pull requests when they finish their work. The agent generates a PR with the changes from its worktree branch.
|
||||
|
||||
Auto-PR can be toggled in the Kanban settings.
|
||||
|
||||
## Task Linking & Dependency Chains
|
||||
|
||||
Task linking lets you create sequential workflows where completing one task triggers the next:
|
||||
|
||||
1. **⌘ + click** a card to link it to another card
|
||||
2. When the first card is completed and moved to trash, the linked card **starts automatically**
|
||||
3. Chain multiple cards together for multi-step workflows
|
||||
|
||||
When combined with auto-commit, this creates fully autonomous pipelines — one agent finishes, its work is committed, and the next agent picks up where it left off.
|
||||
|
||||
## Diff Viewer & Checkpoints
|
||||
|
||||
Clicking a card opens a detail view with a full diff of all changes in that worktree. The diff viewer includes:
|
||||
|
||||
- **Checkpoint-scoped diffs** — rather than showing only the cumulative diff, you can view changes from specific message ranges. This is useful for understanding what changed at each step.
|
||||
- **Inline commenting** — click any line in the diff to leave a comment that gets sent back to the agent. Use this to give targeted feedback like "handle this edge case" or "use a different pattern here."
|
||||
|
||||
## Sidebar Chat & Board Management
|
||||
|
||||
The sidebar chat gives you a conversational interface for managing the board. Instead of manually creating and configuring cards, you can ask the agent to:
|
||||
|
||||
- Break down a piece of work into multiple task cards
|
||||
- Link cards together into dependency chains
|
||||
- Start tasks on the board
|
||||
|
||||
The agent manipulates the board directly based on your instructions.
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
Kanban includes keyboard shortcuts for common actions:
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| **C** | Create a new task card |
|
||||
| **⌘ + click** | Link a card to another card |
|
||||
|
||||
<Tip>
|
||||
The "C" shortcut works from the main board view. You need at least one project open to create a task.
|
||||
</Tip>
|
||||
|
||||
## Settings
|
||||
|
||||
Open the settings dialog to configure how Kanban behaves. Available settings include:
|
||||
|
||||
- **Auto-commit** — toggle whether agents automatically commit changes as they work
|
||||
- **Auto-PR** — toggle whether agents automatically create pull requests on completion (requires auto-commit)
|
||||
- **Script shortcuts** — define frequently-used commands that appear as buttons on task cards
|
||||
- **Project paths** — displayed with `~` instead of full home directory paths for readability
|
||||
|
||||
## Script Shortcuts
|
||||
|
||||
Define frequently-used commands (like `npm run dev` or `npm test`) in the Kanban settings. These appear as play buttons on task cards, giving you quick access to run, test, or debug the application within a worktree without switching to a separate terminal.
|
||||
|
||||
## Git Interface
|
||||
|
||||
Click the **branch name** in the navbar to open a full git interface. From here you can:
|
||||
|
||||
- Browse commit history
|
||||
- Switch branches
|
||||
- Fetch, pull, and push
|
||||
- Visualize the git graph
|
||||
|
||||
This lets you manage your repository without leaving Kanban or opening a separate git client.
|
||||
|
||||
## Agent Compatibility
|
||||
|
||||
Kanban works with CLI-based coding agents. It uses experimental features that bypass permissions and runtime hooks, giving agents more autonomy to work without interruption. Agents currently compatible with Kanban include:
|
||||
|
||||
- **Cline CLI**
|
||||
- **Claude Code**
|
||||
- **Codex**
|
||||
- **OpenCode**
|
||||
|
||||
and more. Check settings for all available agent runtimes
|
||||
|
||||
## Resume Tasks
|
||||
|
||||
When you move a card to trash, the worktree is cleaned up but Kanban saves a **resume ID**. If you need to continue work on a trashed task, you can use this ID to pick up where you left off without starting from scratch.
|
||||
|
||||
## Remote Config Gating
|
||||
|
||||
For teams and organizations, Kanban access can be gated via Cline remote config. This allows administrators to control who can access the Kanban board within their organization, enabling phased rollouts or restricting access to specific teams.
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
title: "Getting Started"
|
||||
description: "Install Cline Kanban and launch your first board"
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Node.js 18 or higher** — check with `node --version`
|
||||
- **A git repository** — Kanban must be run from the root of a git repo
|
||||
|
||||
## Installation
|
||||
|
||||
Install Cline CLI globally via npm:
|
||||
|
||||
```bash
|
||||
npm i -g cline
|
||||
```
|
||||
|
||||
Then launch Kanban:
|
||||
|
||||
```bash
|
||||
cline --kanban
|
||||
```
|
||||
|
||||
<Tip>
|
||||
This launches a local web server and opens the Kanban board in your default browser.
|
||||
</Tip>
|
||||
|
||||
## First Launch
|
||||
|
||||
1. Open your terminal and `cd` to the root of any git repository
|
||||
2. Run `cline --kanban`
|
||||
3. Your browser opens to the Kanban board
|
||||
|
||||
### Onboarding
|
||||
|
||||
On your first launch, Kanban walks you through a short setup:
|
||||
|
||||
1. **Pick a project directory** — a directory picker opens so you can select (or confirm) the repository you want to work in
|
||||
2. **Choose your agent** — select which coding agent to use for tasks (Cline, Claude Code, or Codex)
|
||||
|
||||
After onboarding, you land on the board and can start creating tasks immediately. No account creation, API keys, or configuration files required.
|
||||
|
||||
## Creating Your First Task
|
||||
|
||||
Once the board is open:
|
||||
|
||||
1. **Create a card** — click the add button to create a new task card
|
||||
2. **Write a task description** — describe what you want the agent to do
|
||||
3. **Hit play** — Kanban creates an ephemeral git worktree for the task and starts an agent in its own terminal
|
||||
|
||||
The card updates in real time, showing the agent's latest message or tool call so you can monitor progress from the board.
|
||||
|
||||
<Tip>
|
||||
You can also use the **sidebar chat** to create tasks. Open the chat and ask the agent to break down work into multiple task cards — it can create, link, and start tasks directly on the board.
|
||||
</Tip>
|
||||
|
||||
## Next Steps
|
||||
|
||||
<Columns cols={2}>
|
||||
<Card title="Core Workflow" icon="arrows-spin" href="/kanban/core-workflow">
|
||||
Learn the full workflow: create → link → start → review → ship.
|
||||
</Card>
|
||||
<Card title="Features" icon="list-check" href="/kanban/features">
|
||||
Explore worktrees, auto-commit, task linking, the diff viewer, and more.
|
||||
</Card>
|
||||
</Columns>
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
title: "Cline Kanban"
|
||||
sidebarTitle: "Overview"
|
||||
description: "A kanban board for orchestrating coding agents in parallel using git worktrees"
|
||||
---
|
||||
|
||||
<Warning>
|
||||
Kanban is a **research preview**. Share feedback in [#kanban on Discord](https://discord.gg/cline).
|
||||
</Warning>
|
||||
|
||||
## What is Cline Kanban?
|
||||
|
||||
Cline Kanban is a terminal-launched kanban board that runs in your browser. Each task card gets its own git worktree and terminal, so you can run multiple coding agents in parallel without merge conflicts. You create tasks, assign them to agents, review diffs, leave inline comments, and ship commits or PRs — all from one interface.
|
||||
|
||||
It runs locally, requires no account or setup, and works out of the box from any git repository.
|
||||
|
||||
```bash
|
||||
npm i -g cline
|
||||
cline --kanban
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Run `cline --kanban`** from the root of any git repo — a local web server opens in your browser
|
||||
2. **Create task cards** manually or ask the sidebar chat agent to break work into tasks
|
||||
3. **Hit play** on a card — Kanban creates an ephemeral worktree and starts an agent
|
||||
4. **Monitor progress** — each card shows the agent's latest message or tool call
|
||||
5. **Review diffs** — click a card to see all changes, leave inline comments to steer the agent
|
||||
6. **Ship it** — hit Commit or Open PR, then trash the card to clean up the worktree
|
||||
|
||||
## Key Capabilities
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="Parallel Execution" icon="clone">
|
||||
Each task runs in its own git worktree with its own terminal. Multiple agents work simultaneously without stepping on each other.
|
||||
</Card>
|
||||
<Card title="Unified Task Board" icon="table-columns">
|
||||
Create, triage, link, and monitor all agent tasks from a single browser-based kanban board.
|
||||
</Card>
|
||||
<Card title="Works With Existing Agents" icon="plug">
|
||||
Compatible with CLI agents you already use — Cline, Claude Code, and Codex. Kanban uses experimental features that bypass permissions and runtime hooks for more agent autonomy.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Links
|
||||
|
||||
- [GitHub Repository](https://github.com/cline/kanban) — source code, issues, and feature requests
|
||||
- [npm Package](https://www.npmjs.com/package/kanban) — version history and package details
|
||||
- [Cline App](https://app.cline.bot) — account management
|
||||
- [Discord #kanban](https://discord.gg/cline) — feedback and discussion
|
||||
|
||||
## Next Steps
|
||||
|
||||
<Columns cols={2}>
|
||||
<Card title="Getting Started" icon="rocket" href="/kanban/getting-started">
|
||||
Install Kanban and launch your first board.
|
||||
</Card>
|
||||
<Card title="Core Workflow" icon="arrows-spin" href="/kanban/core-workflow">
|
||||
The full workflow from creating tasks to shipping PRs.
|
||||
- [npm Package](https://www.npmjs.com/package/cline) — version history and package details
|
||||
<Card title="Features" icon="list-check" href="/kanban/features">
|
||||
Worktrees, auto-commit, task linking, diff viewer, git interface, and more.
|
||||
</Card>
|
||||
</Columns>
|
||||
@@ -18,9 +18,12 @@ MiniMax provides AI models with large context windows and competitive pricing, f
|
||||
|
||||
Cline supports the following MiniMax models:
|
||||
|
||||
- `MiniMax-M2.5` (Default) - Latest model with 192K context, prompt caching, and reasoning/thinking support ($0.30/$1.20 per 1M tokens)
|
||||
- `MiniMax-M2.1` - Previous generation with 192K context and prompt caching ($0.30/$1.20 per 1M tokens)
|
||||
- `MiniMax-M2.1-lightning` - Fast variant with higher output pricing ($0.30/$2.40 per 1M tokens)
|
||||
- `MiniMax-M2.7` (Default) - Latest flagship model with enhanced reasoning and coding, 192K context, prompt caching, and reasoning/thinking support ($0.30/$1.20 per 1M tokens)
|
||||
- `MiniMax-M2.7-highspeed` - High-speed version of M2.7 for low-latency scenarios ($0.60/$2.40 per 1M tokens)
|
||||
- `MiniMax-M2.5` - Previous flagship with 192K context, prompt caching, and reasoning support ($0.30/$1.20 per 1M tokens)
|
||||
- `MiniMax-M2.5-highspeed` - High-speed version of M2.5 ($0.60/$2.40 per 1M tokens)
|
||||
- `MiniMax-M2.1` - Earlier generation with 192K context and prompt caching ($0.30/$1.20 per 1M tokens)
|
||||
- `MiniMax-M2.1-lightning` - Fast variant with higher output pricing ($0.60/$2.40 per 1M tokens)
|
||||
- `MiniMax-M2` - Earlier generation with 192K context ($0.30/$1.20 per 1M tokens)
|
||||
|
||||
### Configuration in Cline
|
||||
@@ -33,6 +36,6 @@ Cline supports the following MiniMax models:
|
||||
### Tips and Notes
|
||||
|
||||
- **Large Context:** All models support 192K token context windows.
|
||||
- **Reasoning Support:** M2.5 supports extended thinking/reasoning for complex tasks.
|
||||
- **Prompt Caching:** M2.5 and M2.1 models support prompt caching for reduced costs on repeated queries.
|
||||
- **Reasoning Support:** M2.7 and M2.5 support extended thinking/reasoning for complex tasks.
|
||||
- **Prompt Caching:** M2.7 (including highspeed), M2.5 (including highspeed), and M2.1 models support prompt caching for reduced costs on repeated queries.
|
||||
- **Pricing:** Check the [MiniMax pricing page](https://www.minimax.io/platform/document/pricing) for current rates.
|
||||
|
||||
Generated
+3
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "claude-dev",
|
||||
"version": "3.74.0",
|
||||
"version": "3.75.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-dev",
|
||||
"version": "3.74.0",
|
||||
"version": "3.75.0",
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
".",
|
||||
@@ -162,7 +162,7 @@
|
||||
},
|
||||
"cli": {
|
||||
"name": "cline",
|
||||
"version": "2.8.0",
|
||||
"version": "2.9.0",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "claude-dev",
|
||||
"displayName": "Cline",
|
||||
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
|
||||
"version": "3.74.0",
|
||||
"version": "3.75.0",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"workspaces": [
|
||||
".",
|
||||
|
||||
@@ -104,8 +104,8 @@ message Secrets {
|
||||
optional string oca_refresh_token = 42;
|
||||
optional string mcp_o_auth_secrets = 43;
|
||||
optional string cline_api_key = 44;
|
||||
optional string wandb_api_key = 50;
|
||||
optional string openai_codex_oauth_credentials = 48;
|
||||
optional string wandb_api_key = 50;
|
||||
}
|
||||
|
||||
// NOTE: Add new fields under API_HANDLER_SETTINGS_FIELDS or USER_SETTINGS_FIELDS
|
||||
@@ -280,11 +280,12 @@ message Settings {
|
||||
optional bool worktrees_enabled = 172;
|
||||
optional bool auto_approve_all_toggled = 174;
|
||||
optional bool double_check_completion_enabled = 176;
|
||||
map<string, string> open_ai_headers = 177;
|
||||
optional string plan_mode_cline_model_id = 178;
|
||||
optional OpenRouterModelInfo plan_mode_cline_model_info = 179;
|
||||
optional string act_mode_cline_model_id = 180;
|
||||
optional OpenRouterModelInfo act_mode_cline_model_info = 181;
|
||||
map<string, string> open_ai_headers = 177;
|
||||
optional bool show_feature_tips = 182;
|
||||
}
|
||||
|
||||
message State {
|
||||
@@ -426,6 +427,7 @@ message UpdateSettingsRequest {
|
||||
optional bool opt_out_of_remote_config = 39;
|
||||
optional bool worktrees_enabled = 40;
|
||||
optional bool double_check_completion_enabled = 41;
|
||||
optional bool show_feature_tips = 42;
|
||||
}
|
||||
|
||||
message UpdateTerminalConnectionTimeoutRequest {
|
||||
|
||||
@@ -54,6 +54,9 @@ message GetHostVersionResponse {
|
||||
optional string cline_type = 3;
|
||||
// The version of the cline host environment, e.g. 33.2.10 for extension, or 1.0.6 for JetBrains.
|
||||
optional string cline_version = 4;
|
||||
// The remote environment name when the host is connected to a remote workspace
|
||||
// (for example `ssh-remote`, `dev-container`, or `codespaces`).
|
||||
optional string remote_name = 5;
|
||||
}
|
||||
|
||||
enum Setting {
|
||||
|
||||
@@ -60,6 +60,54 @@ describe("OpenRouterHandler", () => {
|
||||
])
|
||||
})
|
||||
|
||||
it("should read cache_write_tokens from prompt_tokens_details", async () => {
|
||||
const handler = new OpenRouterHandler({
|
||||
openRouterApiKey: "test-api-key",
|
||||
})
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: sinon.stub().resolves(
|
||||
createAsyncIterable([
|
||||
{
|
||||
choices: [{}],
|
||||
usage: {
|
||||
prompt_tokens: 1000,
|
||||
completion_tokens: 200,
|
||||
prompt_tokens_details: {
|
||||
cached_tokens: 500,
|
||||
cache_write_tokens: 300,
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
sinon.stub(handler as any, "ensureClient").returns(fakeClient as any)
|
||||
sinon.stub(handler, "getModel").returns({
|
||||
id: "anthropic/claude-sonnet-4.6",
|
||||
info: openRouterDefaultModelInfo,
|
||||
})
|
||||
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
chunks.should.deepEqual([
|
||||
{
|
||||
type: "usage",
|
||||
cacheWriteTokens: 300,
|
||||
cacheReadTokens: 500,
|
||||
inputTokens: 200,
|
||||
outputTokens: 200,
|
||||
totalCost: 0,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
type ParallelToolCallsTestCase = {
|
||||
modelId: string
|
||||
enableParallelToolCalling: boolean
|
||||
|
||||
@@ -154,11 +154,16 @@ export class OpenRouterHandler implements ApiHandler {
|
||||
}
|
||||
|
||||
if (!didOutputUsage && chunk.usage) {
|
||||
// @ts-expect-error-next-line -- OpenRouter returns cache_write_tokens for Anthropic models
|
||||
const cacheWriteTokens = chunk.usage.prompt_tokens_details?.cache_write_tokens || 0
|
||||
yield {
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheWriteTokens,
|
||||
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
|
||||
inputTokens: (chunk.usage.prompt_tokens || 0) - (chunk.usage.prompt_tokens_details?.cached_tokens || 0),
|
||||
inputTokens:
|
||||
(chunk.usage.prompt_tokens || 0) -
|
||||
(chunk.usage.prompt_tokens_details?.cached_tokens || 0) -
|
||||
(cacheWriteTokens || 0),
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
// @ts-expect-error-next-line
|
||||
totalCost: (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0),
|
||||
@@ -185,7 +190,7 @@ export class OpenRouterHandler implements ApiHandler {
|
||||
// Logger.log("OpenRouter generation details:", generation)
|
||||
return {
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheWriteTokens: generation?.native_tokens_cache_write || 0,
|
||||
cacheReadTokens: generation?.native_tokens_cached || 0,
|
||||
// openrouter generation endpoint fails often
|
||||
inputTokens: (generation?.native_tokens_prompt || 0) - (generation?.native_tokens_cached || 0),
|
||||
|
||||
@@ -52,74 +52,41 @@ export async function createOpenRouterStream(
|
||||
openAiMessages = sanitizeGeminiMessages(openAiMessages, model.id)
|
||||
|
||||
// prompt caching: https://openrouter.ai/docs/prompt-caching
|
||||
// this was initially specifically for claude models (some models may 'support prompt caching' automatically without this)
|
||||
// handles direct model.id match logic
|
||||
switch (model.id) {
|
||||
case "anthropic/claude-opus-4.6":
|
||||
case "anthropic/claude-haiku-4.5":
|
||||
case "anthropic/claude-4.5-haiku":
|
||||
case "anthropic/claude-sonnet-4.6":
|
||||
case "anthropic/claude-4.6-sonnet":
|
||||
case "anthropic/claude-sonnet-4.5":
|
||||
case "anthropic/claude-4.5-sonnet": // OpenRouter accidentally included this in model list for a brief moment, and users may be using this model id. And to support prompt caching, we need to add it here.
|
||||
case "anthropic/claude-sonnet-4":
|
||||
case "anthropic/claude-opus-4.5":
|
||||
case "anthropic/claude-opus-4.1":
|
||||
case "anthropic/claude-opus-4":
|
||||
case "anthropic/claude-3.7-sonnet":
|
||||
case "anthropic/claude-3.7-sonnet:beta":
|
||||
case "anthropic/claude-3.7-sonnet:thinking":
|
||||
case "anthropic/claude-3-7-sonnet":
|
||||
case "anthropic/claude-3-7-sonnet:beta":
|
||||
case "anthropic/claude-3.5-sonnet":
|
||||
case "anthropic/claude-3.5-sonnet:beta":
|
||||
case "anthropic/claude-3.5-sonnet-20240620":
|
||||
case "anthropic/claude-3.5-sonnet-20240620:beta":
|
||||
case "anthropic/claude-3-5-haiku":
|
||||
case "anthropic/claude-3-5-haiku:beta":
|
||||
case "anthropic/claude-3-5-haiku-20241022":
|
||||
case "anthropic/claude-3-5-haiku-20241022:beta":
|
||||
case "anthropic/claude-3-haiku":
|
||||
case "anthropic/claude-3-haiku:beta":
|
||||
case "anthropic/claude-3-opus":
|
||||
case "anthropic/claude-3-opus:beta":
|
||||
case "minimax/minimax-m2":
|
||||
case "minimax/minimax-m2.1":
|
||||
case "minimax/minimax-m2.1-lightning":
|
||||
case "minimax/minimax-m2.5":
|
||||
openAiMessages[0] = {
|
||||
role: "system",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: systemPrompt,
|
||||
// @ts-expect-error-next-line
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
],
|
||||
}
|
||||
// Add cache_control to the last two user messages
|
||||
// (note: this works because we only ever add one user message at a time, but if we added multiple we'd need to mark the user message before the last assistant message)
|
||||
const lastTwoUserMessages = openAiMessages.filter((msg) => msg.role === "user").slice(-2)
|
||||
lastTwoUserMessages.forEach((msg) => {
|
||||
if (typeof msg.content === "string") {
|
||||
msg.content = [{ type: "text", text: msg.content }]
|
||||
}
|
||||
if (Array.isArray(msg.content)) {
|
||||
// NOTE: this is fine since env details will always be added at the end. but if it weren't there, and the user added a image_url type message, it would pop a text part before it and then move it after to the end.
|
||||
let lastTextPart = msg.content.filter((part) => part.type === "text").pop()
|
||||
// Anthropic and MiniMax models require explicit cache_control blocks to enable prompt caching on OpenRouter.
|
||||
// Other providers (OpenAI, Google) handle caching automatically without cache_control blocks.
|
||||
const needsCacheControl = model.id.startsWith("anthropic/") || model.id.startsWith("minimax/")
|
||||
|
||||
if (!lastTextPart) {
|
||||
lastTextPart = { type: "text", text: "..." }
|
||||
msg.content.push(lastTextPart)
|
||||
}
|
||||
if (needsCacheControl) {
|
||||
openAiMessages[0] = {
|
||||
role: "system",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: systemPrompt,
|
||||
// @ts-expect-error-next-line
|
||||
lastTextPart["cache_control"] = { type: "ephemeral" }
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
],
|
||||
}
|
||||
// Add cache_control to the last two user messages
|
||||
// (note: this works because we only ever add one user message at a time, but if we added multiple we'd need to mark the user message before the last assistant message)
|
||||
const lastTwoUserMessages = openAiMessages.filter((msg) => msg.role === "user").slice(-2)
|
||||
lastTwoUserMessages.forEach((msg) => {
|
||||
if (typeof msg.content === "string") {
|
||||
msg.content = [{ type: "text", text: msg.content }]
|
||||
}
|
||||
if (Array.isArray(msg.content)) {
|
||||
// NOTE: this is fine since env details will always be added at the end. but if it weren't there, and the user added a image_url type message, it would pop a text part before it and then move it after to the end.
|
||||
let lastTextPart = msg.content.filter((part) => part.type === "text").pop()
|
||||
|
||||
if (!lastTextPart) {
|
||||
lastTextPart = { type: "text", text: "..." }
|
||||
msg.content.push(lastTextPart)
|
||||
}
|
||||
})
|
||||
break
|
||||
default:
|
||||
break
|
||||
// @ts-expect-error-next-line
|
||||
lastTextPart["cache_control"] = { type: "ephemeral" }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let temperature: number | undefined = 0
|
||||
|
||||
@@ -1,40 +1,13 @@
|
||||
import { RefreshedSkills, SkillInfo } from "@shared/proto/cline/file"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { parseYamlFrontmatter } from "@/core/context/instructions/user-instructions/frontmatter"
|
||||
import { getSkillsDirectoriesForScan } from "@/core/storage/disk"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { fileExistsAtPath, isDirectory } from "@/utils/fs"
|
||||
import { Controller } from ".."
|
||||
|
||||
/**
|
||||
* Parse YAML frontmatter from markdown content.
|
||||
*/
|
||||
function parseFrontmatter(fileContent: string): { data: Record<string, unknown>; content: string } {
|
||||
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/
|
||||
const match = fileContent.match(frontmatterRegex)
|
||||
|
||||
if (!match) {
|
||||
return { data: {}, content: fileContent }
|
||||
}
|
||||
|
||||
const [, yamlContent, body] = match
|
||||
// Simple YAML parsing for name and description
|
||||
const data: Record<string, unknown> = {}
|
||||
const lines = yamlContent.split("\n")
|
||||
for (const line of lines) {
|
||||
const colonIndex = line.indexOf(":")
|
||||
if (colonIndex > 0) {
|
||||
const key = line.slice(0, colonIndex).trim()
|
||||
const value = line
|
||||
.slice(colonIndex + 1)
|
||||
.trim()
|
||||
.replace(/^["']|["']$/g, "")
|
||||
data[key] = value
|
||||
}
|
||||
}
|
||||
return { data, content: body }
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a directory for skill subdirectories containing SKILL.md files.
|
||||
*/
|
||||
@@ -58,7 +31,11 @@ async function scanSkillsDirectory(dirPath: string): Promise<SkillInfo[]> {
|
||||
|
||||
try {
|
||||
const fileContent = await fs.readFile(skillMdPath, "utf-8")
|
||||
const { data: frontmatter } = parseFrontmatter(fileContent)
|
||||
const result = parseYamlFrontmatter(fileContent)
|
||||
if (result.parseError) {
|
||||
Logger.warn("Failed to parse YAML frontmatter:", result.parseError)
|
||||
}
|
||||
const frontmatter = result.data
|
||||
|
||||
// Validate required fields
|
||||
if (!frontmatter.name || typeof frontmatter.name !== "string") continue
|
||||
|
||||
@@ -887,6 +887,7 @@ export class Controller {
|
||||
const lastDismissedCliBannerVersion = this.stateManager.getGlobalStateKey("lastDismissedCliBannerVersion") || 0
|
||||
const dismissedBanners = this.stateManager.getGlobalStateKey("dismissedBanners")
|
||||
const doubleCheckCompletionEnabled = this.stateManager.getGlobalSettingsKey("doubleCheckCompletionEnabled")
|
||||
const showFeatureTips = this.stateManager.getGlobalSettingsKey("showFeatureTips")
|
||||
|
||||
const localClineRulesToggles = this.stateManager.getWorkspaceStateKey("localClineRulesToggles")
|
||||
const localWindsurfRulesToggles = this.stateManager.getWorkspaceStateKey("localWindsurfRulesToggles")
|
||||
@@ -997,6 +998,7 @@ export class Controller {
|
||||
backgroundEditEnabled: this.stateManager.getGlobalSettingsKey("backgroundEditEnabled"),
|
||||
optOutOfRemoteConfig: this.stateManager.getGlobalSettingsKey("optOutOfRemoteConfig"),
|
||||
doubleCheckCompletionEnabled,
|
||||
showFeatureTips,
|
||||
banners,
|
||||
welcomeBanners,
|
||||
openAiCodexIsAuthenticated,
|
||||
|
||||
@@ -344,6 +344,10 @@ export async function updateSettings(controller: Controller, request: UpdateSett
|
||||
controller.stateManager.setGlobalState("doubleCheckCompletionEnabled", request.doubleCheckCompletionEnabled)
|
||||
}
|
||||
|
||||
if (request.showFeatureTips !== undefined) {
|
||||
controller.stateManager.setGlobalState("showFeatureTips", request.showFeatureTips)
|
||||
}
|
||||
|
||||
// Post updated state to webview
|
||||
await controller.postStateToWebview()
|
||||
|
||||
|
||||
@@ -3,14 +3,16 @@ import "should"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import sinon from "sinon"
|
||||
import { HookOutput } from "../../../shared/proto/cline/hooks"
|
||||
import { HookFactory } from "../hook-factory"
|
||||
import { createHookTestEnv, HookTestEnv, loadFixture, stubHookDirs, writeHookScriptForPlatform } from "./test-utils"
|
||||
import { createHookTestEnv, HookTestEnv, stubHookDirs, withFixtureRunner, writeHookScriptForPlatform } from "./test-utils"
|
||||
|
||||
describe("TaskComplete Hook", () => {
|
||||
let tempDir: string
|
||||
let sandbox: sinon.SinonSandbox
|
||||
let getEnv: () => { tempDir: string }
|
||||
let hookTestEnv: HookTestEnv
|
||||
const getErrorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error))
|
||||
|
||||
const writeHookScript = async (hookPath: string, nodeScript: string): Promise<void> => {
|
||||
await writeHookScriptForPlatform(hookPath, nodeScript)
|
||||
@@ -432,72 +434,73 @@ console.log(JSON.stringify({
|
||||
})
|
||||
|
||||
describe("Fixture-Based Tests", () => {
|
||||
it("should work with success fixture", async () => {
|
||||
await loadFixture("hooks/taskcomplete/success", getEnv().tempDir)
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("TaskComplete")
|
||||
|
||||
const result = await runner.run({
|
||||
taskId: "test-task-id",
|
||||
taskComplete: {
|
||||
taskMetadata: {
|
||||
taskId: "test-task-id",
|
||||
ulid: "test-ulid",
|
||||
result: "Test task",
|
||||
command: "",
|
||||
it("should validate representative fixtures end-to-end", async () => {
|
||||
const scenarios: Array<{
|
||||
fixtureName: string
|
||||
resultText: string
|
||||
assert: (result: HookOutput) => void
|
||||
}> = [
|
||||
{
|
||||
fixtureName: "success",
|
||||
resultText: "Test task",
|
||||
assert: (result: HookOutput) => {
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("TaskComplete hook executed successfully")
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("TaskComplete hook executed successfully")
|
||||
})
|
||||
|
||||
it("should work with error fixture", async () => {
|
||||
await loadFixture("hooks/taskcomplete/error", getEnv().tempDir)
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("TaskComplete")
|
||||
|
||||
try {
|
||||
await runner.run({
|
||||
taskId: "test-task-id",
|
||||
taskComplete: {
|
||||
taskMetadata: {
|
||||
taskId: "test-task-id",
|
||||
ulid: "test-ulid",
|
||||
result: "Test task",
|
||||
command: "",
|
||||
},
|
||||
{
|
||||
fixtureName: "context-injection",
|
||||
resultText: "Build a todo app",
|
||||
assert: (result: HookOutput) => {
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("COMPLETED: Build a todo app")
|
||||
},
|
||||
})
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.message.should.match(/TaskComplete.*exited with code 1/)
|
||||
},
|
||||
]
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
await withFixtureRunner(
|
||||
"TaskComplete",
|
||||
`hooks/taskcomplete/${scenario.fixtureName}`,
|
||||
hookTestEnv,
|
||||
async (runner) => {
|
||||
const result = await runner.run({
|
||||
taskId: "test-task-id",
|
||||
taskComplete: {
|
||||
taskMetadata: {
|
||||
taskId: "test-task-id",
|
||||
ulid: "test-ulid",
|
||||
result: scenario.resultText,
|
||||
command: "",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
scenario.assert(result)
|
||||
},
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it("should work with context-injection fixture", async () => {
|
||||
await loadFixture("hooks/taskcomplete/context-injection", getEnv().tempDir)
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("TaskComplete")
|
||||
|
||||
const result = await runner.run({
|
||||
taskId: "test-task-id",
|
||||
taskComplete: {
|
||||
taskMetadata: {
|
||||
it("should preserve fixture-based failure behavior", async () => {
|
||||
await withFixtureRunner("TaskComplete", "hooks/taskcomplete/error", hookTestEnv, async (runner) => {
|
||||
try {
|
||||
await runner.run({
|
||||
taskId: "test-task-id",
|
||||
ulid: "test-ulid",
|
||||
result: "Build a todo app",
|
||||
command: "",
|
||||
},
|
||||
},
|
||||
taskComplete: {
|
||||
taskMetadata: {
|
||||
taskId: "test-task-id",
|
||||
ulid: "test-ulid",
|
||||
result: "Test task",
|
||||
command: "",
|
||||
},
|
||||
},
|
||||
})
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: unknown) {
|
||||
getErrorMessage(error).should.match(/TaskComplete.*exited with code 1/)
|
||||
}
|
||||
})
|
||||
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("COMPLETED: Build a todo app")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,8 +3,9 @@ import "should"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import sinon from "sinon"
|
||||
import { HookOutput } from "../../../shared/proto/cline/hooks"
|
||||
import { HookFactory } from "../hook-factory"
|
||||
import { createHookTestEnv, HookTestEnv, stubHookDirs, writeHookScriptForPlatform } from "./test-utils"
|
||||
import { createHookTestEnv, HookTestEnv, stubHookDirs, withFixtureRunner, writeHookScriptForPlatform } from "./test-utils"
|
||||
|
||||
describe("TaskResume Hook", () => {
|
||||
let tempDir: string
|
||||
@@ -12,6 +13,16 @@ describe("TaskResume Hook", () => {
|
||||
let hookTestEnv: HookTestEnv
|
||||
const WINDOWS_HOOK_TEST_TIMEOUT_MS = 15000
|
||||
|
||||
type FixtureScenario = {
|
||||
fixtureName: string
|
||||
lastMessageTs: string
|
||||
messageCount: string
|
||||
conversationHistoryDeleted: string
|
||||
assert: (result: HookOutput) => void
|
||||
}
|
||||
|
||||
const getErrorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error))
|
||||
|
||||
const writeHookScript = async (hookPath: string, nodeScript: string): Promise<void> => {
|
||||
await writeHookScriptForPlatform(hookPath, nodeScript)
|
||||
}
|
||||
@@ -146,7 +157,11 @@ console.log(JSON.stringify({
|
||||
}
|
||||
})
|
||||
|
||||
it("should handle very old timestamps (days ago)", async () => {
|
||||
it("should handle very old timestamps (days ago)", async function () {
|
||||
if (process.platform === "win32") {
|
||||
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskResume")
|
||||
const hookScript = `#!/usr/bin/env node
|
||||
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
|
||||
@@ -327,7 +342,11 @@ console.log(JSON.stringify({
|
||||
result.contextModification?.should.equal("STALE_TASK: Long conversation paused for extended time")
|
||||
})
|
||||
|
||||
it("should combine context deletion with other state", async () => {
|
||||
it("should combine context deletion with other state", async function () {
|
||||
if (process.platform === "win32") {
|
||||
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskResume")
|
||||
const hookScript = `#!/usr/bin/env node
|
||||
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
|
||||
@@ -537,149 +556,114 @@ console.log(JSON.stringify({
|
||||
})
|
||||
|
||||
describe("Fixture-Based Tests", () => {
|
||||
const loadFixtureAndCreateRunner = async (fixtureName: string) => {
|
||||
const { loadFixture } = await import("./test-utils")
|
||||
await loadFixture(`hooks/taskresume/${fixtureName}`, tempDir)
|
||||
|
||||
const factory = new HookFactory()
|
||||
return await factory.create("TaskResume")
|
||||
}
|
||||
|
||||
it("should work with success fixture", async () => {
|
||||
const runner = await loadFixtureAndCreateRunner("success")
|
||||
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
taskResume: {
|
||||
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
|
||||
previousState: {
|
||||
lastMessageTs: Date.now().toString(),
|
||||
messageCount: "5",
|
||||
conversationHistoryDeleted: "false",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("TaskResume hook executed successfully")
|
||||
})
|
||||
|
||||
it("should work with recent-resume fixture", async () => {
|
||||
const runner = await loadFixtureAndCreateRunner("recent-resume")
|
||||
|
||||
const twoMinutesAgo = Date.now() - 2 * 60 * 1000
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
taskResume: {
|
||||
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
|
||||
previousState: {
|
||||
lastMessageTs: twoMinutesAgo.toString(),
|
||||
messageCount: "5",
|
||||
conversationHistoryDeleted: "false",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.match(/Recently paused task/)
|
||||
})
|
||||
|
||||
it("should work with long-pause fixture", async () => {
|
||||
const runner = await loadFixtureAndCreateRunner("long-pause")
|
||||
|
||||
const twoDaysAgo = Date.now() - 48 * 60 * 60 * 1000
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
taskResume: {
|
||||
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
|
||||
previousState: {
|
||||
lastMessageTs: twoDaysAgo.toString(),
|
||||
messageCount: "5",
|
||||
conversationHistoryDeleted: "false",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.match(/paused 48 hours ago/)
|
||||
})
|
||||
|
||||
it("should work with context-deleted fixture", async () => {
|
||||
const runner = await loadFixtureAndCreateRunner("context-deleted")
|
||||
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
taskResume: {
|
||||
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
|
||||
previousState: {
|
||||
lastMessageTs: Date.now().toString(),
|
||||
messageCount: "50",
|
||||
conversationHistoryDeleted: "true",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.match(/truncated/)
|
||||
})
|
||||
|
||||
it("should work with message-count fixture", async () => {
|
||||
const runner = await loadFixtureAndCreateRunner("message-count")
|
||||
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
taskResume: {
|
||||
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
|
||||
previousState: {
|
||||
lastMessageTs: Date.now().toString(),
|
||||
messageCount: "25",
|
||||
conversationHistoryDeleted: "false",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("TASK_CONTEXT: Resuming task with 25 previous messages")
|
||||
})
|
||||
|
||||
it("should work with context-injection fixture", async () => {
|
||||
const runner = await loadFixtureAndCreateRunner("context-injection")
|
||||
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
taskResume: {
|
||||
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
|
||||
previousState: {
|
||||
lastMessageTs: Date.now().toString(),
|
||||
messageCount: "5",
|
||||
conversationHistoryDeleted: "false",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("WORKSPACE_RULES: Task test-task resumed - review previous context")
|
||||
})
|
||||
|
||||
it("should work with error fixture", async () => {
|
||||
const runner = await loadFixtureAndCreateRunner("error")
|
||||
|
||||
try {
|
||||
await runner.run({
|
||||
taskId: "test-task",
|
||||
taskResume: {
|
||||
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
|
||||
previousState: {
|
||||
lastMessageTs: Date.now().toString(),
|
||||
messageCount: "5",
|
||||
conversationHistoryDeleted: "false",
|
||||
},
|
||||
},
|
||||
})
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.message.should.match(/exited with code 1/)
|
||||
it("should validate representative fixtures end-to-end", async function () {
|
||||
if (process.platform === "win32") {
|
||||
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
const scenarios: FixtureScenario[] = [
|
||||
{
|
||||
fixtureName: "success",
|
||||
lastMessageTs: Date.now().toString(),
|
||||
messageCount: "5",
|
||||
conversationHistoryDeleted: "false",
|
||||
assert: (result: HookOutput) => {
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("TaskResume hook executed successfully")
|
||||
},
|
||||
},
|
||||
{
|
||||
fixtureName: "recent-resume",
|
||||
lastMessageTs: (Date.now() - 2 * 60 * 1000).toString(),
|
||||
messageCount: "5",
|
||||
conversationHistoryDeleted: "false",
|
||||
assert: (result: HookOutput) => {
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.match(/Recently paused task/)
|
||||
},
|
||||
},
|
||||
{
|
||||
fixtureName: "long-pause",
|
||||
lastMessageTs: (Date.now() - 48 * 60 * 60 * 1000).toString(),
|
||||
messageCount: "5",
|
||||
conversationHistoryDeleted: "false",
|
||||
assert: (result: HookOutput) => {
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.match(/paused 48 hours ago/)
|
||||
},
|
||||
},
|
||||
{
|
||||
fixtureName: "context-deleted",
|
||||
lastMessageTs: Date.now().toString(),
|
||||
messageCount: "50",
|
||||
conversationHistoryDeleted: "true",
|
||||
assert: (result: HookOutput) => {
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.match(/truncated/)
|
||||
},
|
||||
},
|
||||
{
|
||||
fixtureName: "message-count",
|
||||
lastMessageTs: Date.now().toString(),
|
||||
messageCount: "25",
|
||||
conversationHistoryDeleted: "false",
|
||||
assert: (result: HookOutput) => {
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("TASK_CONTEXT: Resuming task with 25 previous messages")
|
||||
},
|
||||
},
|
||||
{
|
||||
fixtureName: "context-injection",
|
||||
lastMessageTs: Date.now().toString(),
|
||||
messageCount: "5",
|
||||
conversationHistoryDeleted: "false",
|
||||
assert: (result: HookOutput) => {
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal(
|
||||
"WORKSPACE_RULES: Task test-task resumed - review previous context",
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
await withFixtureRunner("TaskResume", `hooks/taskresume/${scenario.fixtureName}`, hookTestEnv, async (runner) => {
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
taskResume: {
|
||||
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
|
||||
previousState: {
|
||||
lastMessageTs: scenario.lastMessageTs,
|
||||
messageCount: scenario.messageCount,
|
||||
conversationHistoryDeleted: scenario.conversationHistoryDeleted,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
scenario.assert(result)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it("should preserve fixture-based failure behavior", async () => {
|
||||
await withFixtureRunner("TaskResume", "hooks/taskresume/error", hookTestEnv, async (runner) => {
|
||||
try {
|
||||
await runner.run({
|
||||
taskId: "test-task",
|
||||
taskResume: {
|
||||
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
|
||||
previousState: {
|
||||
lastMessageTs: Date.now().toString(),
|
||||
messageCount: "5",
|
||||
conversationHistoryDeleted: "false",
|
||||
},
|
||||
},
|
||||
})
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: unknown) {
|
||||
getErrorMessage(error).should.match(/exited with code 1/)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,14 +3,17 @@ import "should"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import sinon from "sinon"
|
||||
import { HookOutput } from "../../../shared/proto/cline/hooks"
|
||||
import { HookFactory } from "../hook-factory"
|
||||
import { createHookTestEnv, HookTestEnv, loadFixture, stubHookDirs, writeHookScriptForPlatform } from "./test-utils"
|
||||
import { createHookTestEnv, HookTestEnv, stubHookDirs, withFixtureRunner, writeHookScriptForPlatform } from "./test-utils"
|
||||
|
||||
describe("TaskStart Hook", () => {
|
||||
let tempDir: string
|
||||
let sandbox: sinon.SinonSandbox
|
||||
let getEnv: () => { tempDir: string }
|
||||
let hookTestEnv: HookTestEnv
|
||||
const WINDOWS_HOOK_TEST_TIMEOUT_MS = 15000
|
||||
const getErrorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error))
|
||||
|
||||
const writeHookScript = async (hookPath: string, nodeScript: string): Promise<void> => {
|
||||
await writeHookScriptForPlatform(hookPath, nodeScript)
|
||||
@@ -29,7 +32,11 @@ describe("TaskStart Hook", () => {
|
||||
})
|
||||
|
||||
describe("Hook Input Format", () => {
|
||||
it("should receive task metadata from startTask", async () => {
|
||||
it("should receive task metadata from startTask", async function () {
|
||||
if (process.platform === "win32") {
|
||||
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskStart")
|
||||
const hookScript = `#!/usr/bin/env node
|
||||
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
|
||||
@@ -421,69 +428,60 @@ console.log(JSON.stringify({
|
||||
})
|
||||
|
||||
describe("Fixture-Based Tests", () => {
|
||||
it("should work with success fixture", async () => {
|
||||
await loadFixture("hooks/taskstart/success", getEnv().tempDir)
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("TaskStart")
|
||||
|
||||
const result = await runner.run({
|
||||
taskId: "test-task-id",
|
||||
taskStart: {
|
||||
taskMetadata: {
|
||||
taskId: "test-task-id",
|
||||
ulid: "test-ulid",
|
||||
initialTask: "Test task",
|
||||
it("should validate representative fixtures end-to-end", async () => {
|
||||
const scenarios: Array<{ fixtureName: string; assert: (result: HookOutput) => void }> = [
|
||||
{
|
||||
fixtureName: "success",
|
||||
assert: (result: HookOutput) => {
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("TaskStart hook executed successfully")
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("TaskStart hook executed successfully")
|
||||
})
|
||||
|
||||
it("should work with blocking fixture", async () => {
|
||||
await loadFixture("hooks/taskstart/blocking", getEnv().tempDir)
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("TaskStart")
|
||||
|
||||
const result = await runner.run({
|
||||
taskId: "test-task-id",
|
||||
taskStart: {
|
||||
taskMetadata: {
|
||||
taskId: "test-task-id",
|
||||
ulid: "test-ulid",
|
||||
initialTask: "Test task",
|
||||
{
|
||||
fixtureName: "blocking",
|
||||
assert: (result: HookOutput) => {
|
||||
result.cancel.should.be.true()
|
||||
result.errorMessage?.should.equal("Task execution blocked by hook")
|
||||
},
|
||||
},
|
||||
})
|
||||
]
|
||||
|
||||
result.cancel.should.be.true()
|
||||
result.errorMessage?.should.equal("Task execution blocked by hook")
|
||||
})
|
||||
|
||||
it("should work with error fixture", async () => {
|
||||
await loadFixture("hooks/taskstart/error", getEnv().tempDir)
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("TaskStart")
|
||||
|
||||
try {
|
||||
await runner.run({
|
||||
taskId: "test-task-id",
|
||||
taskStart: {
|
||||
taskMetadata: {
|
||||
taskId: "test-task-id",
|
||||
ulid: "test-ulid",
|
||||
initialTask: "Test task",
|
||||
for (const scenario of scenarios) {
|
||||
await withFixtureRunner("TaskStart", `hooks/taskstart/${scenario.fixtureName}`, hookTestEnv, async (runner) => {
|
||||
const result = await runner.run({
|
||||
taskId: "test-task-id",
|
||||
taskStart: {
|
||||
taskMetadata: {
|
||||
taskId: "test-task-id",
|
||||
ulid: "test-ulid",
|
||||
initialTask: "Test task",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
scenario.assert(result)
|
||||
})
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.message.should.match(/TaskStart.*exited with code 1/)
|
||||
}
|
||||
})
|
||||
|
||||
it("should preserve fixture-based failure behavior", async () => {
|
||||
await withFixtureRunner("TaskStart", "hooks/taskstart/error", hookTestEnv, async (runner) => {
|
||||
try {
|
||||
await runner.run({
|
||||
taskId: "test-task-id",
|
||||
taskStart: {
|
||||
taskMetadata: {
|
||||
taskId: "test-task-id",
|
||||
ulid: "test-ulid",
|
||||
initialTask: "Test task",
|
||||
},
|
||||
},
|
||||
})
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: unknown) {
|
||||
getErrorMessage(error).should.match(/TaskStart.*exited with code 1/)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ import { HookOutput } from "../../../shared/proto/cline/hooks"
|
||||
import * as diskModule from "../../storage/disk"
|
||||
import { StateManager } from "../../storage/StateManager"
|
||||
import { HookDiscoveryCache } from "../HookDiscoveryCache"
|
||||
import { Hooks, NamedHookInput } from "../hook-factory"
|
||||
import { HookFactory, Hooks, NamedHookInput } from "../hook-factory"
|
||||
|
||||
// Define HookName locally since it's not exported from hook-factory
|
||||
type HookName = keyof Hooks
|
||||
@@ -576,3 +576,49 @@ export async function loadFixture(fixtureName: string, destDir: string): Promise
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an isolated hook test environment, loads a fixture into it, creates a runner,
|
||||
* and guarantees cleanup once the callback completes.
|
||||
*
|
||||
* This is useful for fixture suites that want to iterate through multiple scenarios
|
||||
* without sharing hook directories, discovery cache state, or filesystem artifacts
|
||||
* between scenarios.
|
||||
*/
|
||||
export async function withFixtureRunner<Name extends HookName, TResult>(
|
||||
hookName: Name,
|
||||
fixtureName: string,
|
||||
callback: (runner: Awaited<ReturnType<HookFactory["create"]>>, env: HookTestEnv) => Promise<TResult>,
|
||||
): Promise<TResult>
|
||||
export async function withFixtureRunner<Name extends HookName, TResult>(
|
||||
hookName: Name,
|
||||
fixtureName: string,
|
||||
env: HookTestEnv,
|
||||
callback: (runner: Awaited<ReturnType<HookFactory["create"]>>, env: HookTestEnv) => Promise<TResult>,
|
||||
): Promise<TResult>
|
||||
export async function withFixtureRunner<Name extends HookName, TResult>(
|
||||
hookName: Name,
|
||||
fixtureName: string,
|
||||
envOrCallback: HookTestEnv | ((runner: Awaited<ReturnType<HookFactory["create"]>>, env: HookTestEnv) => Promise<TResult>),
|
||||
maybeCallback?: (runner: Awaited<ReturnType<HookFactory["create"]>>, env: HookTestEnv) => Promise<TResult>,
|
||||
): Promise<TResult> {
|
||||
const usingExistingEnv = typeof envOrCallback !== "function"
|
||||
const env = usingExistingEnv ? envOrCallback : await createHookTestEnv()
|
||||
const runCallback = usingExistingEnv ? maybeCallback : envOrCallback
|
||||
if (!runCallback) {
|
||||
throw new Error("withFixtureRunner requires a callback")
|
||||
}
|
||||
try {
|
||||
await fs.rm(env.hooksDir, { recursive: true, force: true })
|
||||
await createHooksDirectory(env.tempDir)
|
||||
resetHookCache()
|
||||
await loadFixture(fixtureName, env.tempDir)
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create(hookName)
|
||||
return await runCallback(runner, env)
|
||||
} finally {
|
||||
if (!usingExistingEnv) {
|
||||
await env.cleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,23 @@ import "should"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import sinon from "sinon"
|
||||
import { HookOutput } from "../../../shared/proto/cline/hooks"
|
||||
import { HookFactory } from "../hook-factory"
|
||||
import { createHookTestEnv, HookTestEnv, stubHookDirs, writeHookScriptForPlatform } from "./test-utils"
|
||||
import { createHookTestEnv, HookTestEnv, stubHookDirs, withFixtureRunner, writeHookScriptForPlatform } from "./test-utils"
|
||||
|
||||
describe("UserPromptSubmit Hook", () => {
|
||||
let tempDir: string
|
||||
let sandbox: sinon.SinonSandbox
|
||||
let hookTestEnv: HookTestEnv
|
||||
const WINDOWS_HOOK_TEST_TIMEOUT_MS = 15000
|
||||
|
||||
type FixtureScenario = {
|
||||
fixtureName: string
|
||||
prompt: string
|
||||
assert: (result: HookOutput) => void
|
||||
}
|
||||
|
||||
const getErrorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error))
|
||||
|
||||
const writeHookScript = async (hookPath: string, nodeScript: string): Promise<void> => {
|
||||
await writeHookScriptForPlatform(hookPath, nodeScript)
|
||||
@@ -245,8 +255,8 @@ process.exit(1)`
|
||||
},
|
||||
})
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.message.should.match(/exited with code 1/)
|
||||
} catch (error: unknown) {
|
||||
getErrorMessage(error).should.match(/exited with code 1/)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -358,161 +368,127 @@ console.log(JSON.stringify({
|
||||
describe("Fixture-Based Tests", () => {
|
||||
// These tests demonstrate using pre-written fixtures from the fixtures directory
|
||||
// Fixtures serve as both test data and examples for manual testing
|
||||
const isWindows = process.platform === "win32"
|
||||
|
||||
// Helper to load a fixture and create a runner
|
||||
const loadFixtureAndCreateRunner = async (fixtureName: string) => {
|
||||
const { loadFixture } = await import("./test-utils")
|
||||
await loadFixture(`hooks/userpromptsubmit/${fixtureName}`, tempDir)
|
||||
it("should validate representative fixtures end-to-end", async function () {
|
||||
if (isWindows) {
|
||||
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
const factory = new HookFactory()
|
||||
return await factory.create("UserPromptSubmit")
|
||||
}
|
||||
|
||||
it("should work with success fixture", async () => {
|
||||
const runner = await loadFixtureAndCreateRunner("success")
|
||||
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
userPromptSubmit: {
|
||||
const scenarios: FixtureScenario[] = [
|
||||
{
|
||||
fixtureName: "success",
|
||||
prompt: "Create a feature",
|
||||
attachments: [],
|
||||
assert: (result: HookOutput) => {
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("Prompt approved")
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("Prompt approved")
|
||||
})
|
||||
|
||||
it("should work with blocking fixture", async () => {
|
||||
const runner = await loadFixtureAndCreateRunner("blocking")
|
||||
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
userPromptSubmit: {
|
||||
{
|
||||
fixtureName: "blocking",
|
||||
prompt: "Do something forbidden",
|
||||
attachments: [],
|
||||
assert: (result: HookOutput) => {
|
||||
result.cancel.should.be.true()
|
||||
result.errorMessage?.should.equal("Prompt violates policy")
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
result.cancel.should.be.true()
|
||||
result.errorMessage?.should.equal("Prompt violates policy")
|
||||
})
|
||||
|
||||
it("should work with context-injection fixture", async () => {
|
||||
const runner = await loadFixtureAndCreateRunner("context-injection")
|
||||
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
userPromptSubmit: {
|
||||
{
|
||||
fixtureName: "context-injection",
|
||||
prompt: "Build something",
|
||||
attachments: [],
|
||||
assert: (result: HookOutput) => {
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("CONTEXT_INJECTION: User is in plan mode")
|
||||
},
|
||||
},
|
||||
})
|
||||
{
|
||||
fixtureName: "multiline",
|
||||
prompt: "Line 1\nLine 2\nLine 3",
|
||||
assert: (result: HookOutput) => {
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("Line count: 3")
|
||||
},
|
||||
},
|
||||
{
|
||||
fixtureName: "special-chars",
|
||||
prompt: "Test @user #feature $cost",
|
||||
assert: (result: HookOutput) => {
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("Special chars preserved")
|
||||
},
|
||||
},
|
||||
{
|
||||
fixtureName: "empty-prompt",
|
||||
prompt: "",
|
||||
assert: (result: HookOutput) => {
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("Prompt length: 0")
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("CONTEXT_INJECTION: User is in plan mode")
|
||||
if (!isWindows) {
|
||||
scenarios.push({
|
||||
fixtureName: "large-prompt",
|
||||
prompt: "x".repeat(10000),
|
||||
assert: (result: HookOutput) => {
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("Prompt size: 10000")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
await withFixtureRunner(
|
||||
"UserPromptSubmit",
|
||||
`hooks/userpromptsubmit/${scenario.fixtureName}`,
|
||||
hookTestEnv,
|
||||
async (runner) => {
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
userPromptSubmit: {
|
||||
prompt: scenario.prompt,
|
||||
attachments: [],
|
||||
},
|
||||
})
|
||||
|
||||
scenario.assert(result)
|
||||
},
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it("should work with error fixture", async () => {
|
||||
const runner = await loadFixtureAndCreateRunner("error")
|
||||
|
||||
try {
|
||||
await runner.run({
|
||||
it("should cover malformed-json fixture path", async () => {
|
||||
await withFixtureRunner("UserPromptSubmit", "hooks/userpromptsubmit/malformed-json", hookTestEnv, async (runner) => {
|
||||
const malformedResult = await runner.run({
|
||||
taskId: "test-task",
|
||||
userPromptSubmit: {
|
||||
prompt: "Test",
|
||||
attachments: [],
|
||||
},
|
||||
})
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.message.should.match(/exited with code 1/)
|
||||
}
|
||||
|
||||
malformedResult.cancel.should.be.false()
|
||||
;(
|
||||
malformedResult.contextModification === undefined || malformedResult.contextModification === ""
|
||||
).should.be.true()
|
||||
})
|
||||
})
|
||||
|
||||
it("should work with malformed-json fixture", async () => {
|
||||
const runner = await loadFixtureAndCreateRunner("malformed-json")
|
||||
|
||||
// When hook exits 0 but has malformed JSON, it returns success without context
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
userPromptSubmit: {
|
||||
prompt: "Test",
|
||||
attachments: [],
|
||||
},
|
||||
it("should cover failing fixture path", async () => {
|
||||
await withFixtureRunner("UserPromptSubmit", "hooks/userpromptsubmit/error", hookTestEnv, async (runner) => {
|
||||
try {
|
||||
await runner.run({
|
||||
taskId: "test-task",
|
||||
userPromptSubmit: {
|
||||
prompt: "Test",
|
||||
attachments: [],
|
||||
},
|
||||
})
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: unknown) {
|
||||
getErrorMessage(error).should.match(/exited with code 1/)
|
||||
}
|
||||
})
|
||||
|
||||
// Hook succeeded (exit 0) but couldn't parse JSON, so returns success without context
|
||||
result.cancel.should.be.false()
|
||||
;(result.contextModification === undefined || result.contextModification === "").should.be.true()
|
||||
})
|
||||
|
||||
it("should work with multiline fixture", async () => {
|
||||
const runner = await loadFixtureAndCreateRunner("multiline")
|
||||
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
userPromptSubmit: {
|
||||
prompt: "Line 1\nLine 2\nLine 3",
|
||||
attachments: [],
|
||||
},
|
||||
})
|
||||
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("Line count: 3")
|
||||
})
|
||||
|
||||
it("should work with large-prompt fixture", async function () {
|
||||
// On Windows this fixture path duplicates coverage from
|
||||
// "should handle large prompts" and can be timing-sensitive due to
|
||||
// PowerShell process startup in CI.
|
||||
if (process.platform === "win32") {
|
||||
this.skip()
|
||||
}
|
||||
|
||||
const runner = await loadFixtureAndCreateRunner("large-prompt")
|
||||
|
||||
const largePrompt = "x".repeat(10000)
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
userPromptSubmit: {
|
||||
prompt: largePrompt,
|
||||
attachments: [],
|
||||
},
|
||||
})
|
||||
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("Prompt size: 10000")
|
||||
})
|
||||
|
||||
it("should work with special-chars fixture", async () => {
|
||||
const runner = await loadFixtureAndCreateRunner("special-chars")
|
||||
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
userPromptSubmit: {
|
||||
prompt: "Test @user #feature $cost",
|
||||
attachments: [],
|
||||
},
|
||||
})
|
||||
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("Special chars preserved")
|
||||
})
|
||||
|
||||
it("should work with empty-prompt fixture", async () => {
|
||||
const runner = await loadFixtureAndCreateRunner("empty-prompt")
|
||||
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
userPromptSubmit: {
|
||||
prompt: "",
|
||||
attachments: [],
|
||||
},
|
||||
})
|
||||
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("Prompt length: 0")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it } from "mocha"
|
||||
import "should"
|
||||
import { formatResponse } from "../responses"
|
||||
|
||||
describe("formatResponse.replaceInFileMissingDiffError", () => {
|
||||
it("should include the file path in the error message", () => {
|
||||
const result = formatResponse.replaceInFileMissingDiffError("src/index.ts")
|
||||
result.should.containEql("src/index.ts")
|
||||
})
|
||||
|
||||
it("should mention that the diff parameter was empty", () => {
|
||||
const result = formatResponse.replaceInFileMissingDiffError("src/index.ts")
|
||||
result.should.containEql("'diff' parameter was empty")
|
||||
})
|
||||
|
||||
it("should include the SEARCH/REPLACE block format", () => {
|
||||
const result = formatResponse.replaceInFileMissingDiffError("src/index.ts")
|
||||
result.should.containEql("<<<<<<< SEARCH")
|
||||
result.should.containEql("=======")
|
||||
result.should.containEql(">>>>>>> REPLACE")
|
||||
})
|
||||
|
||||
it("should include rules about exact matching", () => {
|
||||
const result = formatResponse.replaceInFileMissingDiffError("src/index.ts")
|
||||
result.should.containEql("match existing file content exactly")
|
||||
})
|
||||
|
||||
it("should suggest using read_file if unsure", () => {
|
||||
const result = formatResponse.replaceInFileMissingDiffError("src/index.ts")
|
||||
result.should.containEql("read_file")
|
||||
})
|
||||
|
||||
it("should NOT include the generic toolUseInstructionsReminder", () => {
|
||||
const result = formatResponse.replaceInFileMissingDiffError("src/index.ts")
|
||||
result.should.not.containEql("Reminder: Instructions for Tool Use")
|
||||
})
|
||||
|
||||
it("should work with different file paths", () => {
|
||||
const result = formatResponse.replaceInFileMissingDiffError("components/App.tsx")
|
||||
result.should.containEql("components/App.tsx")
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatResponse.executeCommandMissingCommandError", () => {
|
||||
it("should mention that the command parameter was empty", () => {
|
||||
const result = formatResponse.executeCommandMissingCommandError()
|
||||
result.should.containEql("'command' parameter was empty")
|
||||
})
|
||||
|
||||
it("should include a concrete XML example", () => {
|
||||
const result = formatResponse.executeCommandMissingCommandError()
|
||||
result.should.containEql("<execute_command>")
|
||||
result.should.containEql("<command>")
|
||||
result.should.containEql("</command>")
|
||||
result.should.containEql("</execute_command>")
|
||||
})
|
||||
|
||||
it("should include requires_approval in the example", () => {
|
||||
const result = formatResponse.executeCommandMissingCommandError()
|
||||
result.should.containEql("<requires_approval>")
|
||||
})
|
||||
|
||||
it("should NOT include the generic toolUseInstructionsReminder", () => {
|
||||
const result = formatResponse.executeCommandMissingCommandError()
|
||||
result.should.not.containEql("Reminder: Instructions for Tool Use")
|
||||
})
|
||||
})
|
||||
@@ -96,6 +96,33 @@ Otherwise, if you have not completed the task and do not need additional informa
|
||||
)
|
||||
},
|
||||
|
||||
replaceInFileMissingDiffError: (relPath: string): string => {
|
||||
return (
|
||||
`Failed to edit '${relPath}': The 'diff' parameter was empty.\n\n` +
|
||||
`The diff parameter must contain SEARCH/REPLACE blocks in this format:\n` +
|
||||
"<<<<<<< SEARCH\n" +
|
||||
"exact lines to find\n" +
|
||||
"=======\n" +
|
||||
"replacement lines\n" +
|
||||
">>>>>>> REPLACE\n\n" +
|
||||
`Rules:\n` +
|
||||
`- The SEARCH block must match existing file content exactly (including whitespace and indentation)\n` +
|
||||
`- You can include multiple SEARCH/REPLACE blocks in a single diff parameter\n` +
|
||||
`- If you're unsure of the exact content, use read_file first to see the current file`
|
||||
)
|
||||
},
|
||||
|
||||
executeCommandMissingCommandError: (): string => {
|
||||
return (
|
||||
"The 'command' parameter was empty. Provide the shell command to execute.\n\n" +
|
||||
"Example:\n" +
|
||||
"<execute_command>\n" +
|
||||
"<command>cd /path && python -m pytest tests/</command>\n" +
|
||||
"<requires_approval>false</requires_approval>\n" +
|
||||
"</execute_command>"
|
||||
)
|
||||
},
|
||||
|
||||
invalidMcpToolArgumentError: (serverName: string, toolName: string) =>
|
||||
`Invalid JSON argument used with ${serverName} for ${toolName}. Please retry with a properly formatted JSON argument.`,
|
||||
|
||||
@@ -279,6 +306,9 @@ Otherwise, if you have not completed the task and do not need additional informa
|
||||
toolAlreadyUsed: (toolName: string) =>
|
||||
`Tool [${toolName}] was not executed because a tool has already been used in this message. Only one tool may be used per message. You must assess the first tool's result before proceeding to use the next tool.`,
|
||||
|
||||
repeatedToolCall: (toolName: string, count: number) =>
|
||||
`Tool [${toolName}] has been called ${count} times consecutively with identical arguments. This is not making progress. Please use a different tool or different arguments instead of repeating the same call.`,
|
||||
|
||||
clineIgnoreInstructions: (content: string) =>
|
||||
`# .clineignore\n\n(The following is provided by a root-level .clineignore file where the user has specified files and directories that should not be accessed. When using list_files, you'll notice a ${LOCK_TEXT_SYMBOL} next to files that are blocked. Attempting to access the file's contents e.g. through read_file will result in an error.)\n\n${content}\n.clineignore`,
|
||||
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import type { PresentationPriority } from "./presentation-types"
|
||||
|
||||
export type { PresentationPriority }
|
||||
|
||||
type TaskPresentationSchedulerOptions = {
|
||||
flush: () => Promise<void>
|
||||
getDelayMs: (priority: PresentationPriority) => number
|
||||
setTimeoutFn?: typeof setTimeout
|
||||
clearTimeoutFn?: typeof clearTimeout
|
||||
onFlushError?: (error: unknown) => void
|
||||
}
|
||||
|
||||
export class TaskPresentationScheduler {
|
||||
private scheduledTimer: ReturnType<typeof setTimeout> | undefined
|
||||
private scheduledPriority: PresentationPriority | undefined
|
||||
private pendingPriority: PresentationPriority | undefined
|
||||
private flushInProgress = false
|
||||
private currentFlushCompletion: Promise<{ error?: unknown }> | undefined
|
||||
private disposed = false
|
||||
|
||||
private readonly flush: () => Promise<void>
|
||||
private readonly getDelayMs: (priority: PresentationPriority) => number
|
||||
private readonly setTimeoutFn: typeof setTimeout
|
||||
private readonly clearTimeoutFn: typeof clearTimeout
|
||||
private readonly onFlushError?: (error: unknown) => void
|
||||
|
||||
constructor(options: TaskPresentationSchedulerOptions) {
|
||||
this.flush = options.flush
|
||||
this.getDelayMs = options.getDelayMs
|
||||
this.setTimeoutFn = options.setTimeoutFn ?? setTimeout
|
||||
this.clearTimeoutFn = options.clearTimeoutFn ?? clearTimeout
|
||||
this.onFlushError = options.onFlushError
|
||||
}
|
||||
|
||||
requestFlush(priority: PresentationPriority = "normal"): void {
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
|
||||
this.pendingPriority = this.mergePriority(this.pendingPriority, priority)
|
||||
|
||||
if (this.flushInProgress) {
|
||||
// pendingPriority is already set above; runFlushCycle's post-flush
|
||||
// continuation will pick it up after the in-flight flush completes.
|
||||
return
|
||||
}
|
||||
|
||||
if (this.pendingPriority === "immediate") {
|
||||
if (this.scheduledTimer) {
|
||||
this.clearTimeoutFn(this.scheduledTimer)
|
||||
this.scheduledTimer = undefined
|
||||
this.scheduledPriority = undefined
|
||||
}
|
||||
void this.runFlushCycle({ rethrowErrors: false })
|
||||
return
|
||||
}
|
||||
|
||||
const nextPriority = this.pendingPriority ?? "normal"
|
||||
|
||||
if (this.scheduledTimer) {
|
||||
if (this.scheduledPriority === nextPriority) {
|
||||
return
|
||||
}
|
||||
|
||||
this.clearTimeoutFn(this.scheduledTimer)
|
||||
this.scheduledTimer = undefined
|
||||
this.scheduledPriority = undefined
|
||||
}
|
||||
|
||||
if (!this.pendingPriority) {
|
||||
return
|
||||
}
|
||||
|
||||
const delayMs = this.getDelayMs(nextPriority)
|
||||
this.scheduledPriority = nextPriority
|
||||
this.scheduledTimer = this.setTimeoutFn(() => {
|
||||
this.scheduledTimer = undefined
|
||||
this.scheduledPriority = undefined
|
||||
void this.runFlushCycle({ rethrowErrors: false })
|
||||
}, delayMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush immediately and await completion.
|
||||
*
|
||||
* Guarantees that at least one flush runs at "immediate" priority after this
|
||||
* call returns, even if a concurrent flush cycle consumed the pending priority
|
||||
* before this call could start its own cycle.
|
||||
*
|
||||
* If the scheduler has already been disposed this is a no-op and resolves
|
||||
* without error. Callers that need a guarantee that the final presentation
|
||||
* was delivered should ensure `dispose()` has not been called before
|
||||
* invoking `flushNow()` (the task streaming finalization path does this
|
||||
* correctly because `dispose()` is only called during `abortTask()`).
|
||||
*/
|
||||
async flushNow(): Promise<void> {
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.scheduledTimer) {
|
||||
this.clearTimeoutFn(this.scheduledTimer)
|
||||
this.scheduledTimer = undefined
|
||||
this.scheduledPriority = undefined
|
||||
}
|
||||
|
||||
// If a flush is already in-flight, wait for it to complete. After it
|
||||
// finishes, the post-flush continuation in runFlushCycle may have already
|
||||
// consumed our pendingPriority. We therefore set pendingPriority *after*
|
||||
// the in-flight flush resolves so it cannot be stolen by the continuation.
|
||||
if (this.flushInProgress) {
|
||||
await (this.currentFlushCompletion ?? Promise.resolve())
|
||||
// Another concurrent caller may have started a new flush cycle after
|
||||
// the same in-flight flush resolved. If one is now in progress, wait
|
||||
// for it too — we need a flush to run *after* we set pendingPriority.
|
||||
while (this.flushInProgress) {
|
||||
await (this.currentFlushCompletion ?? Promise.resolve())
|
||||
}
|
||||
}
|
||||
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
|
||||
// Now that no flush is in-flight, set pendingPriority and run our own cycle.
|
||||
this.pendingPriority = this.mergePriority(this.pendingPriority, "immediate")
|
||||
await this.runFlushCycle({ rethrowErrors: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel any pending timers and clear queued state without marking the scheduler
|
||||
* as disposed. Use this between API request retries within the same task to prevent
|
||||
* stale timers from firing against reset streaming state.
|
||||
*
|
||||
* Note: any flush that is already in-flight when reset() is called will complete
|
||||
* naturally. The flush callback (presentAssistantMessage) will operate on the
|
||||
* already-reset task state, but since currentStreamingContentIndex will be 0 and
|
||||
* assistantMessageContent will be empty, it will hit the out-of-bounds early-return
|
||||
* path and do nothing harmful.
|
||||
*/
|
||||
reset(): void {
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
if (this.scheduledTimer) {
|
||||
this.clearTimeoutFn(this.scheduledTimer)
|
||||
this.scheduledTimer = undefined
|
||||
}
|
||||
this.scheduledPriority = undefined
|
||||
this.pendingPriority = undefined
|
||||
// Note: we intentionally do NOT clear flushInProgress or currentFlushCompletion
|
||||
// here. If a flush is in-flight it will complete naturally. The reset only
|
||||
// prevents *new* timer-driven flushes from firing on stale state.
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.disposed = true
|
||||
if (this.scheduledTimer) {
|
||||
this.clearTimeoutFn(this.scheduledTimer)
|
||||
this.scheduledTimer = undefined
|
||||
}
|
||||
this.scheduledPriority = undefined
|
||||
this.pendingPriority = undefined
|
||||
|
||||
const inFlightFlush = this.currentFlushCompletion
|
||||
if (inFlightFlush) {
|
||||
await inFlightFlush
|
||||
}
|
||||
}
|
||||
|
||||
private async runFlushCycle(options: { rethrowErrors: boolean }): Promise<void> {
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
|
||||
while (true) {
|
||||
if (this.flushInProgress) {
|
||||
// flushNow() handles the in-flight case itself before calling runFlushCycle,
|
||||
// so this branch is only reached from requestFlush() (which returns early when
|
||||
// flushInProgress is true) — meaning this path should not be hit in practice.
|
||||
// Guard it defensively anyway.
|
||||
const inFlightResult = await this.currentFlushCompletion
|
||||
if (options.rethrowErrors && inFlightResult?.error) {
|
||||
throw inFlightResult.error
|
||||
}
|
||||
// Re-check flushInProgress: another concurrent caller may have already
|
||||
// started a new flush cycle after the same in-flight flush resolved.
|
||||
// Without this guard both callers would proceed past the pendingPriority
|
||||
// check and start concurrent flushes against the same presentation state.
|
||||
if (this.flushInProgress || this.disposed || !this.pendingPriority) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.pendingPriority) {
|
||||
return
|
||||
}
|
||||
|
||||
this.flushInProgress = true
|
||||
this.pendingPriority = undefined
|
||||
|
||||
this.currentFlushCompletion = (async () => {
|
||||
try {
|
||||
await this.flush()
|
||||
return {}
|
||||
} catch (error) {
|
||||
this.onFlushError?.(error)
|
||||
return { error }
|
||||
} finally {
|
||||
this.flushInProgress = false
|
||||
}
|
||||
})()
|
||||
|
||||
const result = await this.currentFlushCompletion
|
||||
this.currentFlushCompletion = undefined
|
||||
if (result.error && options.rethrowErrors) {
|
||||
throw result.error
|
||||
}
|
||||
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
|
||||
const priorityToRun = this.pendingPriority
|
||||
if (!priorityToRun) {
|
||||
return
|
||||
}
|
||||
|
||||
if (priorityToRun !== "immediate") {
|
||||
this.requestFlush(priorityToRun)
|
||||
return
|
||||
}
|
||||
|
||||
// Continue the loop synchronously for immediate follow-up work. Because
|
||||
// there is no await between clearing currentFlushCompletion above and
|
||||
// re-entering the loop here, no other caller can observe an interleaved
|
||||
// "idle" state before the immediate flush is started.
|
||||
}
|
||||
}
|
||||
|
||||
private mergePriority(current: PresentationPriority | undefined, next: PresentationPriority): PresentationPriority {
|
||||
if (!current) {
|
||||
return next
|
||||
}
|
||||
|
||||
const rank: Record<PresentationPriority, number> = {
|
||||
normal: 0,
|
||||
immediate: 1,
|
||||
}
|
||||
|
||||
return rank[next] > rank[current] ? next : current
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,8 @@ export class TaskState {
|
||||
didAlreadyUseTool = false
|
||||
didEditFile = false
|
||||
lastToolName = "" // Track last tool used for consecutive call detection
|
||||
lastToolParams = "" // Canonical signature of last tool's params (via toolCallSignature)
|
||||
consecutiveIdenticalToolCount = 0 // Consecutive calls with identical tool name + params
|
||||
|
||||
// File read deduplication cache - prevents the model from endlessly reading the same files
|
||||
// Maps absolute file path → { readCount: times read in this task, mtime: last modified timestamp, imageBlock: optional image data for multimodal models }
|
||||
|
||||
@@ -20,6 +20,7 @@ import { formatResponse } from "../prompts/responses"
|
||||
import { StateManager } from "../storage/StateManager"
|
||||
import { WorkspaceRootManager } from "../workspace"
|
||||
import { ToolResponse } from "."
|
||||
import { checkRepeatedToolCall, LOOP_DETECTION_SOFT_THRESHOLD, toolCallSignature } from "./loop-detection"
|
||||
import { MessageStateHandler } from "./message-state"
|
||||
import { TaskState } from "./TaskState"
|
||||
import { AutoApprove } from "./tools/autoApprove"
|
||||
@@ -575,8 +576,26 @@ export class ToolExecutor {
|
||||
toolWasExecuted = true
|
||||
this.pushToolResult(toolResult, block)
|
||||
|
||||
// Track the last executed tool for consecutive call detection (used by act_mode_respond)
|
||||
// --- Repeated tool call loop detection ---
|
||||
// Must run BEFORE updating lastToolName/lastToolParams so we compare
|
||||
// against the previous call's values, not the current one.
|
||||
const currentSignature = toolCallSignature(block.params)
|
||||
const loopCheck = checkRepeatedToolCall(this.taskState, block.name, currentSignature)
|
||||
|
||||
if (loopCheck.softWarning) {
|
||||
this.taskState.userMessageContent.push({
|
||||
type: "text",
|
||||
text: formatResponse.repeatedToolCall(block.name, LOOP_DETECTION_SOFT_THRESHOLD),
|
||||
})
|
||||
}
|
||||
|
||||
if (loopCheck.hardEscalation) {
|
||||
this.taskState.consecutiveMistakeCount = this.stateManager.getGlobalSettingsKey("maxConsecutiveMistakes")
|
||||
}
|
||||
|
||||
// Update state AFTER comparison
|
||||
this.taskState.lastToolName = block.name
|
||||
this.taskState.lastToolParams = currentSignature
|
||||
|
||||
// Check abort before running PostToolUse hook (success path)
|
||||
if (this.taskState.abort) {
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { strict as assert } from "node:assert"
|
||||
import { Task } from "@core/task"
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { describe, it } from "mocha"
|
||||
import sinon from "sinon"
|
||||
|
||||
async function flushMicrotasks(iterations = 5) {
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
function createFakeTask(taskState: {
|
||||
abort: boolean
|
||||
askResponse: string | undefined
|
||||
askResponseText: string | undefined
|
||||
askResponseImages: string[] | undefined
|
||||
askResponseFiles: string[] | undefined
|
||||
lastMessageTs: number | undefined
|
||||
}) {
|
||||
const clineMessages: ClineMessage[] = []
|
||||
|
||||
const fakeTask = {
|
||||
taskState,
|
||||
messageStateHandler: {
|
||||
addToClineMessages: async (message: ClineMessage) => {
|
||||
clineMessages.push(message)
|
||||
},
|
||||
getClineMessages: () => clineMessages,
|
||||
},
|
||||
postStateToWebview: async () => undefined,
|
||||
runNotificationHook: async () => undefined,
|
||||
}
|
||||
|
||||
return { clineMessages, fakeTask }
|
||||
}
|
||||
|
||||
describe("Task.ask", () => {
|
||||
it("keeps resume asks waiting for a user response even when the task is aborted", async () => {
|
||||
const clock = sinon.useFakeTimers()
|
||||
const taskState: {
|
||||
abort: boolean
|
||||
askResponse: string | undefined
|
||||
askResponseText: string | undefined
|
||||
askResponseImages: string[] | undefined
|
||||
askResponseFiles: string[] | undefined
|
||||
lastMessageTs: number | undefined
|
||||
} = {
|
||||
abort: true,
|
||||
askResponse: undefined,
|
||||
askResponseText: undefined,
|
||||
askResponseImages: undefined,
|
||||
askResponseFiles: undefined,
|
||||
lastMessageTs: undefined,
|
||||
}
|
||||
const { clineMessages, fakeTask } = createFakeTask(taskState)
|
||||
|
||||
try {
|
||||
const askPromise = (
|
||||
Task.prototype as unknown as {
|
||||
ask: (type: "resume_task") => Promise<{ response: string; text?: string }>
|
||||
}
|
||||
).ask.call(fakeTask, "resume_task")
|
||||
|
||||
let settled = false
|
||||
void askPromise.then(
|
||||
() => {
|
||||
settled = true
|
||||
},
|
||||
() => {
|
||||
settled = true
|
||||
},
|
||||
)
|
||||
|
||||
await flushMicrotasks()
|
||||
assert.equal(clineMessages.length, 1)
|
||||
assert.equal(clineMessages[0].ask, "resume_task")
|
||||
assert.notEqual(taskState.lastMessageTs, undefined)
|
||||
|
||||
await clock.tickAsync(1_000)
|
||||
assert.equal(settled, false)
|
||||
assert.equal(taskState.askResponse, undefined)
|
||||
|
||||
taskState.askResponse = "yesButtonClicked"
|
||||
taskState.askResponseText = "resume"
|
||||
|
||||
await clock.tickAsync(100)
|
||||
const result = await askPromise
|
||||
|
||||
assert.equal(result.response, "yesButtonClicked")
|
||||
assert.equal(result.text, "resume")
|
||||
} finally {
|
||||
clock.restore()
|
||||
}
|
||||
})
|
||||
|
||||
it("keeps resume-completed asks waiting for a user response even when the task is aborted", async () => {
|
||||
const clock = sinon.useFakeTimers()
|
||||
const taskState: {
|
||||
abort: boolean
|
||||
askResponse: string | undefined
|
||||
askResponseText: string | undefined
|
||||
askResponseImages: string[] | undefined
|
||||
askResponseFiles: string[] | undefined
|
||||
lastMessageTs: number | undefined
|
||||
} = {
|
||||
abort: true,
|
||||
askResponse: undefined,
|
||||
askResponseText: undefined,
|
||||
askResponseImages: undefined,
|
||||
askResponseFiles: undefined,
|
||||
lastMessageTs: undefined,
|
||||
}
|
||||
const { clineMessages, fakeTask } = createFakeTask(taskState)
|
||||
|
||||
try {
|
||||
const askPromise = (
|
||||
Task.prototype as unknown as {
|
||||
ask: (type: "resume_completed_task") => Promise<{ response: string; text?: string }>
|
||||
}
|
||||
).ask.call(fakeTask, "resume_completed_task")
|
||||
|
||||
let settled = false
|
||||
void askPromise.then(
|
||||
() => {
|
||||
settled = true
|
||||
},
|
||||
() => {
|
||||
settled = true
|
||||
},
|
||||
)
|
||||
|
||||
await flushMicrotasks()
|
||||
assert.equal(clineMessages.length, 1)
|
||||
assert.equal(clineMessages[0].ask, "resume_completed_task")
|
||||
assert.notEqual(taskState.lastMessageTs, undefined)
|
||||
|
||||
await clock.tickAsync(1_000)
|
||||
assert.equal(settled, false)
|
||||
assert.equal(taskState.askResponse, undefined)
|
||||
|
||||
taskState.askResponse = "yesButtonClicked"
|
||||
taskState.askResponseText = "resume completed"
|
||||
|
||||
await clock.tickAsync(100)
|
||||
const result = await askPromise
|
||||
|
||||
assert.equal(result.response, "yesButtonClicked")
|
||||
assert.equal(result.text, "resume completed")
|
||||
} finally {
|
||||
clock.restore()
|
||||
}
|
||||
})
|
||||
|
||||
it("still wakes non-resume asks when abort is triggered after the ask is shown", async () => {
|
||||
const clock = sinon.useFakeTimers()
|
||||
const taskState: {
|
||||
abort: boolean
|
||||
askResponse: string | undefined
|
||||
askResponseText: string | undefined
|
||||
askResponseImages: string[] | undefined
|
||||
askResponseFiles: string[] | undefined
|
||||
lastMessageTs: number | undefined
|
||||
} = {
|
||||
abort: false,
|
||||
askResponse: undefined,
|
||||
askResponseText: undefined,
|
||||
askResponseImages: undefined,
|
||||
askResponseFiles: undefined,
|
||||
lastMessageTs: undefined,
|
||||
}
|
||||
const { clineMessages, fakeTask } = createFakeTask(taskState)
|
||||
|
||||
try {
|
||||
const askPromise = (
|
||||
Task.prototype as unknown as {
|
||||
ask: (type: "completion_result") => Promise<{ response: string }>
|
||||
}
|
||||
).ask.call(fakeTask, "completion_result")
|
||||
|
||||
await flushMicrotasks()
|
||||
assert.equal(clineMessages.length, 1)
|
||||
assert.equal(clineMessages[0].ask, "completion_result")
|
||||
|
||||
const rejectionPromise = assert.rejects(askPromise, /Cline instance aborted/)
|
||||
taskState.abort = true
|
||||
|
||||
await clock.tickAsync(100)
|
||||
await rejectionPromise
|
||||
} finally {
|
||||
clock.restore()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import { strict as assert } from "node:assert"
|
||||
import type { ToolUse } from "@core/assistant-message"
|
||||
import { registerPartialMessageCallback } from "@core/controller/ui/subscribeToPartialMessage"
|
||||
import { Task } from "@core/task"
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { ClineDefaultTool } from "@shared/tools"
|
||||
import { describe, it } from "mocha"
|
||||
|
||||
describe("Task.processNativeToolCalls", () => {
|
||||
it("finalizes a partial text row before handing off to native tool calls", async () => {
|
||||
const clineMessages: ClineMessage[] = [
|
||||
{
|
||||
ts: 1,
|
||||
type: "say",
|
||||
say: "text",
|
||||
text: "partial text before tool handoff",
|
||||
partial: true,
|
||||
},
|
||||
]
|
||||
|
||||
let saveCalls = 0
|
||||
const emittedPartialMessages: Array<{ partial: boolean; text: string }> = []
|
||||
const unsubscribe = registerPartialMessageCallback((message) => {
|
||||
emittedPartialMessages.push({
|
||||
partial: message.partial,
|
||||
text: message.text,
|
||||
})
|
||||
})
|
||||
|
||||
const toolBlocks: ToolUse[] = [
|
||||
{
|
||||
type: "tool_use",
|
||||
name: ClineDefaultTool.ASK,
|
||||
params: {
|
||||
question: "Need clarification",
|
||||
},
|
||||
partial: true,
|
||||
isNativeToolCall: true,
|
||||
call_id: "call-1",
|
||||
},
|
||||
]
|
||||
|
||||
const fakeTask = {
|
||||
messageStateHandler: {
|
||||
getClineMessages: () => clineMessages,
|
||||
saveClineMessagesAndUpdateHistory: async () => {
|
||||
saveCalls += 1
|
||||
},
|
||||
},
|
||||
taskState: {
|
||||
assistantMessageContent: [],
|
||||
currentStreamingContentIndex: 0,
|
||||
userMessageContentReady: true,
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
await (
|
||||
Task.prototype as unknown as { processNativeToolCalls: (text: string, blocks: ToolUse[]) => Promise<void> }
|
||||
).processNativeToolCalls.call(fakeTask, "visible streamed text", toolBlocks)
|
||||
|
||||
assert.equal(clineMessages[0].text, "visible streamed text")
|
||||
assert.equal(clineMessages[0].partial, false)
|
||||
assert.equal(saveCalls, 1)
|
||||
assert.deepEqual(emittedPartialMessages, [{ partial: false, text: "visible streamed text" }])
|
||||
|
||||
assert.deepEqual(fakeTask.taskState.assistantMessageContent, [
|
||||
{ type: "text", content: "visible streamed text", partial: false },
|
||||
...toolBlocks,
|
||||
])
|
||||
assert.equal(fakeTask.taskState.currentStreamingContentIndex, 1)
|
||||
assert.equal(fakeTask.taskState.userMessageContentReady, false)
|
||||
} finally {
|
||||
unsubscribe()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,253 @@
|
||||
import { describe, it } from "mocha"
|
||||
import "should"
|
||||
import sinon from "sinon"
|
||||
|
||||
import { TaskPresentationScheduler } from "../TaskPresentationScheduler"
|
||||
|
||||
describe("TaskPresentationScheduler", () => {
|
||||
it("rethrows flush errors from flushNow so callers do not hang on hidden failures", async () => {
|
||||
const scheduler = new TaskPresentationScheduler({
|
||||
flush: async () => {
|
||||
throw new Error("flush failed")
|
||||
},
|
||||
getDelayMs: () => 10,
|
||||
})
|
||||
|
||||
await scheduler
|
||||
.flushNow()
|
||||
.then(() => {
|
||||
throw new Error("expected flushNow to reject")
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
error.message.should.equal("flush failed")
|
||||
})
|
||||
})
|
||||
|
||||
it("coalesces multiple normal-priority requests into a single timer", () => {
|
||||
const clock = sinon.useFakeTimers()
|
||||
const flushSpy = sinon.spy(async () => {})
|
||||
|
||||
const scheduler = new TaskPresentationScheduler({
|
||||
flush: flushSpy,
|
||||
getDelayMs: () => 50,
|
||||
})
|
||||
|
||||
scheduler.requestFlush("normal")
|
||||
scheduler.requestFlush("normal")
|
||||
scheduler.requestFlush("normal")
|
||||
|
||||
clock.tick(49)
|
||||
flushSpy.callCount.should.equal(0)
|
||||
|
||||
clock.tick(1)
|
||||
flushSpy.callCount.should.equal(1)
|
||||
|
||||
clock.restore()
|
||||
})
|
||||
|
||||
it("waits for an in-flight flush and runs the requested immediate flush before resolving flushNow", async () => {
|
||||
let resolveFirstFlush: (() => void) | undefined
|
||||
let flushCount = 0
|
||||
|
||||
const scheduler = new TaskPresentationScheduler({
|
||||
flush: async () => {
|
||||
flushCount += 1
|
||||
if (flushCount === 1) {
|
||||
await new Promise<void>((resolve) => {
|
||||
resolveFirstFlush = resolve
|
||||
})
|
||||
}
|
||||
},
|
||||
getDelayMs: () => 0,
|
||||
})
|
||||
|
||||
scheduler.requestFlush("immediate")
|
||||
await Promise.resolve()
|
||||
|
||||
let didResolve = false
|
||||
const flushNowPromise = scheduler.flushNow().then(() => {
|
||||
didResolve = true
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
flushCount.should.equal(1)
|
||||
didResolve.should.equal(false)
|
||||
|
||||
resolveFirstFlush?.()
|
||||
await flushNowPromise
|
||||
|
||||
flushCount.should.equal(2)
|
||||
didResolve.should.equal(true)
|
||||
})
|
||||
|
||||
it("does not rethrow errors from an overlapping in-flight flush when flushNow is called", async () => {
|
||||
let rejectFirstFlush: ((error: Error) => void) | undefined
|
||||
let flushCount = 0
|
||||
|
||||
const scheduler = new TaskPresentationScheduler({
|
||||
flush: async () => {
|
||||
flushCount += 1
|
||||
if (flushCount === 1) {
|
||||
await new Promise<void>((_, reject) => {
|
||||
rejectFirstFlush = reject
|
||||
})
|
||||
}
|
||||
},
|
||||
getDelayMs: () => 0,
|
||||
})
|
||||
|
||||
scheduler.requestFlush("immediate")
|
||||
await Promise.resolve()
|
||||
|
||||
let flushNowResolved = false
|
||||
const flushNowPromise = scheduler.flushNow().then(() => {
|
||||
flushNowResolved = true
|
||||
})
|
||||
rejectFirstFlush?.(new Error("flush failed"))
|
||||
|
||||
await flushNowPromise
|
||||
flushNowResolved.should.equal(true)
|
||||
flushCount.should.equal(2)
|
||||
})
|
||||
|
||||
it("flushNow guarantees a flush even when the post-flush continuation consumed pendingPriority", async () => {
|
||||
// Regression test for the race condition where:
|
||||
// 1. A timer fires → runFlushCycle starts, sets flushInProgress=true, clears pendingPriority
|
||||
// 2. flushNow() is called → sets pendingPriority="immediate", enters runFlushCycle
|
||||
// 3. runFlushCycle sees flushInProgress, awaits currentFlushCompletion
|
||||
// 4. In-flight flush completes → post-flush continuation sees pendingPriority="immediate",
|
||||
// calls runFlushCycle recursively → clears pendingPriority, runs flush #2
|
||||
// 5. flushNow()'s runFlushCycle resumes → pendingPriority is now undefined → would return
|
||||
// without flushing (the bug)
|
||||
//
|
||||
// The fix: flushNow() waits for all in-flight flushes to drain *before* setting
|
||||
// pendingPriority, so the continuation cannot steal it.
|
||||
|
||||
let resolveFirstFlush: (() => void) | undefined
|
||||
let flushCount = 0
|
||||
|
||||
const scheduler = new TaskPresentationScheduler({
|
||||
flush: async () => {
|
||||
flushCount += 1
|
||||
if (flushCount === 1) {
|
||||
// First flush: pause so flushNow() arrives while it's in-flight
|
||||
await new Promise<void>((resolve) => {
|
||||
resolveFirstFlush = resolve
|
||||
})
|
||||
}
|
||||
},
|
||||
getDelayMs: () => 0,
|
||||
})
|
||||
|
||||
// Start the first flush (via immediate requestFlush)
|
||||
scheduler.requestFlush("immediate")
|
||||
// Yield so the async flush body starts executing
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
// flushNow() is called while flush #1 is paused mid-execution
|
||||
let flushNowResolved = false
|
||||
const flushNowPromise = scheduler.flushNow().then(() => {
|
||||
flushNowResolved = true
|
||||
})
|
||||
|
||||
// Unblock flush #1
|
||||
resolveFirstFlush?.()
|
||||
await flushNowPromise
|
||||
|
||||
// flushNow must have triggered a second flush after flush #1 completed
|
||||
flushNowResolved.should.equal(true)
|
||||
flushCount.should.equal(2)
|
||||
})
|
||||
|
||||
it("runs an immediate follow-up flush requested during an in-flight flush", async () => {
|
||||
let resolveFirstFlush: (() => void) | undefined
|
||||
let flushCount = 0
|
||||
|
||||
const scheduler = new TaskPresentationScheduler({
|
||||
flush: async () => {
|
||||
flushCount += 1
|
||||
if (flushCount === 1) {
|
||||
await new Promise<void>((resolve) => {
|
||||
resolveFirstFlush = resolve
|
||||
})
|
||||
}
|
||||
},
|
||||
getDelayMs: () => 0,
|
||||
})
|
||||
|
||||
scheduler.requestFlush("immediate")
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
scheduler.requestFlush("immediate")
|
||||
resolveFirstFlush?.()
|
||||
|
||||
await scheduler.flushNow()
|
||||
flushCount.should.equal(3)
|
||||
})
|
||||
|
||||
it("reset() cancels pending timers without marking the scheduler as disposed", () => {
|
||||
const clock = sinon.useFakeTimers()
|
||||
const flushSpy = sinon.spy(async () => {})
|
||||
|
||||
const scheduler = new TaskPresentationScheduler({
|
||||
flush: flushSpy,
|
||||
getDelayMs: () => 50,
|
||||
})
|
||||
|
||||
scheduler.requestFlush("normal")
|
||||
scheduler.reset()
|
||||
|
||||
// The pending timer should have been cancelled
|
||||
clock.tick(100)
|
||||
flushSpy.callCount.should.equal(0)
|
||||
|
||||
// Scheduler should still be usable after reset (not disposed)
|
||||
scheduler.requestFlush("normal")
|
||||
clock.tick(50)
|
||||
flushSpy.callCount.should.equal(1)
|
||||
|
||||
clock.restore()
|
||||
})
|
||||
|
||||
it("immediate priority bypasses the timer and flushes synchronously", () => {
|
||||
const clock = sinon.useFakeTimers()
|
||||
const flushSpy = sinon.spy(async () => {})
|
||||
|
||||
const scheduler = new TaskPresentationScheduler({
|
||||
flush: flushSpy,
|
||||
getDelayMs: () => 100,
|
||||
})
|
||||
|
||||
scheduler.requestFlush("immediate")
|
||||
// immediate fires via void runFlushCycle, which starts synchronously
|
||||
flushSpy.callCount.should.equal(1)
|
||||
|
||||
clock.restore()
|
||||
})
|
||||
|
||||
it("upgrades a pending normal timer to immediate when immediate is requested", () => {
|
||||
const clock = sinon.useFakeTimers()
|
||||
const flushSpy = sinon.spy(async () => {})
|
||||
|
||||
const scheduler = new TaskPresentationScheduler({
|
||||
flush: flushSpy,
|
||||
getDelayMs: () => 100,
|
||||
})
|
||||
|
||||
scheduler.requestFlush("normal")
|
||||
clock.tick(50)
|
||||
flushSpy.callCount.should.equal(0)
|
||||
|
||||
// Upgrade to immediate — should cancel the timer and flush now
|
||||
scheduler.requestFlush("immediate")
|
||||
flushSpy.callCount.should.equal(1)
|
||||
|
||||
// Original timer should not fire again
|
||||
clock.tick(100)
|
||||
flushSpy.callCount.should.equal(1)
|
||||
|
||||
clock.restore()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it } from "mocha"
|
||||
import "should"
|
||||
|
||||
import { isRemoteWorkspaceEnvironment } from "../latency"
|
||||
|
||||
describe("latency", () => {
|
||||
it("detects remote workspaces from explicit remoteName metadata", () => {
|
||||
isRemoteWorkspaceEnvironment({
|
||||
platform: "Visual Studio Code",
|
||||
version: "1.103.0",
|
||||
remoteName: "ssh-remote",
|
||||
}).should.equal(true)
|
||||
})
|
||||
|
||||
it("detects remote workspaces for dev-container and codespaces remoteName values", () => {
|
||||
isRemoteWorkspaceEnvironment({ remoteName: "dev-container" }).should.equal(true)
|
||||
isRemoteWorkspaceEnvironment({ remoteName: "codespaces" }).should.equal(true)
|
||||
})
|
||||
|
||||
it("does not classify hosts as remote when remoteName is absent", () => {
|
||||
isRemoteWorkspaceEnvironment({
|
||||
platform: "Visual Studio Code",
|
||||
version: "1.103.0",
|
||||
remoteName: undefined,
|
||||
}).should.equal(false)
|
||||
})
|
||||
|
||||
it("does not classify hosts as remote when remoteName is null", () => {
|
||||
isRemoteWorkspaceEnvironment({
|
||||
platform: "Visual Studio Code",
|
||||
version: "1.103.0",
|
||||
remoteName: null,
|
||||
}).should.equal(false)
|
||||
})
|
||||
|
||||
it("does not false-positive on platform or version strings containing 'remote'", () => {
|
||||
// Previously the heuristic would have returned true for these — now it must not.
|
||||
isRemoteWorkspaceEnvironment({
|
||||
platform: "Remote IDE",
|
||||
version: "1.0.0",
|
||||
}).should.equal(false)
|
||||
|
||||
isRemoteWorkspaceEnvironment({
|
||||
platform: "Visual Studio Code",
|
||||
version: "1.0.0-remote-fix",
|
||||
}).should.equal(false)
|
||||
})
|
||||
|
||||
it("does not classify hosts as remote when no fields are provided", () => {
|
||||
isRemoteWorkspaceEnvironment({}).should.equal(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, it } from "mocha"
|
||||
import "should"
|
||||
import { checkRepeatedToolCall, toolCallSignature } from "../loop-detection"
|
||||
import { TaskState } from "../TaskState"
|
||||
|
||||
/** Simulate a tool call matching production order in ToolExecutor. */
|
||||
function simulateToolCall(state: TaskState, toolName: string, params: Record<string, string>, maxMistakes = 3) {
|
||||
const sig = toolCallSignature(params)
|
||||
const result = checkRepeatedToolCall(state, toolName, sig)
|
||||
|
||||
if (result.softWarning) {
|
||||
state.userMessageContent.push({ type: "text", text: `[WARNING] loop detected for ${toolName}` })
|
||||
}
|
||||
if (result.hardEscalation) {
|
||||
state.consecutiveMistakeCount = maxMistakes
|
||||
}
|
||||
|
||||
state.lastToolName = toolName
|
||||
state.lastToolParams = sig
|
||||
return result
|
||||
}
|
||||
|
||||
describe("toolCallSignature", () => {
|
||||
it("produces identical output regardless of key order", () => {
|
||||
toolCallSignature({ b: "2", a: "1" }).should.equal(toolCallSignature({ a: "1", b: "2" }))
|
||||
})
|
||||
})
|
||||
|
||||
describe("Loop Detection", () => {
|
||||
it("should warn at 3 identical calls and escalate at 5", () => {
|
||||
const state = new TaskState()
|
||||
const results = []
|
||||
for (let i = 0; i < 5; i++) {
|
||||
results.push(simulateToolCall(state, "read_file", { path: "src/main.ts" }))
|
||||
}
|
||||
|
||||
results[0].softWarning.should.be.false()
|
||||
results[1].softWarning.should.be.false()
|
||||
results[2].softWarning.should.be.true()
|
||||
results[3].softWarning.should.be.false()
|
||||
results[3].hardEscalation.should.be.false()
|
||||
results[4].softWarning.should.be.false()
|
||||
results[4].hardEscalation.should.be.true()
|
||||
state.userMessageContent.length.should.equal(1)
|
||||
state.consecutiveMistakeCount.should.equal(3)
|
||||
})
|
||||
|
||||
it("should reset when tool or params change", () => {
|
||||
const state = new TaskState()
|
||||
|
||||
simulateToolCall(state, "read_file", { path: "a.ts" })
|
||||
simulateToolCall(state, "read_file", { path: "a.ts" })
|
||||
simulateToolCall(state, "read_file", { path: "b.ts" }) // different params
|
||||
state.consecutiveIdenticalToolCount.should.equal(1)
|
||||
|
||||
simulateToolCall(state, "read_file", { path: "b.ts" })
|
||||
simulateToolCall(state, "list_files", { path: "b.ts" }) // different tool
|
||||
state.consecutiveIdenticalToolCount.should.equal(1)
|
||||
})
|
||||
|
||||
it("should NOT count different tools with same params as identical", () => {
|
||||
const state = new TaskState()
|
||||
|
||||
simulateToolCall(state, "read_file", { path: "src/main.ts" })
|
||||
simulateToolCall(state, "search_files", { path: "src/main.ts" })
|
||||
simulateToolCall(state, "list_files", { path: "src/main.ts" })
|
||||
|
||||
state.consecutiveIdenticalToolCount.should.equal(1)
|
||||
})
|
||||
|
||||
it("should re-arm after loop detection state is reset", () => {
|
||||
const state = new TaskState()
|
||||
|
||||
// First cycle: escalate at call 5
|
||||
for (let i = 0; i < 5; i++) {
|
||||
simulateToolCall(state, "read_file", { path: "src/main.ts" })
|
||||
}
|
||||
state.consecutiveIdenticalToolCount.should.equal(5)
|
||||
state.consecutiveMistakeCount.should.equal(3)
|
||||
|
||||
// Simulate what index.ts does when user clicks "continue"
|
||||
state.consecutiveMistakeCount = 0
|
||||
state.consecutiveIdenticalToolCount = 0
|
||||
state.lastToolName = ""
|
||||
state.lastToolParams = ""
|
||||
|
||||
// Second cycle: same tool + params should trigger again
|
||||
const results = []
|
||||
for (let i = 0; i < 5; i++) {
|
||||
results.push(simulateToolCall(state, "read_file", { path: "src/main.ts" }))
|
||||
}
|
||||
|
||||
results[2].softWarning.should.be.true()
|
||||
results[4].hardEscalation.should.be.true()
|
||||
state.consecutiveMistakeCount.should.equal(3)
|
||||
})
|
||||
|
||||
it("should work correctly when tool changes after reset", () => {
|
||||
const state = new TaskState()
|
||||
|
||||
// Escalate with one tool
|
||||
for (let i = 0; i < 5; i++) {
|
||||
simulateToolCall(state, "read_file", { path: "src/main.ts" })
|
||||
}
|
||||
|
||||
// Reset (user clicks "continue")
|
||||
state.consecutiveMistakeCount = 0
|
||||
state.consecutiveIdenticalToolCount = 0
|
||||
state.lastToolName = ""
|
||||
state.lastToolParams = ""
|
||||
|
||||
// Model switches to a different tool — no false positives
|
||||
const result = simulateToolCall(state, "list_files", { path: "src/" })
|
||||
result.softWarning.should.be.false()
|
||||
result.hardEscalation.should.be.false()
|
||||
state.consecutiveIdenticalToolCount.should.equal(1)
|
||||
})
|
||||
|
||||
it("should strip task_progress from comparison", () => {
|
||||
const state = new TaskState()
|
||||
const results = []
|
||||
for (let i = 0; i < 5; i++) {
|
||||
results.push(
|
||||
simulateToolCall(state, "read_file", {
|
||||
path: "src/index.ts",
|
||||
task_progress: `step ${i} of 5`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
results[2].softWarning.should.be.true()
|
||||
results[4].hardEscalation.should.be.true()
|
||||
})
|
||||
})
|
||||
+125
-14
@@ -114,9 +114,17 @@ import { Controller } from "../controller"
|
||||
import { executeHook } from "../hooks/hook-executor"
|
||||
import { StateManager } from "../storage/StateManager"
|
||||
import { FocusChainManager } from "./focus-chain"
|
||||
import {
|
||||
getPresentationCadenceMs,
|
||||
isPresentationSchedulingDisabled,
|
||||
isRemoteWorkspaceEnvironment,
|
||||
type TaskLatencyTrigger,
|
||||
} from "./latency"
|
||||
import { MessageStateHandler } from "./message-state"
|
||||
import type { PresentationPriority } from "./presentation-types"
|
||||
import { StreamChunkCoordinator } from "./StreamChunkCoordinator"
|
||||
import { StreamResponseHandler } from "./StreamResponseHandler"
|
||||
import { TaskPresentationScheduler } from "./TaskPresentationScheduler"
|
||||
import { TaskState } from "./TaskState"
|
||||
import { ToolExecutor } from "./ToolExecutor"
|
||||
import { detectAvailableCliTools, extractProviderDomainFromUrl, updateApiReqMsg } from "./utils"
|
||||
@@ -256,6 +264,11 @@ export class Task {
|
||||
|
||||
// Command executor for running shell commands (extracted from executeCommandTool)
|
||||
private commandExecutor!: CommandExecutor
|
||||
private isRemoteWorkspaceEnvironment = false
|
||||
private remoteWorkspaceDetectionSettled = false
|
||||
private readonly remoteWorkspaceDetectionPromise: Promise<void>
|
||||
private readonly presentationScheduler: TaskPresentationScheduler
|
||||
private readonly presentationSchedulingDisabled = isPresentationSchedulingDisabled()
|
||||
|
||||
constructor(params: TaskParams) {
|
||||
const {
|
||||
@@ -283,6 +296,17 @@ export class Task {
|
||||
|
||||
this.taskInitializationStartTime = performance.now()
|
||||
this.taskState = new TaskState()
|
||||
this.remoteWorkspaceDetectionPromise = HostProvider.env
|
||||
.getHostVersion({})
|
||||
.then((hostVersion) => {
|
||||
this.isRemoteWorkspaceEnvironment = isRemoteWorkspaceEnvironment(hostVersion)
|
||||
})
|
||||
.catch((error) => {
|
||||
Logger.warn(`[Task ${taskId}] Failed to detect remote workspace state: ${error}`)
|
||||
})
|
||||
.finally(() => {
|
||||
this.remoteWorkspaceDetectionSettled = true
|
||||
})
|
||||
this.controller = controller
|
||||
this.mcpHub = mcpHub
|
||||
this.updateTaskHistory = updateTaskHistory
|
||||
@@ -531,6 +555,26 @@ export class Task {
|
||||
|
||||
this.commandExecutor = new CommandExecutor(commandExecutorConfig, commandExecutorCallbacks)
|
||||
|
||||
// Note: the scheduler's getDelayMs reads this.isRemoteWorkspaceEnvironment which is
|
||||
// populated asynchronously by remoteWorkspaceDetectionPromise. The promise is awaited
|
||||
// before streaming begins (in recursivelyMakeClineRequests) so the cadence is always
|
||||
// correct by the time the first flush is scheduled.
|
||||
this.presentationScheduler = new TaskPresentationScheduler({
|
||||
flush: () => this.presentAssistantMessage(),
|
||||
getDelayMs: (priority) => {
|
||||
if (!this.remoteWorkspaceDetectionSettled) {
|
||||
// This should never fire in production because recursivelyMakeClineRequests
|
||||
// awaits remoteWorkspaceDetectionPromise before the first flush is scheduled.
|
||||
// If it does fire, we fall back to the local cadence (safe default).
|
||||
Logger.warn(
|
||||
`[Task ${taskId}] getDelayMs called before remote workspace detection settled — using local cadence as fallback`,
|
||||
)
|
||||
}
|
||||
return getPresentationCadenceMs(this.isRemoteWorkspaceEnvironment, priority)
|
||||
},
|
||||
onFlushError: (error) => Logger.debug(`[Task] Failed scheduled presentation flush: ${error}`),
|
||||
})
|
||||
|
||||
this.toolExecutor = new ToolExecutor(
|
||||
this.taskState,
|
||||
this.messageStateHandler,
|
||||
@@ -569,6 +613,44 @@ export class Task {
|
||||
)
|
||||
}
|
||||
|
||||
private async scheduleAssistantPresentation(
|
||||
trigger: TaskLatencyTrigger,
|
||||
priority: PresentationPriority = "normal",
|
||||
): Promise<void> {
|
||||
if (this.presentationSchedulingDisabled) {
|
||||
// Scheduling is disabled: preserve the old per-chunk synchronisation
|
||||
// semantics by awaiting flushNow() directly, while still routing through
|
||||
// the scheduler so its serialisation/locking guarantees are respected.
|
||||
await this.presentationScheduler.flushNow().catch((error) => {
|
||||
Logger.warn(`[Task] Failed immediate presentation flush: ${error}`)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Immediate semantic boundaries: first visible token, tool transitions, finalization, and cleanup drains.
|
||||
Logger.debug(`[Task ${this.taskId}] schedule assistant presentation (${trigger}, ${priority})`)
|
||||
this.presentationScheduler.requestFlush(priority)
|
||||
}
|
||||
|
||||
private async flushAssistantPresentationOrThrow() {
|
||||
await this.presentationScheduler.flushNow()
|
||||
}
|
||||
|
||||
private getPresentationPriorityForChunk(args: {
|
||||
chunkType: "text" | "reasoning" | "tool_calls"
|
||||
hadVisibleAssistantContent: boolean
|
||||
}): PresentationPriority {
|
||||
if (!args.hadVisibleAssistantContent) {
|
||||
return "immediate"
|
||||
}
|
||||
|
||||
if (args.chunkType === "tool_calls") {
|
||||
return "immediate"
|
||||
}
|
||||
|
||||
return "normal"
|
||||
}
|
||||
|
||||
// Communicate with webview
|
||||
|
||||
// partial has three valid states true (partial message), false (completion of partial message), undefined (individual complete message)
|
||||
@@ -697,9 +779,17 @@ export class Task {
|
||||
})
|
||||
}
|
||||
|
||||
await pWaitFor(() => this.taskState.askResponse !== undefined || this.taskState.lastMessageTs !== askTs, {
|
||||
interval: 100,
|
||||
})
|
||||
const shouldWakeOnAbort = type !== "resume_task" && type !== "resume_completed_task"
|
||||
await pWaitFor(
|
||||
() =>
|
||||
this.taskState.askResponse !== undefined ||
|
||||
this.taskState.lastMessageTs !== askTs ||
|
||||
(shouldWakeOnAbort && this.taskState.abort),
|
||||
{ interval: 100 },
|
||||
)
|
||||
if (shouldWakeOnAbort && this.taskState.abort) {
|
||||
throw new Error("Cline instance aborted")
|
||||
}
|
||||
if (this.taskState.lastMessageTs !== askTs) {
|
||||
throw new Error("Current ask promise was ignored") // could happen if we send multiple asks in a row i.e. with command_output. It's important that when we know an ask could fail, it is handled gracefully
|
||||
}
|
||||
@@ -1583,6 +1673,7 @@ export class Task {
|
||||
if (this.FocusChainManager) {
|
||||
this.FocusChainManager.dispose()
|
||||
}
|
||||
await this.presentationScheduler.dispose()
|
||||
} finally {
|
||||
// Release task folder lock
|
||||
if (this.taskLockAcquired) {
|
||||
@@ -2271,6 +2362,10 @@ export class Task {
|
||||
throw new Error("Task instance aborted")
|
||||
}
|
||||
|
||||
// Ensure remote workspace detection completes before streaming begins so
|
||||
// the presentation scheduler uses the correct cadence from the first flush.
|
||||
await this.remoteWorkspaceDetectionPromise
|
||||
|
||||
// Increment API request counter for focus chain list management
|
||||
this.taskState.apiRequestCount++
|
||||
this.taskState.apiRequestsSinceLastTodoUpdate++
|
||||
@@ -2343,6 +2438,10 @@ export class Task {
|
||||
}
|
||||
this.taskState.consecutiveMistakeCount = 0
|
||||
this.taskState.autoRetryAttempts = 0 // need to reset this if the user chooses to manually retry after the mistake limit is reached
|
||||
// Reset loop detection state so it can re-arm if the model continues looping
|
||||
this.taskState.consecutiveIdenticalToolCount = 0
|
||||
this.taskState.lastToolName = ""
|
||||
this.taskState.lastToolParams = ""
|
||||
}
|
||||
|
||||
// get previous api req's index to check token usage and determine if we need to truncate conversation history
|
||||
@@ -2700,6 +2799,7 @@ export class Task {
|
||||
this.taskState.didAutomaticallyRetryFailedApiRequest = false
|
||||
await this.diffViewProvider.reset()
|
||||
this.streamHandler.reset()
|
||||
this.presentationScheduler.reset()
|
||||
this.taskState.toolUseIdMap.clear()
|
||||
|
||||
const { toolUseHandler, reasonsHandler } = this.streamHandler.getHandlers()
|
||||
@@ -2713,6 +2813,7 @@ export class Task {
|
||||
this.taskState.isStreaming = true
|
||||
let didReceiveUsageChunk = false
|
||||
let didFinalizeReasoningForUi = false
|
||||
let didScheduleAnyContent = false // Tracks whether any content chunk has been scheduled for presentation (not necessarily flushed yet)
|
||||
|
||||
const finalizePendingReasoningMessage = async (thinking: string): Promise<boolean> => {
|
||||
const pendingReasoningIndex = findLastIndex(
|
||||
@@ -2764,6 +2865,10 @@ export class Task {
|
||||
if (!chunk) {
|
||||
break
|
||||
}
|
||||
// Track whether any content chunk has been scheduled for presentation (not necessarily flushed yet).
|
||||
// Using assistantMessage alone would miss reasoning-only streams where text hasn't
|
||||
// started yet, causing every reasoning chunk to get "immediate" priority.
|
||||
const hadVisibleAssistantContent = didScheduleAnyContent
|
||||
if (!this.taskState.taskFirstTokenTimeMs) {
|
||||
this.taskState.taskFirstTokenTimeMs = Math.max(0, Date.now() - this.taskState.taskStartTimeMs)
|
||||
}
|
||||
@@ -2790,6 +2895,11 @@ export class Task {
|
||||
await this.say("reasoning", thinkingBlock.thinking, undefined, undefined, true)
|
||||
}
|
||||
}
|
||||
await this.scheduleAssistantPresentation(
|
||||
"reasoning",
|
||||
this.getPresentationPriorityForChunk({ chunkType: "reasoning", hadVisibleAssistantContent }),
|
||||
)
|
||||
didScheduleAnyContent = true
|
||||
|
||||
break
|
||||
}
|
||||
@@ -2812,6 +2922,11 @@ export class Task {
|
||||
}
|
||||
|
||||
await this.processNativeToolCalls(assistantTextOnly, toolUseHandler.getPartialToolUsesAsContent())
|
||||
await this.scheduleAssistantPresentation(
|
||||
"tool",
|
||||
this.getPresentationPriorityForChunk({ chunkType: "tool_calls", hadVisibleAssistantContent }),
|
||||
)
|
||||
didScheduleAnyContent = true
|
||||
break
|
||||
}
|
||||
case "text": {
|
||||
@@ -2839,16 +2954,15 @@ export class Task {
|
||||
if (this.taskState.assistantMessageContent.length > prevLength) {
|
||||
this.taskState.userMessageContentReady = false // new content we need to present, reset to false in case previous content set this to true
|
||||
}
|
||||
await this.scheduleAssistantPresentation(
|
||||
"text",
|
||||
this.getPresentationPriorityForChunk({ chunkType: "text", hadVisibleAssistantContent }),
|
||||
)
|
||||
didScheduleAnyContent = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Present content once per chunk. Calling this from multiple case branches can
|
||||
// race partial updates and duplicate text rows in the chat.
|
||||
await this.presentAssistantMessage().catch((error) =>
|
||||
Logger.debug("[Task] Failed to present message: " + error),
|
||||
)
|
||||
|
||||
if (this.taskState.abort) {
|
||||
this.api.abort?.()
|
||||
if (!this.taskState.abandoned) {
|
||||
@@ -3075,10 +3189,7 @@ export class Task {
|
||||
// in case there are native tool calls pending
|
||||
const partialToolBlocks = toolUseHandler.getPartialToolUsesAsContent()?.map((block) => ({ ...block, partial: false }))
|
||||
await this.processNativeToolCalls(assistantTextOnly, partialToolBlocks)
|
||||
|
||||
if (partialBlocks.length > 0) {
|
||||
await this.presentAssistantMessage() // if there is content to update then it will complete and update this.userMessageContentReady to true, which we pwaitfor before making the next request. all this is really doing is presenting the last partial message that we just set to complete
|
||||
}
|
||||
await this.flushAssistantPresentationOrThrow() // finalization is immediate so no coalesced content remains pending
|
||||
|
||||
// now add to apiconversationhistory
|
||||
// need to save assistant responses to file before proceeding to tool use since user can exit at any moment and we wouldn't be able to save the assistant's response
|
||||
@@ -3338,7 +3449,7 @@ export class Task {
|
||||
return [processedUserContent, environmentDetails, clinerulesError]
|
||||
}
|
||||
|
||||
async processNativeToolCalls(assistantTextOnly: string, toolBlocks: ToolUse[]) {
|
||||
protected async processNativeToolCalls(assistantTextOnly: string, toolBlocks: ToolUse[]) {
|
||||
if (!toolBlocks?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import type { PresentationPriority } from "./presentation-types"
|
||||
|
||||
export type TaskLatencyTrigger = "text" | "reasoning" | "tool"
|
||||
|
||||
function readBooleanEnv(envVarName: string): boolean {
|
||||
const rawValue = process.env[envVarName]?.toLowerCase()
|
||||
return rawValue === "1" || rawValue === "true" || rawValue === "yes"
|
||||
}
|
||||
|
||||
function readCadenceOverride(envVarName: string): number | undefined {
|
||||
const rawValue = process.env[envVarName]
|
||||
if (!rawValue) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(rawValue, 10)
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
Logger.warn(`[latency] Ignoring invalid cadence override ${envVarName}="${rawValue}" (must be a non-negative integer)`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
// Cadence overrides are read once at module load. Env vars do not change at
|
||||
// runtime, and getPresentationCadenceMs is called on every flush (hot path).
|
||||
const localCadenceOverride = readCadenceOverride("CLINE_PRESENTATION_CADENCE_MS")
|
||||
const remoteCadenceOverride = readCadenceOverride("CLINE_REMOTE_PRESENTATION_CADENCE_MS")
|
||||
const schedulingDisabled = readBooleanEnv("CLINE_DISABLE_PRESENTATION_SCHEDULER")
|
||||
|
||||
/**
|
||||
* Determines whether the host is connected to a remote workspace.
|
||||
*
|
||||
* The primary signal is `remoteName` which is populated from `vscode.env.remoteName`
|
||||
* (e.g. `"ssh-remote"`, `"dev-container"`, `"codespaces"`). When this field is present
|
||||
* the host is definitively remote.
|
||||
*
|
||||
* For non-VSCode hosts (e.g. JetBrains) that do not populate `remoteName`, this
|
||||
* function conservatively returns `false` and uses the local cadence. This avoids
|
||||
* false positives from version strings that happen to contain the word "remote"
|
||||
* (e.g. `"1.0.0-remote-fix"`). Host bridges for remote-capable environments should
|
||||
* populate `remoteName` explicitly to opt in to the higher cadence.
|
||||
*/
|
||||
export function isRemoteWorkspaceEnvironment(host: { platform?: string; version?: string; remoteName?: string | null }): boolean {
|
||||
return !!host.remoteName
|
||||
}
|
||||
|
||||
export function isPresentationSchedulingDisabled(): boolean {
|
||||
return schedulingDisabled
|
||||
}
|
||||
|
||||
export function getPresentationCadenceMs(isRemoteWorkspace: boolean, priority: PresentationPriority): number {
|
||||
if (priority === "immediate") {
|
||||
return 0
|
||||
}
|
||||
|
||||
const override = isRemoteWorkspace ? remoteCadenceOverride : localCadenceOverride
|
||||
if (override !== undefined) {
|
||||
return override
|
||||
}
|
||||
|
||||
// Default cadences: remote workspaces use a higher interval to reduce
|
||||
// message-passing overhead over the network.
|
||||
return isRemoteWorkspace ? 90 : 40
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Repeated tool call loop detection.
|
||||
*
|
||||
* Detects when the LLM calls the same tool with identical arguments
|
||||
* repeatedly, which wastes tokens without making progress.
|
||||
*
|
||||
* This is complementary to fileReadCache in ReadFileToolHandler, which
|
||||
* deduplicates file *content* on cache hits but still allows the tool
|
||||
* call to succeed and consume a turn. Loop detection catches the
|
||||
* repeated call pattern itself, regardless of which tool is involved.
|
||||
*
|
||||
* Shared between ToolExecutor (production) and tests so the
|
||||
* comparison algorithm cannot drift between the two.
|
||||
*/
|
||||
|
||||
import type { TaskState } from "./TaskState"
|
||||
|
||||
// Soft threshold: inject a warning, giving the LLM one chance to self-correct.
|
||||
// Hard threshold: escalate to user or fail task. Set higher to avoid false
|
||||
// positives on tools that may legitimately repeat (e.g., browser_action screenshots).
|
||||
export const LOOP_DETECTION_SOFT_THRESHOLD = 3
|
||||
const LOOP_DETECTION_HARD_THRESHOLD = 5
|
||||
|
||||
// Params that are metadata/tracking, not tool-relevant input.
|
||||
// These change between calls even when the user-facing arguments are identical
|
||||
// (e.g., task_progress updates its checklist each turn).
|
||||
const IGNORED_PARAMS = new Set(["task_progress"])
|
||||
|
||||
/**
|
||||
* Compute a canonical signature for a tool call's params.
|
||||
* Strips metadata fields and sorts keys via the JSON.stringify replacer
|
||||
* so key order doesn't affect comparison.
|
||||
*
|
||||
* block.params is Partial<Record<ToolParamName, string>> — always flat,
|
||||
* string-valued, no nesting — so the replacer array is sufficient.
|
||||
*/
|
||||
export function toolCallSignature(params: Partial<Record<string, string>> | undefined): string {
|
||||
if (!params) return "{}"
|
||||
const keys = Object.keys(params)
|
||||
.filter((k) => !IGNORED_PARAMS.has(k))
|
||||
.sort()
|
||||
return JSON.stringify(params, keys)
|
||||
}
|
||||
|
||||
interface LoopDetectionResult {
|
||||
softWarning: boolean
|
||||
hardEscalation: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Core loop detection step. Must be called BEFORE updating
|
||||
* lastToolName / lastToolParams on TaskState.
|
||||
*
|
||||
* Compares the current call against the previous state, updates the
|
||||
* counter, and returns which thresholds (if any) were crossed.
|
||||
*/
|
||||
export function checkRepeatedToolCall(state: TaskState, toolName: string, currentSignature: string): LoopDetectionResult {
|
||||
if (toolName === state.lastToolName && currentSignature === state.lastToolParams) {
|
||||
state.consecutiveIdenticalToolCount++
|
||||
} else {
|
||||
state.consecutiveIdenticalToolCount = 1
|
||||
}
|
||||
|
||||
return {
|
||||
softWarning: state.consecutiveIdenticalToolCount === LOOP_DETECTION_SOFT_THRESHOLD,
|
||||
hardEscalation: state.consecutiveIdenticalToolCount === LOOP_DETECTION_HARD_THRESHOLD,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Priority level for a presentation flush request.
|
||||
*
|
||||
* - `"immediate"` — flush synchronously (delay = 0 ms). Used at semantic
|
||||
* boundaries: first visible token, tool-call transitions, and finalization.
|
||||
* - `"normal"` — flush after the configured cadence delay, coalescing
|
||||
* intermediate chunks to reduce message-passing overhead.
|
||||
*/
|
||||
export type PresentationPriority = "immediate" | "normal"
|
||||
@@ -99,7 +99,11 @@ export class ExecuteCommandToolHandler implements IFullyManagedTool {
|
||||
// Validate required parameters
|
||||
if (!command) {
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
return await config.callbacks.sayAndCreateMissingParamError(this.name, "command")
|
||||
await config.callbacks.say(
|
||||
"error",
|
||||
"Cline tried to use execute_command without value for required parameter 'command'. Retrying...",
|
||||
)
|
||||
return formatResponse.toolError(formatResponse.executeCommandMissingCommandError())
|
||||
}
|
||||
|
||||
if (!requiresApprovalRaw) {
|
||||
|
||||
@@ -114,7 +114,12 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
|
||||
if (block.name === "replace_in_file" && !rawDiff) {
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
await config.services.diffViewProvider.reset()
|
||||
return await config.callbacks.sayAndCreateMissingParamError(block.name, "diff")
|
||||
const relPath = rawRelPath || "unknown"
|
||||
await config.callbacks.say(
|
||||
"error",
|
||||
`Cline tried to use replace_in_file for '${relPath}' without value for required parameter 'diff'. Retrying...`,
|
||||
)
|
||||
return formatResponse.toolError(formatResponse.replaceInFileMissingDiffError(relPath))
|
||||
}
|
||||
|
||||
if (block.name === "write_to_file" && !rawContent) {
|
||||
|
||||
@@ -115,6 +115,7 @@ export abstract class WebviewProvider {
|
||||
font-src ${this.getCspSource()} data:;
|
||||
style-src ${this.getCspSource()} 'unsafe-inline';
|
||||
img-src ${this.getCspSource()} https: data:;
|
||||
media-src ${this.getCspSource()} https: data: blob:;
|
||||
script-src 'nonce-${nonce}' 'unsafe-eval';">
|
||||
<title>Cline</title>
|
||||
</head>
|
||||
@@ -202,6 +203,7 @@ export abstract class WebviewProvider {
|
||||
`font-src ${this.getCspSource()}`,
|
||||
`style-src ${this.getCspSource()} 'unsafe-inline' https://* http://${localServerUrl} http://0.0.0.0:${localPort}`,
|
||||
`img-src ${this.getCspSource()} https: data:`,
|
||||
`media-src ${this.getCspSource()} https: data: blob: http://${localServerUrl} http://0.0.0.0:${localPort}`,
|
||||
`script-src 'unsafe-eval' https://* http://${localServerUrl} http://0.0.0.0:${localPort} 'nonce-${nonce}'`,
|
||||
`connect-src https://* ws://${localServerUrl} ws://0.0.0.0:${localPort} http://${localServerUrl} http://0.0.0.0:${localPort}`,
|
||||
]
|
||||
|
||||
@@ -10,5 +10,11 @@ export async function getHostVersion(_: EmptyRequest): Promise<GetHostVersionRes
|
||||
version: vscode.version,
|
||||
clineType: ClineClient.VSCode,
|
||||
clineVersion: ExtensionRegistryInfo.version,
|
||||
// vscode.env.remoteName is a non-empty string when connected to a remote workspace
|
||||
// (e.g. "ssh-remote", "dev-container", "codespaces") and undefined otherwise.
|
||||
// We coerce falsy values (undefined, null, "") to undefined so the proto optional
|
||||
// field is absent for local workspaces. An empty string is treated as local — the
|
||||
// safe direction (false negative rather than false positive).
|
||||
remoteName: vscode.env.remoteName || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,15 +71,19 @@ describe("Hostbridge - Window - getOpenTabs", () => {
|
||||
await createAndOpenTestDocument("open-tabs-1", vscode.ViewColumn.One)
|
||||
await createAndOpenTestDocument("open-tabs-2", vscode.ViewColumn.Two)
|
||||
|
||||
// Wait for tabs to be fully created
|
||||
// Wait for both expected tabs to appear.
|
||||
// On Windows, opening in a different ViewColumn can sometimes create
|
||||
// duplicate tab entries, so we check for unique paths containing both files
|
||||
// rather than an exact count.
|
||||
await pWaitFor(
|
||||
async () => {
|
||||
const request = GetOpenTabsRequest.create({})
|
||||
const response = await getOpenTabs(request)
|
||||
const uniquePaths = [...new Set(response.paths)]
|
||||
console.log(
|
||||
`[DEBUG] Waiting for 2 tabs, currently found ${response.paths.length}: ${JSON.stringify(response.paths)}`,
|
||||
`[DEBUG] Waiting for 2 unique tabs, currently found ${uniquePaths.length}: ${JSON.stringify(uniquePaths)}`,
|
||||
)
|
||||
return response.paths.length === 2
|
||||
return uniquePaths.some((p) => p.includes("open-tabs-1")) && uniquePaths.some((p) => p.includes("open-tabs-2"))
|
||||
},
|
||||
{
|
||||
timeout: 8000,
|
||||
@@ -89,12 +93,13 @@ describe("Hostbridge - Window - getOpenTabs", () => {
|
||||
|
||||
const request = GetOpenTabsRequest.create({})
|
||||
const response = await getOpenTabs(request)
|
||||
const uniquePaths = [...new Set(response.paths)]
|
||||
|
||||
// Should have 2 tabs open
|
||||
// Should have both tabs open (deduplicated for Windows ViewColumn quirk)
|
||||
assert.strictEqual(
|
||||
response.paths.length,
|
||||
uniquePaths.length,
|
||||
2,
|
||||
`Expected 2 tabs, got ${response.paths.length}. Found tabs: ${JSON.stringify(response.paths)}`,
|
||||
`Expected 2 unique tabs, got ${uniquePaths.length}. Found tabs: ${JSON.stringify(response.paths)}`,
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -37,3 +37,157 @@ describe("listFiles", () => {
|
||||
files.map(normalizeForComparison).should.containEql(normalizeForComparison(nestedFile))
|
||||
})
|
||||
})
|
||||
|
||||
describe("listFiles gitignore handling", () => {
|
||||
// Each test gets its own isolated subdirectory to avoid cross-test pollution.
|
||||
// Previous version shared a single tmpDir, which meant later tests could
|
||||
// overwrite earlier .gitignore files and pass for the wrong reasons.
|
||||
const baseDir = path.join(os.tmpdir(), `cline-gitignore-test-${Math.random().toString(36).slice(2)}`)
|
||||
|
||||
after(async () => {
|
||||
await fs.rm(baseDir, { recursive: true, force: true }).catch(() => undefined)
|
||||
})
|
||||
|
||||
it("excludes files matching root .gitignore directory patterns", async () => {
|
||||
// Verifies the most common .gitignore use case: a directory pattern like "some-dir/"
|
||||
// at the project root excludes that directory and everything inside it.
|
||||
//
|
||||
// project/
|
||||
// .gitignore → "ignored-dir/"
|
||||
// visible.ts
|
||||
// ignored-dir/
|
||||
// secret.ts ← should be excluded
|
||||
// src/
|
||||
// app.ts
|
||||
const project = path.join(baseDir, "test-root-gitignore")
|
||||
await fs.mkdir(path.join(project, "ignored-dir"), { recursive: true })
|
||||
await fs.mkdir(path.join(project, "src"), { recursive: true })
|
||||
await fs.writeFile(path.join(project, ".gitignore"), "ignored-dir/\n")
|
||||
await fs.writeFile(path.join(project, "visible.ts"), "export const x = 1\n")
|
||||
await fs.writeFile(path.join(project, "ignored-dir", "secret.ts"), "secret\n")
|
||||
await fs.writeFile(path.join(project, "src", "app.ts"), "app\n")
|
||||
|
||||
const [files] = await listFiles(project, true, 200)
|
||||
const normalized = files.map(normalizeForComparison)
|
||||
|
||||
normalized.should.containEql(normalizeForComparison(path.join(project, "visible.ts")))
|
||||
normalized.should.containEql(normalizeForComparison(path.join(project, "src", "app.ts")))
|
||||
|
||||
const hasIgnoredContent = normalized.some((f) => f.includes("ignored-dir"))
|
||||
hasIgnoredContent.should.equal(false, "ignored-dir/ contents should be excluded by root .gitignore")
|
||||
})
|
||||
|
||||
it("excludes files matching .gitignore file patterns (not just directories)", async () => {
|
||||
// The .gitignore parser handles two kinds of patterns differently:
|
||||
// - Directory patterns ending in "/" → converted to "**/dir/**"
|
||||
// - File/glob patterns like "*.log" → converted to "**/*.log" + "**/*.log/**"
|
||||
// This test exercises the file pattern branch.
|
||||
//
|
||||
// project/
|
||||
// .gitignore → "*.log\nsecret.env"
|
||||
// app.ts
|
||||
// debug.log ← should be excluded
|
||||
// src/
|
||||
// nested.log ← should also be excluded (pattern is global)
|
||||
// secret.env ← should be excluded
|
||||
// config.ts
|
||||
const project = path.join(baseDir, "test-file-patterns")
|
||||
await fs.mkdir(path.join(project, "src"), { recursive: true })
|
||||
await fs.writeFile(path.join(project, ".gitignore"), "*.log\nsecret.env\n")
|
||||
await fs.writeFile(path.join(project, "app.ts"), "app\n")
|
||||
await fs.writeFile(path.join(project, "debug.log"), "debug output\n")
|
||||
await fs.writeFile(path.join(project, "src", "nested.log"), "nested log\n")
|
||||
await fs.writeFile(path.join(project, "src", "secret.env"), "API_KEY=xxx\n")
|
||||
await fs.writeFile(path.join(project, "src", "config.ts"), "config\n")
|
||||
|
||||
const [files] = await listFiles(project, true, 200)
|
||||
const normalized = files.map(normalizeForComparison)
|
||||
|
||||
normalized.should.containEql(normalizeForComparison(path.join(project, "app.ts")))
|
||||
normalized.should.containEql(normalizeForComparison(path.join(project, "src", "config.ts")))
|
||||
|
||||
const hasLogFiles = normalized.some((f) => f.endsWith(".log"))
|
||||
hasLogFiles.should.equal(false, "*.log files should be excluded")
|
||||
|
||||
const hasSecretEnv = normalized.some((f) => f.includes("secret.env"))
|
||||
hasSecretEnv.should.equal(false, "secret.env should be excluded")
|
||||
})
|
||||
|
||||
it("reads .gitignore from subdirectories during BFS traversal", async () => {
|
||||
// .gitignore files aren't only at the root — subdirectories can have their own.
|
||||
// During BFS, when we enter a non-ignored directory, we read its .gitignore
|
||||
// and add those patterns to the accumulator for all deeper traversal.
|
||||
//
|
||||
// project/
|
||||
// src/
|
||||
// .gitignore → "generated/"
|
||||
// code.ts
|
||||
// generated/
|
||||
// output.ts ← should be excluded by src/.gitignore
|
||||
// lib/
|
||||
// util.ts
|
||||
const project = path.join(baseDir, "test-subdirectory-gitignore")
|
||||
const srcDir = path.join(project, "src")
|
||||
const genDir = path.join(srcDir, "generated")
|
||||
const libDir = path.join(project, "lib")
|
||||
await fs.mkdir(genDir, { recursive: true })
|
||||
await fs.mkdir(libDir, { recursive: true })
|
||||
await fs.writeFile(path.join(srcDir, ".gitignore"), "generated/\n")
|
||||
await fs.writeFile(path.join(srcDir, "code.ts"), "code\n")
|
||||
await fs.writeFile(path.join(genDir, "output.ts"), "generated output\n")
|
||||
await fs.writeFile(path.join(libDir, "util.ts"), "util\n")
|
||||
|
||||
const [files] = await listFiles(project, true, 200)
|
||||
const normalized = files.map(normalizeForComparison)
|
||||
|
||||
normalized.should.containEql(normalizeForComparison(path.join(srcDir, "code.ts")))
|
||||
normalized.should.containEql(normalizeForComparison(path.join(libDir, "util.ts")))
|
||||
|
||||
const hasGeneratedContent = normalized.some((f) => f.includes("generated"))
|
||||
hasGeneratedContent.should.equal(false, "src/generated/ should be excluded by src/.gitignore")
|
||||
})
|
||||
|
||||
it("does not read .gitignore from inside gitignored directories", async () => {
|
||||
// This is the core OOM-prevention test.
|
||||
//
|
||||
// The crash scenario: a gitignored directory (e.g., third-party/) contains
|
||||
// hundreds of nested repos, each with their own .gitignore. globby's old
|
||||
// gitignore:true would read ALL of them upfront, build a massive regex,
|
||||
// and OOM during V8 regex compilation.
|
||||
//
|
||||
// With incremental reading, we never enter third-party/ because the root
|
||||
// .gitignore excludes it, so we never read any .gitignore files inside it.
|
||||
//
|
||||
// NOTE: We intentionally use "third-party/" instead of "vendor/" here because
|
||||
// "vendor" is in DEFAULT_IGNORE_DIRECTORIES and would be excluded regardless
|
||||
// of .gitignore. Using a name NOT in that list proves the .gitignore-based
|
||||
// exclusion is actually working.
|
||||
//
|
||||
// project/
|
||||
// .gitignore → "third-party/"
|
||||
// app.ts
|
||||
// third-party/
|
||||
// .gitignore ← should never be read
|
||||
// repo1/
|
||||
// .gitignore ← should never be read
|
||||
// file.ts
|
||||
const project = path.join(baseDir, "test-no-read-inside-ignored")
|
||||
const thirdPartyDir = path.join(project, "third-party")
|
||||
const repo1Dir = path.join(thirdPartyDir, "repo1")
|
||||
await fs.mkdir(repo1Dir, { recursive: true })
|
||||
await fs.writeFile(path.join(project, ".gitignore"), "third-party/\n")
|
||||
await fs.writeFile(path.join(project, "app.ts"), "app\n")
|
||||
// These .gitignore files simulate the nested repos that caused OOM
|
||||
await fs.writeFile(path.join(thirdPartyDir, ".gitignore"), "*.log\nbuild/\n")
|
||||
await fs.writeFile(path.join(repo1Dir, ".gitignore"), "dist/\ncoverage/\n")
|
||||
await fs.writeFile(path.join(repo1Dir, "file.ts"), "file\n")
|
||||
|
||||
const [files] = await listFiles(project, true, 200)
|
||||
const normalized = files.map(normalizeForComparison)
|
||||
|
||||
normalized.should.containEql(normalizeForComparison(path.join(project, "app.ts")))
|
||||
|
||||
const hasThirdPartyContent = normalized.some((f) => f.includes("third-party"))
|
||||
hasThirdPartyContent.should.equal(false, "third-party/ contents should be excluded — and its .gitignore files never read")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { workspaceResolver } from "@core/workspace"
|
||||
import { isDirectory } from "@utils/fs"
|
||||
import { arePathsEqual } from "@utils/path"
|
||||
import * as fs from "fs/promises"
|
||||
import { globby, Options } from "globby"
|
||||
import * as os from "os"
|
||||
import * as path from "path"
|
||||
@@ -46,7 +47,56 @@ function isTargetingHiddenDirectory(absolutePath: string): boolean {
|
||||
return dirName.startsWith(".")
|
||||
}
|
||||
|
||||
function buildIgnorePatterns(absolutePath: string): string[] {
|
||||
/**
|
||||
* Read a .gitignore file and convert its patterns to glob ignore patterns.
|
||||
*
|
||||
* We do NOT use globby's built-in `gitignore: true` option because it recursively
|
||||
* reads ALL .gitignore files in the entire directory tree upfront - including those
|
||||
* inside directories that are themselves gitignored. In projects with large gitignored
|
||||
* directories containing many nested repos (each with their own .gitignore), this
|
||||
* causes V8 to run out of memory during regex compilation, crashing the extension host.
|
||||
*
|
||||
* Instead, we read .gitignore files incrementally during BFS traversal: only from
|
||||
* directories we actually enter (which are not ignored), never from ignored directories.
|
||||
*/
|
||||
async function readGitignorePatterns(dirPath: string): Promise<string[]> {
|
||||
try {
|
||||
const gitignorePath = path.join(dirPath, ".gitignore")
|
||||
const content = await fs.readFile(gitignorePath, "utf8")
|
||||
const patterns: string[] = []
|
||||
|
||||
for (const line of content.split("\n")) {
|
||||
const trimmed = line.trim()
|
||||
// Skip empty lines and comments
|
||||
if (!trimmed || trimmed.startsWith("#")) {
|
||||
continue
|
||||
}
|
||||
// Skip negation patterns - they're complex to convert and rarely
|
||||
// critical for the directory listing use case
|
||||
if (trimmed.startsWith("!")) {
|
||||
continue
|
||||
}
|
||||
// Convert gitignore patterns to glob ignore patterns
|
||||
if (trimmed.endsWith("/")) {
|
||||
// Directory pattern: "ignored-dir/" → match the directory itself and its contents.
|
||||
// Two explicit patterns avoid ambiguity across glob library versions:
|
||||
const dirName = trimmed.slice(0, -1)
|
||||
patterns.push(`**/${dirName}`)
|
||||
patterns.push(`**/${dirName}/**`)
|
||||
} else {
|
||||
// File or ambiguous pattern: "*.log" -> "**/*.log" and "**/*.log/**"
|
||||
patterns.push(`**/${trimmed}`)
|
||||
patterns.push(`**/${trimmed}/**`)
|
||||
}
|
||||
}
|
||||
|
||||
return patterns
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function buildIgnorePatterns(absolutePath: string): Promise<string[]> {
|
||||
const isTargetHidden = isTargetingHiddenDirectory(absolutePath)
|
||||
|
||||
const patterns = [...DEFAULT_IGNORE_DIRECTORIES]
|
||||
@@ -56,7 +106,15 @@ function buildIgnorePatterns(absolutePath: string): string[] {
|
||||
patterns.push(".*")
|
||||
}
|
||||
|
||||
return patterns.map((dir) => `**/${dir}/**`)
|
||||
const globPatterns = patterns.map((dir) => `**/${dir}/**`)
|
||||
|
||||
// Read root .gitignore to seed the initial ignore patterns.
|
||||
// Additional .gitignore files from subdirectories are read incrementally
|
||||
// during BFS traversal in globbyLevelByLevel().
|
||||
const gitignorePatterns = await readGitignorePatterns(absolutePath)
|
||||
globPatterns.push(...gitignorePatterns)
|
||||
|
||||
return globPatterns
|
||||
}
|
||||
|
||||
export async function listFiles(dirPath: string, recursive: boolean, limit: number): Promise<[string[], boolean]> {
|
||||
@@ -78,8 +136,8 @@ export async function listFiles(dirPath: string, recursive: boolean, limit: numb
|
||||
dot: true, // do not ignore hidden files/directories
|
||||
absolute: true,
|
||||
markDirectories: true, // Append a / on any directories matched
|
||||
gitignore: recursive, // globby ignores any files that are gitignored
|
||||
ignore: recursive ? buildIgnorePatterns(absolutePath) : undefined,
|
||||
gitignore: false, // We handle .gitignore ourselves incrementally during BFS to avoid OOM
|
||||
ignore: recursive ? await buildIgnorePatterns(absolutePath) : undefined,
|
||||
onlyFiles: false, // include directories in results
|
||||
suppressErrors: true,
|
||||
}
|
||||
@@ -95,6 +153,9 @@ Breadth-first traversal of directory structure level by level up to a limit:
|
||||
- Processes directory patterns level by level
|
||||
- Captures a representative sample of the directory structure up to the limit
|
||||
- Minimizes risk of missing deeply nested files
|
||||
- Reads .gitignore files incrementally from each non-ignored directory entered,
|
||||
avoiding the OOM crash caused by globby's gitignore:true reading ALL nested
|
||||
.gitignore files upfront (including those inside gitignored directories)
|
||||
|
||||
- Notes:
|
||||
- Relies on globby to mark directories with /
|
||||
@@ -104,11 +165,16 @@ Breadth-first traversal of directory structure level by level up to a limit:
|
||||
async function globbyLevelByLevel(limit: number, options?: Options) {
|
||||
const results: Set<string> = new Set()
|
||||
const queue: string[] = ["*"]
|
||||
// Track all ignore patterns, starting with whatever was passed in options.
|
||||
// We'll add patterns from .gitignore files as we discover non-ignored directories.
|
||||
const currentIgnore: string[] = [...((options?.ignore as string[]) ?? [])]
|
||||
|
||||
const globbingProcess = async () => {
|
||||
while (queue.length > 0 && results.size < limit) {
|
||||
const pattern = queue.shift()!
|
||||
const filesAtLevel = await globby(pattern, options)
|
||||
// Use current accumulated ignore patterns for each globby call
|
||||
const currentOptions = { ...options, ignore: currentIgnore }
|
||||
const filesAtLevel = await globby(pattern, currentOptions)
|
||||
|
||||
for (const file of filesAtLevel) {
|
||||
if (results.size >= limit) {
|
||||
@@ -116,11 +182,28 @@ async function globbyLevelByLevel(limit: number, options?: Options) {
|
||||
}
|
||||
results.add(file)
|
||||
if (file.endsWith("/")) {
|
||||
// Escape parentheses in the path to prevent glob pattern interpretation
|
||||
// This is crucial for NextJS folder naming conventions which use parentheses like (auth), (dashboard)
|
||||
// Without escaping, glob treats parentheses as special pattern grouping characters
|
||||
const escapedFile = file.replace(/\(/g, "\\(").replace(/\)/g, "\\)")
|
||||
queue.push(`${escapedFile}*`)
|
||||
// This directory passed the ignore filters, so it's not gitignored.
|
||||
// Read its .gitignore (if any) and add patterns to the ignore list
|
||||
// so deeper traversal respects them.
|
||||
const dirGitignorePatterns = await readGitignorePatterns(file)
|
||||
if (dirGitignorePatterns.length > 0) {
|
||||
currentIgnore.push(...dirGitignorePatterns)
|
||||
}
|
||||
|
||||
// Queue as a RELATIVE path to cwd so that ignore patterns (like **/tmp/**)
|
||||
// are checked against relative entry paths, not absolute ones. Using absolute
|
||||
// patterns causes false matches when the project is under a directory whose
|
||||
// name collides with DEFAULT_IGNORE_DIRECTORIES (e.g., /tmp on Linux).
|
||||
const cwd = options?.cwd?.toString() ?? ""
|
||||
const relativeDir = path.relative(cwd, file)
|
||||
// Escape backslashes and parentheses in the path to prevent glob pattern interpretation.
|
||||
// This is crucial for NextJS folder naming conventions which use parentheses like (auth), (dashboard).
|
||||
// Without escaping, glob treats backslashes as escapes and parentheses as special pattern grouping characters.
|
||||
const escapedDir = relativeDir
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/\(/g, "\\(")
|
||||
.replace(/\)/g, "\\)")
|
||||
queue.push(`${escapedDir}/*`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@ export interface ExtensionState {
|
||||
backgroundEditEnabled?: boolean
|
||||
optOutOfRemoteConfig?: boolean
|
||||
doubleCheckCompletionEnabled?: boolean
|
||||
showFeatureTips?: boolean
|
||||
banners?: BannerCardData[]
|
||||
welcomeBanners?: BannerCardData[]
|
||||
openAiCodexIsAuthenticated?: boolean
|
||||
|
||||
+32
-8
@@ -4724,8 +4724,32 @@ export const qwenCodeDefaultModelId: QwenCodeModelId = "qwen3-coder-plus"
|
||||
// https://www.minimax.io/platform/document/text_api_intro
|
||||
// https://www.minimax.io/platform/document/pricing
|
||||
export type MinimaxModelId = keyof typeof minimaxModels
|
||||
export const minimaxDefaultModelId: MinimaxModelId = "MiniMax-M2.5"
|
||||
export const minimaxDefaultModelId: MinimaxModelId = "MiniMax-M2.7"
|
||||
export const minimaxModels = {
|
||||
"MiniMax-M2.7": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 192_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
inputPrice: 0.3,
|
||||
outputPrice: 1.2,
|
||||
cacheWritesPrice: 0.375,
|
||||
cacheReadsPrice: 0.06,
|
||||
description: "Latest flagship model with enhanced reasoning and coding",
|
||||
},
|
||||
"MiniMax-M2.7-highspeed": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 192_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
inputPrice: 0.6,
|
||||
outputPrice: 2.4,
|
||||
cacheWritesPrice: 0.375,
|
||||
cacheReadsPrice: 0.06,
|
||||
description: "High-speed version of M2.7 for low-latency scenarios",
|
||||
},
|
||||
"MiniMax-M2.5": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 192_000,
|
||||
@@ -4734,7 +4758,7 @@ export const minimaxModels = {
|
||||
supportsReasoning: true,
|
||||
inputPrice: 0.3,
|
||||
outputPrice: 1.2,
|
||||
cacheWritesPrice: 0.0375,
|
||||
cacheWritesPrice: 0.375,
|
||||
cacheReadsPrice: 0.03,
|
||||
},
|
||||
"MiniMax-M2.5-highspeed": {
|
||||
@@ -4743,9 +4767,9 @@ export const minimaxModels = {
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
inputPrice: 0.3,
|
||||
outputPrice: 1.2,
|
||||
cacheWritesPrice: 0.0375,
|
||||
inputPrice: 0.6,
|
||||
outputPrice: 2.4,
|
||||
cacheWritesPrice: 0.375,
|
||||
cacheReadsPrice: 0.03,
|
||||
},
|
||||
"MiniMax-M2.1": {
|
||||
@@ -4755,7 +4779,7 @@ export const minimaxModels = {
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 0.3,
|
||||
outputPrice: 1.2,
|
||||
cacheWritesPrice: 0.0375,
|
||||
cacheWritesPrice: 0.375,
|
||||
cacheReadsPrice: 0.03,
|
||||
},
|
||||
"MiniMax-M2.1-lightning": {
|
||||
@@ -4763,9 +4787,9 @@ export const minimaxModels = {
|
||||
contextWindow: 192_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 0.3,
|
||||
inputPrice: 0.6,
|
||||
outputPrice: 2.4,
|
||||
cacheWritesPrice: 0.0375,
|
||||
cacheWritesPrice: 0.375,
|
||||
cacheReadsPrice: 0.03,
|
||||
},
|
||||
"MiniMax-M2": {
|
||||
|
||||
@@ -545,6 +545,7 @@ describe("Remote Config Schema", () => {
|
||||
const validConfig: RemoteConfig = {
|
||||
version: "v1",
|
||||
telemetryEnabled: true,
|
||||
kanbanEnabled: true,
|
||||
mcpMarketplaceEnabled: true,
|
||||
yoloModeAllowed: false,
|
||||
providerSettings: {
|
||||
@@ -639,6 +640,7 @@ describe("Remote Config Schema", () => {
|
||||
const config = {
|
||||
version: "v1",
|
||||
telemetryEnabled: true,
|
||||
kanbanEnabled: true,
|
||||
mcpMarketplaceEnabled: false,
|
||||
blockPersonalRemoteMCPServers: true,
|
||||
allowedMCPServers: [{ id: "https://github.com/mcp/filesystem" }, { id: "https://github.com/mcp/github" }],
|
||||
@@ -769,6 +771,7 @@ describe("Remote Config Schema", () => {
|
||||
expect(result.version).to.equal("v1")
|
||||
expect(result.telemetryEnabled).to.equal(true)
|
||||
expect(result.yoloModeAllowed).to.equal(true)
|
||||
expect(result.kanbanEnabled).to.equal(true)
|
||||
|
||||
expect(result.mcpMarketplaceEnabled).to.equal(false)
|
||||
expect(result.allowedMCPServers).to.deep.equal(config.allowedMCPServers)
|
||||
|
||||
@@ -182,6 +182,7 @@ export const RemoteConfigSchema = z.object({
|
||||
|
||||
// General settings not specific to any provider
|
||||
telemetryEnabled: z.boolean().optional(),
|
||||
kanbanEnabled: z.boolean().optional(),
|
||||
|
||||
// MCP settings
|
||||
// If this is false, the MCP marketplace is disabled in the extension
|
||||
|
||||
@@ -78,6 +78,7 @@ const GLOBAL_STATE_FIELDS = {
|
||||
},
|
||||
isNewUser: { default: true as boolean },
|
||||
welcomeViewCompleted: { default: undefined as boolean | undefined },
|
||||
cliKanbanMigrationAnnouncementShown: { default: false as boolean },
|
||||
mcpDisplayMode: { default: DEFAULT_MCP_DISPLAY_MODE as McpDisplayMode },
|
||||
workspaceRoots: { default: undefined as WorkspaceRoot[] | undefined },
|
||||
primaryRootIndex: { default: 0 as number },
|
||||
@@ -272,6 +273,7 @@ const USER_SETTINGS_FIELDS = {
|
||||
backgroundEditEnabled: { default: false as boolean },
|
||||
optOutOfRemoteConfig: { default: false as boolean },
|
||||
doubleCheckCompletionEnabled: { default: false as boolean },
|
||||
showFeatureTips: { default: true as boolean },
|
||||
|
||||
// OpenTelemetry configuration
|
||||
openTelemetryEnabled: { default: true as boolean },
|
||||
|
||||
@@ -42,11 +42,24 @@ e2e("Views - can set up API keys and navigate to Settings from Chat", async ({ s
|
||||
await expect(apiKeyInput).not.toBeVisible()
|
||||
await expect(providerSelectorInput).not.toBeVisible()
|
||||
|
||||
// New installs may first show the Kanban launch modal, which blocks the
|
||||
// update announcement modal until it has been dismissed.
|
||||
const kanbanDialog = sidebar.getByRole("heading", {
|
||||
name: "Introducing Cline Kanban",
|
||||
})
|
||||
try {
|
||||
await kanbanDialog.waitFor({ state: "visible", timeout: 5_000 })
|
||||
await sidebar.getByRole("button", { name: "Close" }).click()
|
||||
await expect(kanbanDialog).not.toBeVisible()
|
||||
} catch {
|
||||
// Kanban modal did not appear during this run.
|
||||
}
|
||||
|
||||
// Verify the "What's New" modal is visible for new installs and can be closed.
|
||||
const dialog = sidebar.getByRole("heading", {
|
||||
name: /^🎉 New in v\d/,
|
||||
})
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 })
|
||||
await sidebar.getByRole("button", { name: "Close" }).click()
|
||||
await expect(dialog).not.toBeVisible()
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"
|
||||
import type { Socket } from "node:net"
|
||||
import { parse } from "node:url"
|
||||
import { v4 as uuidv4 } from "uuid"
|
||||
import type { BalanceResponse, OrganizationBalanceResponse, UserResponse } from "../../../../shared/ClineAccount"
|
||||
import { E2E_MOCK_API_RESPONSES, E2E_REGISTERED_MOCK_ENDPOINTS } from "./api"
|
||||
@@ -122,9 +121,9 @@ export class ClineApiServerMock {
|
||||
log("Starting global server...")
|
||||
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
||||
// Parse URL and method
|
||||
const parsedUrl = parse(req.url || "", true)
|
||||
const path = parsedUrl.pathname || ""
|
||||
const query = parsedUrl.query
|
||||
const parsedUrl = new URL(req.url || "/", MOCK_CLINE_API_SERVER_URL)
|
||||
const path = parsedUrl.pathname
|
||||
const query = Object.fromEntries(parsedUrl.searchParams.entries())
|
||||
const method = req.method || "GET"
|
||||
|
||||
// Helper to read request body
|
||||
@@ -454,36 +453,35 @@ export class ClineApiServerMock {
|
||||
|
||||
sendChunk()
|
||||
return
|
||||
} else {
|
||||
const response = {
|
||||
id: generationId,
|
||||
object: "chat.completion",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "Hello! I'm a mock Cline API response.",
|
||||
},
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 140,
|
||||
completion_tokens: responseText.length,
|
||||
total_tokens: 140 + responseText.length,
|
||||
cost: (140 + responseText.length) * 0.00015,
|
||||
},
|
||||
}
|
||||
return sendJson(response)
|
||||
}
|
||||
const response = {
|
||||
id: generationId,
|
||||
object: "chat.completion",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "Hello! I'm a mock Cline API response.",
|
||||
},
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 140,
|
||||
completion_tokens: responseText.length,
|
||||
total_tokens: 140 + responseText.length,
|
||||
cost: (140 + responseText.length) * 0.00015,
|
||||
},
|
||||
}
|
||||
return sendJson(response)
|
||||
}
|
||||
|
||||
// Generation details endpoint
|
||||
if (endpoint === "/generation" && method === "GET") {
|
||||
const generationId = query.id as string
|
||||
const generationId = parsedUrl.searchParams.get("id") || ""
|
||||
const generation = controller.API_USER.getGeneration(generationId)
|
||||
|
||||
if (!generation) {
|
||||
|
||||
@@ -227,7 +227,9 @@ setTimeout(() => {
|
||||
|
||||
describe("Cancellable Hooks", () => {
|
||||
it("should support user cancellation for cancellable hooks", async function () {
|
||||
this.timeout(5000)
|
||||
this.timeout(isWindows ? 10000 : 5000)
|
||||
const hookDelayMs = isWindows ? 1500 : 500
|
||||
const abortDelayMs = isWindows ? 300 : 50
|
||||
|
||||
// Create a hook that takes some time to execute
|
||||
await createHookScript(
|
||||
@@ -236,7 +238,7 @@ setTimeout(() => {
|
||||
cancel: false,
|
||||
},
|
||||
0,
|
||||
2000, // 2 second delay
|
||||
hookDelayMs,
|
||||
)
|
||||
|
||||
let capturedAbortController: AbortController | null = null
|
||||
@@ -259,10 +261,11 @@ setTimeout(() => {
|
||||
setActiveHookExecution: async (execution) => {
|
||||
setHookCalled = true
|
||||
capturedAbortController = execution.abortController
|
||||
// Abort after capturing the controller
|
||||
// Give the spawned hook process enough time to become fully active,
|
||||
// especially on slower Windows/PowerShell CI runners, before aborting.
|
||||
setTimeout(() => {
|
||||
capturedAbortController?.abort()
|
||||
}, 100)
|
||||
}, abortDelayMs)
|
||||
},
|
||||
clearActiveHookExecution: async () => {
|
||||
clearHookCalled = true
|
||||
|
||||
@@ -21,7 +21,7 @@ import { expectVisible } from "../utils.js"
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("cline --act", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["--act"] },
|
||||
program: { file: CLINE_BIN, args: ["--tui", "--act"] },
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("default"),
|
||||
})
|
||||
@@ -37,7 +37,7 @@ test.describe("cline --act", () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("cline --plan", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["--plan"] },
|
||||
program: { file: CLINE_BIN, args: ["--tui", "--plan"] },
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("default"),
|
||||
})
|
||||
@@ -53,7 +53,7 @@ test.describe("cline --plan", () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("cline --timeout (interactive mode, flag ignored) ⚠️", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["--timeout", "30"] },
|
||||
program: { file: CLINE_BIN, args: ["--tui", "--timeout", "30"] },
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("default"),
|
||||
})
|
||||
@@ -71,7 +71,7 @@ test.describe("cline --model (interactive mode, flag ignored) ⚠️", () => {
|
||||
test.use({
|
||||
program: {
|
||||
file: CLINE_BIN,
|
||||
args: ["--model", "claude-3-5-haiku-20241022"],
|
||||
args: ["--tui", "--model", "claude-3-5-haiku-20241022"],
|
||||
},
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("default"),
|
||||
@@ -88,7 +88,7 @@ test.describe("cline --model (interactive mode, flag ignored) ⚠️", () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("cline --verbose (interactive mode, flag ignored) ⚠️", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["--verbose"] },
|
||||
program: { file: CLINE_BIN, args: ["--tui", "--verbose"] },
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("default"),
|
||||
})
|
||||
@@ -104,7 +104,7 @@ test.describe("cline --verbose (interactive mode, flag ignored) ⚠️", () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("cline --cwd <dir>", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["--cwd", "/tmp"] },
|
||||
program: { file: CLINE_BIN, args: ["--tui", "--cwd", "/tmp"] },
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("default"),
|
||||
})
|
||||
@@ -116,7 +116,7 @@ test.describe("cline --cwd <dir>", () => {
|
||||
|
||||
test.describe("cline -c <dir> (short alias)", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["-c", "/tmp"] },
|
||||
program: { file: CLINE_BIN, args: ["--tui", "-c", "/tmp"] },
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("default"),
|
||||
})
|
||||
@@ -134,7 +134,7 @@ test.describe("cline --config (claude-sonnet-4.6)", () => {
|
||||
test.use({
|
||||
program: {
|
||||
file: CLINE_BIN,
|
||||
args: ["--config", "configs/claude-sonnet-4.6"],
|
||||
args: ["--tui", "--config", "configs/claude-sonnet-4.6"],
|
||||
},
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("claude-sonnet-4.6"),
|
||||
@@ -152,7 +152,7 @@ test.describe("cline --config (claude-sonnet-4.6)", () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("cline --thinking ⚠️", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["--thinking"] },
|
||||
program: { file: CLINE_BIN, args: ["--tui", "--thinking"] },
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("default"),
|
||||
})
|
||||
@@ -169,7 +169,7 @@ test.describe("cline --thinking ⚠️", () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("cline --reasoning-effort ⚠️", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["--reasoning-effort", "high"] },
|
||||
program: { file: CLINE_BIN, args: ["--tui", "--reasoning-effort", "high"] },
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("default"),
|
||||
})
|
||||
@@ -186,7 +186,7 @@ test.describe("cline --max-consecutive-mistakes", () => {
|
||||
test.use({
|
||||
program: {
|
||||
file: CLINE_BIN,
|
||||
args: ["--max-consecutive-mistakes", "5"],
|
||||
args: ["--tui", "--max-consecutive-mistakes", "5"],
|
||||
},
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("default"),
|
||||
@@ -202,7 +202,7 @@ test.describe("cline --max-consecutive-mistakes", () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("cline --double-check-completion", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["--double-check-completion"] },
|
||||
program: { file: CLINE_BIN, args: ["--tui", "--double-check-completion"] },
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("default"),
|
||||
})
|
||||
|
||||
@@ -30,6 +30,7 @@ test.describe("root flag descriptions", () => {
|
||||
"Output messages as JSON",
|
||||
"first completion attempt",
|
||||
"ACP",
|
||||
"legacy terminal UI",
|
||||
"Resume an existing task",
|
||||
])
|
||||
})
|
||||
|
||||
@@ -43,6 +43,7 @@ test.describe("cline --help", () => {
|
||||
"--json",
|
||||
"--double-check-completion",
|
||||
"--acp",
|
||||
"--tui",
|
||||
"--taskId",
|
||||
])
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import { expectVisible, testEnv, typeAndSubmit } from "./utils.js"
|
||||
|
||||
test.describe("cline interactive basics", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: [] },
|
||||
program: { file: CLINE_BIN, args: ["--tui"] },
|
||||
rows: 50,
|
||||
columns: 120,
|
||||
env: testEnv("default"),
|
||||
|
||||
@@ -43,7 +43,7 @@ import { expectVisible } from "../helpers/terminal.js"
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("cline (unauthenticated) — shows auth view", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: [] },
|
||||
program: { file: CLINE_BIN, args: ["--tui"] },
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("unauthenticated"),
|
||||
})
|
||||
@@ -60,7 +60,7 @@ test.describe("cline (unauthenticated) — shows auth view", () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("cline (authenticated) — shows chat view", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: [] },
|
||||
program: { file: CLINE_BIN, args: ["--tui"] },
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("claude-sonnet-4.6"),
|
||||
})
|
||||
@@ -75,7 +75,7 @@ test.describe("cline (authenticated) — shows chat view", () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("/settings — tab navigation", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: [] },
|
||||
program: { file: CLINE_BIN, args: ["--tui"] },
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("claude-sonnet-4.6"),
|
||||
})
|
||||
@@ -115,7 +115,7 @@ test.describe("/settings — tab navigation", () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("/models — model browser", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: [] },
|
||||
program: { file: CLINE_BIN, args: ["--tui"] },
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("claude-sonnet-4.6"),
|
||||
})
|
||||
@@ -138,7 +138,7 @@ test.describe("/models — model browser", () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("/history — task history", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: [] },
|
||||
program: { file: CLINE_BIN, args: ["--tui"] },
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("claude-sonnet-4.6"),
|
||||
})
|
||||
@@ -161,7 +161,7 @@ test.describe("/history — task history", () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("/skills — skills view", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: [] },
|
||||
program: { file: CLINE_BIN, args: ["--tui"] },
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("default"),
|
||||
})
|
||||
@@ -184,7 +184,7 @@ test.describe("/skills — skills view", () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("Plan/Act mode toggle", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: [] },
|
||||
program: { file: CLINE_BIN, args: ["--tui"] },
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("default"),
|
||||
})
|
||||
@@ -204,7 +204,7 @@ test.describe("Plan/Act mode toggle", () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("Auto-approve all — Shift+Tab toggle", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: [] },
|
||||
program: { file: CLINE_BIN, args: ["--tui"] },
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("default"),
|
||||
})
|
||||
|
||||
+55
-14
@@ -1,7 +1,8 @@
|
||||
import type { Boolean, EmptyRequest } from "@shared/proto/cline/common"
|
||||
import { useEffect } from "react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import AccountView from "./components/account/AccountView"
|
||||
import ChatView from "./components/chat/ChatView"
|
||||
import ClineKanbanLaunchModal, { CLINE_KANBAN_MODAL_DISMISS_ID } from "./components/common/ClineKanbanLaunchModal"
|
||||
import HistoryView from "./components/history/HistoryView"
|
||||
import McpView from "./components/mcp/configuration/McpConfigurationView"
|
||||
import OnboardingView from "./components/onboarding/OnboardingView"
|
||||
@@ -11,13 +12,14 @@ import WorktreesView from "./components/worktrees/WorktreesView"
|
||||
import { useClineAuth } from "./context/ClineAuthContext"
|
||||
import { useExtensionState } from "./context/ExtensionStateContext"
|
||||
import { Providers } from "./Providers"
|
||||
import { UiServiceClient } from "./services/grpc-client"
|
||||
import { StateServiceClient, UiServiceClient } from "./services/grpc-client"
|
||||
|
||||
const AppContent = () => {
|
||||
const {
|
||||
didHydrateState,
|
||||
showWelcome,
|
||||
shouldShowAnnouncement,
|
||||
dismissedBanners,
|
||||
showMcp,
|
||||
mcpTab,
|
||||
showSettings,
|
||||
@@ -37,23 +39,61 @@ const AppContent = () => {
|
||||
hideWorktrees,
|
||||
hideAnnouncement,
|
||||
} = useExtensionState()
|
||||
const [showKanbanModal, setShowKanbanModal] = useState(false)
|
||||
const [hasShownKanbanModal, setHasShownKanbanModal] = useState(false)
|
||||
|
||||
const { clineUser, organizations, activeOrganization } = useClineAuth()
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldShowAnnouncement) {
|
||||
setShowAnnouncement(true)
|
||||
const showUpdateAnnouncementModal = useCallback(() => {
|
||||
setShowAnnouncement(true)
|
||||
UiServiceClient.onDidShowAnnouncement({} as EmptyRequest)
|
||||
.then((response: Boolean) => {
|
||||
setShouldShowAnnouncement(response.value)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to acknowledge announcement:", error)
|
||||
})
|
||||
}, [setShouldShowAnnouncement, setShowAnnouncement])
|
||||
|
||||
// Use the gRPC client instead of direct WebviewMessage
|
||||
UiServiceClient.onDidShowAnnouncement({} as EmptyRequest)
|
||||
.then((response: Boolean) => {
|
||||
setShouldShowAnnouncement(response.value)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to acknowledge announcement:", error)
|
||||
})
|
||||
useEffect(() => {
|
||||
if (!didHydrateState || showWelcome || hasShownKanbanModal) {
|
||||
return
|
||||
}
|
||||
}, [shouldShowAnnouncement, setShouldShowAnnouncement, setShowAnnouncement])
|
||||
const hasDismissedKanbanModal = dismissedBanners?.some((banner) => banner.bannerId === CLINE_KANBAN_MODAL_DISMISS_ID)
|
||||
if (!hasDismissedKanbanModal) {
|
||||
setShowKanbanModal(true)
|
||||
}
|
||||
setHasShownKanbanModal(true)
|
||||
}, [didHydrateState, dismissedBanners, hasShownKanbanModal, showWelcome])
|
||||
|
||||
// Keep update announcements queued until the Kanban modal has either shown and closed or been skipped.
|
||||
useEffect(() => {
|
||||
if (!didHydrateState || showWelcome || !shouldShowAnnouncement || showAnnouncement) {
|
||||
return
|
||||
}
|
||||
const isKanbanModalBlocking = showKanbanModal || !hasShownKanbanModal
|
||||
if (isKanbanModalBlocking) {
|
||||
return
|
||||
}
|
||||
showUpdateAnnouncementModal()
|
||||
}, [
|
||||
didHydrateState,
|
||||
showWelcome,
|
||||
shouldShowAnnouncement,
|
||||
showAnnouncement,
|
||||
showKanbanModal,
|
||||
hasShownKanbanModal,
|
||||
showUpdateAnnouncementModal,
|
||||
])
|
||||
|
||||
const handleCloseKanbanModal = useCallback((doNotShowAgain: boolean) => {
|
||||
setShowKanbanModal(false)
|
||||
if (doNotShowAgain) {
|
||||
StateServiceClient.dismissBanner({ value: CLINE_KANBAN_MODAL_DISMISS_ID }).catch((error) =>
|
||||
console.error("Failed to persist Cline Kanban modal dismissal:", error),
|
||||
)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!didHydrateState) {
|
||||
return null
|
||||
@@ -65,6 +105,7 @@ const AppContent = () => {
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full flex-col">
|
||||
<ClineKanbanLaunchModal onClose={handleCloseKanbanModal} open={showKanbanModal} />
|
||||
{showSettings && <SettingsView onDone={hideSettings} targetSection={settingsTargetSection} />}
|
||||
{showHistory && <HistoryView onDone={hideHistory} />}
|
||||
{showMcp && <McpView initialTab={mcpTab} onDone={closeMcpView} />}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:fa7c90bf657f317e63dee745e010898ca0cb13ee82e9dbe341ac16199170399a
|
||||
size 3738635
|
||||
@@ -155,6 +155,7 @@ export const ChatRowContent = memo(
|
||||
onRelinquishControl,
|
||||
vscodeTerminalExecutionMode,
|
||||
clineMessages,
|
||||
showFeatureTips,
|
||||
} = useExtensionState()
|
||||
const [seeNewChangesDisabled, setSeeNewChangesDisabled] = useState(false)
|
||||
const [explainChangesDisabled, setExplainChangesDisabled] = useState(false)
|
||||
@@ -903,7 +904,7 @@ export const ChatRowContent = memo(
|
||||
showTitle={true}
|
||||
title={isReasoningStreaming ? "Thinking..." : "Thinking"}
|
||||
/>
|
||||
{isReasoningStreaming && <FeatureTip />}
|
||||
{isReasoningStreaming && showFeatureTips !== false && <FeatureTip />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -43,6 +43,9 @@ const FEATURE_TIPS: FeatureTipItem[] = [
|
||||
{
|
||||
text: "Use /reportbug to quickly file a GitHub issue with diagnostic context included.",
|
||||
},
|
||||
{
|
||||
text: 'You can disable these tips in Settings → Features → "Feature Tips".',
|
||||
},
|
||||
]
|
||||
|
||||
const SHOW_DELAY_MS = 2000
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { StringRequest } from "@shared/proto/cline/common"
|
||||
import { VSCodeButton, VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
|
||||
import React, { useEffect, useMemo, useState } from "react"
|
||||
import kanbanDemoVideo from "@/assets/cline_kanban_demo.mp4"
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog"
|
||||
import { PLATFORM_CONFIG, PlatformType } from "@/config/platform.config"
|
||||
import { FileServiceClient, StateServiceClient } from "@/services/grpc-client"
|
||||
|
||||
const INSTALL_COMMAND = "npm install -g cline"
|
||||
const COPIED_TIMEOUT = 1500
|
||||
const kanbanDemoVideoSrc = kanbanDemoVideo.startsWith("/src/")
|
||||
? new URL(kanbanDemoVideo, import.meta.url).toString()
|
||||
: kanbanDemoVideo
|
||||
|
||||
export const CLINE_KANBAN_MODAL_DISMISS_ID = "cline-kanban-launch-modal-v1"
|
||||
|
||||
interface ClineKanbanLaunchModalProps {
|
||||
open: boolean
|
||||
onClose: (doNotShowAgain: boolean) => void
|
||||
}
|
||||
|
||||
export const ClineKanbanLaunchModal: React.FC<ClineKanbanLaunchModalProps> = ({ open, onClose }) => {
|
||||
const [doNotShowAgain, setDoNotShowAgain] = useState(false)
|
||||
const [isInstalling, setIsInstalling] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const isVsCode = useMemo(() => PLATFORM_CONFIG.type === PlatformType.VSCODE, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setCopied(false)
|
||||
setIsInstalling(false)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleAction = async () => {
|
||||
if (isVsCode) {
|
||||
setIsInstalling(true)
|
||||
try {
|
||||
await StateServiceClient.installClineCli({})
|
||||
} catch (error) {
|
||||
console.error("Failed to run CLI install command:", error)
|
||||
} finally {
|
||||
setIsInstalling(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await FileServiceClient.copyToClipboard(StringRequest.create({ value: INSTALL_COMMAND }))
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), COPIED_TIMEOUT)
|
||||
} catch (error) {
|
||||
console.error("Failed to copy CLI install command:", error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={(isOpen) => !isOpen && onClose(doNotShowAgain)} open={open}>
|
||||
<DialogContent
|
||||
aria-describedby="cline-kanban-description"
|
||||
aria-labelledby="cline-kanban-title"
|
||||
className="pt-4 px-5 pb-4 gap-0 max-w-2xl">
|
||||
<div className="space-y-3" id="cline-kanban-description">
|
||||
<div className="pr-6 min-h-6 flex items-center">
|
||||
<h2
|
||||
className="m-0 text-lg font-semibold"
|
||||
id="cline-kanban-title"
|
||||
style={{ color: "var(--vscode-editor-foreground)" }}>
|
||||
Introducing Cline Kanban
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<video
|
||||
autoPlay
|
||||
className="w-full rounded-md border border-[var(--vscode-editorGroup-border)]"
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
src={kanbanDemoVideoSrc}
|
||||
/>
|
||||
|
||||
<p className="text-sm" style={{ color: "var(--vscode-descriptionForeground)" }}>
|
||||
A replacement for your IDE better suited for running many agents in parallel and reviewing diffs. Enable
|
||||
auto-commit and link cards together to create dependency chains that complete large amounts of work
|
||||
autonomously.
|
||||
</p>
|
||||
|
||||
<div className="p-1">
|
||||
<code className="block rounded-sm px-2 py-1 bg-[var(--vscode-textCodeBlock-background)] text-sm">
|
||||
{INSTALL_COMMAND}
|
||||
</code>
|
||||
<div className="mt-3">
|
||||
<VSCodeButton disabled={isInstalling} onClick={handleAction}>
|
||||
{isVsCode
|
||||
? isInstalling
|
||||
? "Running install command..."
|
||||
: "Run in terminal"
|
||||
: copied
|
||||
? "Copied"
|
||||
: "Copy command"}
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<VSCodeCheckbox
|
||||
checked={doNotShowAgain}
|
||||
onChange={(e: any) => {
|
||||
setDoNotShowAgain(e.target.checked === true)
|
||||
}}>
|
||||
Do not show again
|
||||
</VSCodeCheckbox>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default ClineKanbanLaunchModal
|
||||
@@ -8,6 +8,7 @@ vi.mock("@/context/ExtensionStateContext", () => ({
|
||||
useExtensionState: vi.fn(() => ({
|
||||
enableCheckpointsSetting: true,
|
||||
hooksEnabled: false,
|
||||
showFeatureTips: false,
|
||||
mcpDisplayMode: "rich",
|
||||
strictPlanModeEnabled: false,
|
||||
yoloModeToggled: false,
|
||||
@@ -41,6 +42,18 @@ describe("FeatureSettingsSection", () => {
|
||||
expect(agentSection?.querySelector("#Hooks")).toBeNull()
|
||||
})
|
||||
|
||||
it("renders Feature Tips toggle in the Editor section", () => {
|
||||
const { container } = render(<FeatureSettingsSection renderSectionHeader={() => null} />)
|
||||
|
||||
expect(screen.getByText("Feature Tips")).toBeTruthy()
|
||||
|
||||
const editorSection = container.querySelector("#optional-features")
|
||||
const agentSection = container.querySelector("#agent-features")
|
||||
|
||||
expect(editorSection?.querySelector('[id="Feature Tips"]')).toBeTruthy()
|
||||
expect(agentSection?.querySelector('[id="Feature Tips"]')).toBeNull()
|
||||
})
|
||||
|
||||
it("calls updateSetting with hooksEnabled when toggled", () => {
|
||||
const { container } = render(<FeatureSettingsSection renderSectionHeader={() => null} />)
|
||||
|
||||
@@ -51,4 +64,15 @@ describe("FeatureSettingsSection", () => {
|
||||
|
||||
expect(mockUpdateSetting).toHaveBeenCalledWith("hooksEnabled", true)
|
||||
})
|
||||
|
||||
it("calls updateSetting with showFeatureTips when toggled", () => {
|
||||
const { container } = render(<FeatureSettingsSection renderSectionHeader={() => null} />)
|
||||
|
||||
const featureTipsSwitch = container.querySelector('[id="Feature Tips"]')
|
||||
expect(featureTipsSwitch).toBeTruthy()
|
||||
|
||||
fireEvent.click(featureTipsSwitch as Element)
|
||||
|
||||
expect(mockUpdateSetting).toHaveBeenCalledWith("showFeatureTips", true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -79,6 +79,13 @@ const agentFeatures: FeatureToggle[] = [
|
||||
]
|
||||
|
||||
const editorFeatures: FeatureToggle[] = [
|
||||
{
|
||||
id: "show-feature-tips",
|
||||
label: "Feature Tips",
|
||||
description: "Show rotating tips during the thinking phase to help you discover Cline features.",
|
||||
stateKey: "showFeatureTips",
|
||||
settingKey: "showFeatureTips",
|
||||
},
|
||||
{
|
||||
id: "background-edit",
|
||||
label: "Background Edit",
|
||||
@@ -211,6 +218,7 @@ const FeatureSettingsSection = ({ renderSectionHeader }: FeatureSettingsSectionP
|
||||
enableParallelToolCalling,
|
||||
backgroundEditEnabled,
|
||||
doubleCheckCompletionEnabled,
|
||||
showFeatureTips,
|
||||
} = useExtensionState()
|
||||
|
||||
const handleFocusChainIntervalChange = useCallback(
|
||||
@@ -224,6 +232,7 @@ const FeatureSettingsSection = ({ renderSectionHeader }: FeatureSettingsSectionP
|
||||
|
||||
// State lookup for mapped features
|
||||
const featureState: Record<string, boolean | undefined> = {
|
||||
showFeatureTips,
|
||||
enableCheckpointsSetting,
|
||||
strictPlanModeEnabled,
|
||||
hooksEnabled,
|
||||
|
||||
@@ -277,6 +277,7 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
lastDismissedCliBannerVersion: 0,
|
||||
backgroundEditEnabled: false,
|
||||
doubleCheckCompletionEnabled: false,
|
||||
showFeatureTips: true,
|
||||
globalSkillsToggles: {},
|
||||
localSkillsToggles: {},
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ if (!VALID_PLATFORMS.includes(platform)) {
|
||||
console.log("Building webview for", platform)
|
||||
|
||||
export default defineConfig({
|
||||
base: "./",
|
||||
optimizeDeps: {
|
||||
force: true, // Forces re-optimization
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user