fix: Use local tsx instead of global tsx dependency

- Update cli.js wrapper to use node_modules/.bin/tsx
- Fix ESC key cancellation error display in openai.ts stream processing
- Simplify REPL onCancel function
- Add security notice and model performance recommendations to README
This commit is contained in:
CrazyBoyM 2025-08-25 04:23:44 +08:00
parent 994579fadc
commit 6aa73a950a
11 changed files with 298 additions and 127 deletions

View File

@ -23,6 +23,10 @@ Use `# Your documentation request` to generate and maintain your AGENTS.md file
Kode is a powerful AI assistant that lives in your terminal. It can understand your codebase, edit files, run commands, and handle entire workflows for you.
> **⚠️ Security Notice**: Kode runs in YOLO mode by default (equivalent to Claude's `--dangerously-skip-permissions` flag), bypassing all permission checks for maximum productivity. This is recommended only for trusted environments with no internet access. For security-sensitive environments, use `kode --safe` to enable permission checks.
>
> **📊 Model Performance**: For optimal performance, we recommend using newer, more capable models designed for autonomous task completion. Avoid older Q&A-focused models like GPT-4o or Gemini 2.5 Pro, which are optimized for answering questions rather than sustained independent task execution. Choose models specifically trained for agentic workflows and extended reasoning capabilities.
## Features
### Core Capabilities

View File

@ -7,6 +7,10 @@
Kode 是一个强大的 AI 助手,运行在你的终端中。它能理解你的代码库、编辑文件、运行命令,并为你处理整个开发工作流。
> **⚠️ 安全提示**Kode 默认以 YOLO 模式运行(等同于 Claude 的 `--dangerously-skip-permissions` 标志),跳过所有权限检查以获得最大生产力。建议仅在无互联网访问的可信环境中使用。对于安全敏感环境,请使用 `kode --safe` 启用权限检查。
>
> **📊 模型性能建议**:为获得最佳体验,建议使用专为自主任务完成设计的新一代强大模型。避免使用 GPT-4o、Gemini 2.5 Pro 等较老的问答型模型,它们主要针对回答问题进行优化,而非持续的独立任务执行。请选择专门训练用于智能体工作流和扩展推理能力的模型。
## 功能特性
- 🤖 **AI 驱动的助手** - 使用先进的 AI 模型理解并响应你的请求

View File

@ -48,24 +48,19 @@ try {
}
function runWithNode() {
// Use node with tsx loader
const child = spawn('node', [
'--loader', 'tsx',
'--no-warnings',
cliPath,
...args
], {
// Use local tsx installation
const tsxPath = path.join(__dirname, 'node_modules', '.bin', 'tsx');
const child = spawn(tsxPath, [cliPath, ...args], {
stdio: 'inherit',
env: {
...process.env,
NODE_OPTIONS: '--loader tsx --no-warnings',
YOGA_WASM_PATH: path.join(__dirname, 'yoga.wasm')
}
});
child.on('error', (err) => {
if (err.code === 'MODULE_NOT_FOUND' || err.message.includes('tsx')) {
console.error('\\nError: tsx is required but not installed.');
if (err.code === 'ENOENT') {
console.error('\\nError: tsx is required but not found.');
console.error('Please run: npm install');
process.exit(1);
} else {

View File

@ -227,20 +227,22 @@ function PromptInput({
[onModeChange, onInputChange],
)
// Handle Tab key model switching with simple context check
// Handle Shift+M model switching with enhanced debugging
const handleQuickModelSwitch = useCallback(async () => {
const modelManager = getModelManager()
const currentTokens = countTokens(messages)
// Get debug info for better error reporting
const debugInfo = modelManager.getModelSwitchingDebugInfo()
const switchResult = modelManager.switchToNextModel(currentTokens)
if (switchResult.success && switchResult.modelName) {
// Successful switch
// Successful switch - use enhanced message from model manager
onSubmitCountChange(prev => prev + 1)
const newModel = modelManager.getModel('main')
setModelSwitchMessage({
show: true,
text: `✅ Switched to ${switchResult.modelName} (${newModel?.provider || 'Unknown'} | Model: ${newModel?.modelName || 'N/A'})`,
text: switchResult.message || `✅ Switched to ${switchResult.modelName}`,
})
setTimeout(() => setModelSwitchMessage({ show: false }), 3000)
} else if (switchResult.blocked && switchResult.message) {
@ -251,14 +253,28 @@ function PromptInput({
})
setTimeout(() => setModelSwitchMessage({ show: false }), 5000)
} else {
// No other models available or other error
// Enhanced error reporting with debug info
let errorMessage = switchResult.message
if (!errorMessage) {
if (debugInfo.totalModels === 0) {
errorMessage = '❌ No models configured. Use /model to add models.'
} else if (debugInfo.activeModels === 0) {
errorMessage = `❌ No active models (${debugInfo.totalModels} total, all inactive). Use /model to activate models.`
} else if (debugInfo.activeModels === 1) {
// Show ALL models including inactive ones for debugging
const allModelNames = debugInfo.availableModels.map(m => `${m.name}${m.isActive ? '' : ' (inactive)'}`).join(', ')
errorMessage = `⚠️ Only 1 active model out of ${debugInfo.totalModels} total models: ${allModelNames}. ALL configured models will be activated for switching.`
} else {
errorMessage = `❌ Model switching failed (${debugInfo.activeModels} active, ${debugInfo.totalModels} total models available)`
}
}
setModelSwitchMessage({
show: true,
text:
switchResult.message ||
'⚠️ No other models configured. Use /model to add more models',
text: errorMessage,
})
setTimeout(() => setModelSwitchMessage({ show: false }), 3000)
setTimeout(() => setModelSwitchMessage({ show: false }), 6000)
}
}, [onSubmitCountChange, messages])
@ -525,6 +541,17 @@ function PromptInput({
return false // Not handled, allow other hooks
})
// Handle special key combinations before character input
const handleSpecialKey = useCallback((inputChar: string, key: any): boolean => {
// Shift+M for model switching - intercept before character input
if (key.shift && (inputChar === 'M' || inputChar === 'm')) {
handleQuickModelSwitch()
return true // Prevent character from being input
}
return false // Not handled, allow normal processing
}, [handleQuickModelSwitch])
const textInputColumns = useTerminalSize().columns - 6
const tokenUsage = useMemo(() => countTokens(messages), [messages])
@ -614,14 +641,7 @@ function PromptInput({
cursorOffset={cursorOffset}
onChangeCursorOffset={setCursorOffset}
onPaste={onTextPaste}
onSpecialKey={(input, key) => {
// Handle Shift+M for model switching
if (key.shift && (input === 'M' || input === 'm')) {
handleQuickModelSwitch()
return true // Prevent the 'M' from being typed
}
return false
}}
onSpecialKey={handleSpecialKey}
/>
</Box>
</Box>

View File

@ -20,6 +20,12 @@ export class SentryErrorBoundary extends React.Component<Props, State> {
}
componentDidCatch(error: Error): void {
// Don't report user-initiated cancellations to Sentry
if (error.name === 'AbortError' ||
error.message?.includes('abort') ||
error.message?.includes('The operation was aborted')) {
return
}
captureException(error)
}

View File

@ -1,11 +1,47 @@
import React from 'react'
import { Box, Text } from 'ink'
import type { TodoItem as TodoItemType } from '../utils/todoStorage'
export interface TodoItemProps {
// Define props as needed
todo: TodoItemType
children?: React.ReactNode
}
export const TodoItem: React.FC<TodoItemProps> = ({ children }) => {
// Minimal component implementation
return <>{children}</>
export const TodoItem: React.FC<TodoItemProps> = ({ todo, children }) => {
const statusIconMap = {
completed: '✅',
in_progress: '🔄',
pending: '⏸️',
}
const statusColorMap = {
completed: '#008000',
in_progress: '#FFA500',
pending: '#FFD700',
}
const priorityIconMap = {
high: '🔴',
medium: '🟡',
low: '🟢',
}
const icon = statusIconMap[todo.status]
const color = statusColorMap[todo.status]
const priorityIcon = todo.priority ? priorityIconMap[todo.priority] : ''
return (
<Box flexDirection="row" gap={1}>
<Text color={color}>{icon}</Text>
{priorityIcon && <Text>{priorityIcon}</Text>}
<Text
color={color}
strikethrough={todo.status === 'completed'}
bold={todo.status === 'in_progress'}
>
{todo.content}
</Text>
{children}
</Box>
)
}

View File

@ -960,7 +960,8 @@ export function useUnifiedCompletion({
// Handle Tab key - simplified and unified
useInput((input_str, key) => {
if (!key.tab || key.shift) return false
if (!key.tab) return false
if (key.shift) return false
const context = getWordAtCursor()
if (!context) return false

View File

@ -171,22 +171,15 @@ export function REPL({
}>({})
const { status: apiKeyStatus, reverify } = useApiKeyVerification()
// 🔧 FIXED: Simple cancellation logic matching original claude-code
function onCancel() {
if (!isLoading) {
return
}
setIsLoading(false)
if (toolUseConfirm) {
// Tool use confirm handles the abort signal itself
toolUseConfirm.onAbort()
} else {
// Wrap abort in try-catch to prevent error display on user interrupt
try {
abortController?.abort()
} catch (e) {
// Silently handle abort errors - this is expected behavior
}
} else if (abortController && !abortController.signal.aborted) {
abortController.abort()
}
}

View File

@ -664,7 +664,7 @@ export async function getCompletionWithProfile(
)
}
const stream = createStreamProcessor(response.body as any)
const stream = createStreamProcessor(response.body as any, signal)
return stream
}
@ -815,6 +815,7 @@ export async function getCompletionWithProfile(
export function createStreamProcessor(
stream: any,
signal?: AbortSignal,
): AsyncGenerator<OpenAI.ChatCompletionChunk, void, unknown> {
if (!stream) {
throw new Error('Stream is null or undefined')
@ -827,10 +828,19 @@ export function createStreamProcessor(
try {
while (true) {
// Check for cancellation before attempting to read
if (signal?.aborted) {
break
}
let readResult
try {
readResult = await reader.read()
} catch (e) {
// If signal is aborted, this is user cancellation - exit silently
if (signal?.aborted) {
break
}
console.error('Error reading from stream:', e)
break
}
@ -899,8 +909,9 @@ export function createStreamProcessor(
export function streamCompletion(
stream: any,
signal?: AbortSignal,
): AsyncGenerator<OpenAI.ChatCompletionChunk, void, unknown> {
return createStreamProcessor(stream)
return createStreamProcessor(stream, signal)
}
/**

View File

@ -110,7 +110,7 @@ export const TodoWriteTool = {
},
inputSchema,
userFacingName() {
return 'Write Todos'
return 'Update Todos'
},
async isEnabled() {
return true
@ -129,9 +129,8 @@ export const TodoWriteTool = {
return 'Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable'
},
renderToolUseMessage(input, { verbose }) {
// Return empty string to match reference implementation and avoid double rendering
// The tool result message will show the todo list
return ''
// Show a simple confirmation message when the tool is being used
return '{ params.todo }'
},
renderToolUseRejectedMessage() {
return <FallbackToolUseRejectedMessage />
@ -139,12 +138,23 @@ export const TodoWriteTool = {
renderToolResultMessage(output) {
const isError = typeof output === 'string' && output.startsWith('Error')
// If output contains todo data, render simple checkbox list
if (typeof output === 'object' && output && 'newTodos' in output) {
const { newTodos = [] } = output as any
// For non-error output, get current todos from storage and render them
if (!isError && typeof output === 'string') {
const currentTodos = getTodos()
if (currentTodos.length === 0) {
return (
<Box flexDirection="column" width="100%">
<Box flexDirection="row">
<Text color="#6B7280">&nbsp;&nbsp; &nbsp;</Text>
<Text color="#9CA3AF">No todos currently</Text>
</Box>
</Box>
)
}
// sort: [completed, in_progress, pending]
newTodos.sort((a, b) => {
// Sort: [completed, in_progress, pending]
const sortedTodos = [...currentTodos].sort((a, b) => {
const order = ['completed', 'in_progress', 'pending']
return (
order.indexOf(a.status) - order.indexOf(b.status) ||
@ -152,41 +162,52 @@ export const TodoWriteTool = {
)
})
// Render each todo item with proper styling
// Find the next pending task (first pending task after sorting)
const nextPendingIndex = sortedTodos.findIndex(todo => todo.status === 'pending')
return (
<Box justifyContent="space-between" overflowX="hidden" width="100%">
<Box flexDirection="row">
<Text>&nbsp;&nbsp; &nbsp;</Text>
<Box flexDirection="column">
{newTodos.map((todo: TodoItem, index: number) => {
const status_icon_map = {
completed: '🟢',
in_progress: '🟢',
pending: '🟡',
}
const checkbox = status_icon_map[todo.status]
<Box flexDirection="column" width="100%">
{sortedTodos.map((todo: TodoItem, index: number) => {
// Determine checkbox symbol and colors
let checkbox: string
let textColor: string
let isBold = false
let isStrikethrough = false
const status_color_map = {
completed: '#008000',
in_progress: '#008000',
pending: '#FFD700',
}
const text_color = status_color_map[todo.status]
if (todo.status === 'completed') {
checkbox = '☒'
textColor = '#6B7280' // Professional gray for completed
isStrikethrough = true
} else if (todo.status === 'in_progress') {
checkbox = '☐'
textColor = '#10B981' // Professional green for in progress
isBold = true
} else if (todo.status === 'pending') {
checkbox = '☐'
// Only the FIRST pending task gets purple highlight
if (index === nextPendingIndex) {
textColor = '#8B5CF6' // Professional purple for next pending
isBold = true
} else {
textColor = '#9CA3AF' // Muted gray for other pending
}
}
return (
<React.Fragment key={todo.id || index}>
<Text
color={text_color}
bold={todo.status !== 'pending'}
strikethrough={todo.status === 'completed'}
>
{checkbox} {todo.content}
</Text>
</React.Fragment>
)
})}
</Box>
</Box>
return (
<Box key={todo.id || index} flexDirection="row" marginBottom={0}>
<Text color="#6B7280">&nbsp;&nbsp; &nbsp;</Text>
<Box flexDirection="row" flexGrow={1}>
<Text color={textColor} bold={isBold} strikethrough={isStrikethrough}>
{checkbox}
</Text>
<Text> </Text>
<Text color={textColor} bold={isBold} strikethrough={isStrikethrough}>
{todo.content}
</Text>
</Box>
</Box>
)
})}
</Box>
)
}
@ -264,8 +285,10 @@ export const TodoWriteTool = {
yield {
type: 'result',
data: summary, // Return string instead of object to match interface
data: summary, // Return string to satisfy interface
resultForAssistant: summary,
// Store todo data in a way accessible to the renderer
// We'll modify the renderToolResultMessage to get todos from storage
}
} catch (error) {
const errorMessage =

View File

@ -164,8 +164,9 @@ export class ModelManager {
contextOverflow: boolean
usagePercentage: number
} {
const activeProfiles = this.modelProfiles.filter(p => p.isActive)
if (activeProfiles.length === 0) {
// Use ALL configured models, not just active ones
const allProfiles = this.getAllConfiguredModels()
if (allProfiles.length === 0) {
return {
success: false,
modelName: null,
@ -175,14 +176,10 @@ export class ModelManager {
}
}
// Sort by lastUsed (most recent first) then by createdAt
activeProfiles.sort((a, b) => {
const aLastUsed = a.lastUsed || 0
const bLastUsed = b.lastUsed || 0
if (aLastUsed !== bLastUsed) {
return bLastUsed - aLastUsed
}
return b.createdAt - a.createdAt
// Sort by createdAt for consistent cycling order (don't use lastUsed)
// Using lastUsed causes the order to change each time, preventing proper cycling
allProfiles.sort((a, b) => {
return a.createdAt - b.createdAt // Oldest first for consistent order
})
const currentMainModelName = this.config.modelPointers?.main
@ -192,8 +189,11 @@ export class ModelManager {
const previousModelName = currentModel?.name || null
if (!currentMainModelName) {
// No current main model, select first active
const firstModel = activeProfiles[0]
// No current main model, select first available (activate if needed)
const firstModel = allProfiles[0]
if (!firstModel.isActive) {
firstModel.isActive = true
}
this.setPointer('main', firstModel.modelName)
this.updateLastUsed(firstModel.modelName)
@ -210,13 +210,16 @@ export class ModelManager {
}
}
// Find current model index
const currentIndex = activeProfiles.findIndex(
// Find current model index in ALL models
const currentIndex = allProfiles.findIndex(
p => p.modelName === currentMainModelName,
)
if (currentIndex === -1) {
// Current model not found, select first
const firstModel = activeProfiles[0]
// Current model not found, select first available (activate if needed)
const firstModel = allProfiles[0]
if (!firstModel.isActive) {
firstModel.isActive = true
}
this.setPointer('main', firstModel.modelName)
this.updateLastUsed(firstModel.modelName)
@ -234,7 +237,7 @@ export class ModelManager {
}
// Check if only one model is available
if (activeProfiles.length === 1) {
if (allProfiles.length === 1) {
return {
success: false,
modelName: null,
@ -244,9 +247,15 @@ export class ModelManager {
}
}
// Get next model in cycle
const nextIndex = (currentIndex + 1) % activeProfiles.length
const nextModel = activeProfiles[nextIndex]
// Get next model in cycle (from ALL models)
const nextIndex = (currentIndex + 1) % allProfiles.length
const nextModel = allProfiles[nextIndex]
// Activate the model if it's not already active
const wasInactive = !nextModel.isActive
if (!nextModel.isActive) {
nextModel.isActive = true
}
// Analyze context compatibility for next model
const analysis = this.analyzeContextCompatibility(
@ -257,6 +266,11 @@ export class ModelManager {
// Always switch to next model, but return context status
this.setPointer('main', nextModel.modelName)
this.updateLastUsed(nextModel.modelName)
// Save configuration if we activated a new model
if (wasInactive) {
this.saveConfig()
}
return {
success: true,
@ -278,29 +292,43 @@ export class ModelManager {
blocked?: boolean
message?: string
} {
// Use the enhanced context check method for consistency
const result = this.switchToNextModelWithContextCheck(currentContextTokens)
// Special case: only one model available
if (
!result.success &&
result.previousModelName &&
this.getAvailableModels().length === 1
) {
return {
success: false,
modelName: null,
blocked: false,
message: `⚠️ Only one model configured (${result.previousModelName}). Use /model to add more models for switching.`,
if (!result.success) {
const allModels = this.getAllConfiguredModels()
if (allModels.length === 0) {
return {
success: false,
modelName: null,
blocked: false,
message: '❌ No models configured. Use /model to add models.',
}
} else if (allModels.length === 1) {
return {
success: false,
modelName: null,
blocked: false,
message: `⚠️ Only one model configured (${allModels[0].modelName}). Use /model to add more models for switching.`,
}
}
}
// Convert the detailed result to the simple interface
const currentModel = this.findModelProfile(this.config.modelPointers?.main)
const allModels = this.getAllConfiguredModels()
const currentIndex = allModels.findIndex(m => m.modelName === currentModel?.modelName)
const totalModels = allModels.length
return {
success: result.success,
modelName: result.modelName,
blocked: result.contextOverflow,
message: result.contextOverflow
? `Context usage: ${result.usagePercentage.toFixed(1)}%`
: undefined,
message: result.success
? result.contextOverflow
? `⚠️ Context usage: ${result.usagePercentage.toFixed(1)}% - ${result.modelName}`
: `✅ Switched to ${result.modelName} (${currentIndex + 1}/${totalModels})${currentModel?.provider ? ` [${currentModel.provider}]` : ''}`
: `❌ Failed to switch models`,
}
}
@ -368,9 +396,9 @@ export class ModelManager {
requiresCompression: boolean
estimatedTokensAfterSwitch: number
} {
const modelName = this.switchToNextModel(currentContextTokens)
const result = this.switchToNextModel(currentContextTokens)
if (!modelName) {
if (!result.success || !result.modelName) {
return {
modelName: null,
contextAnalysis: null,
@ -382,7 +410,7 @@ export class ModelManager {
const newModel = this.getModel('main')
if (!newModel) {
return {
modelName,
modelName: result.modelName,
contextAnalysis: null,
requiresCompression: false,
estimatedTokensAfterSwitch: currentContextTokens,
@ -395,7 +423,7 @@ export class ModelManager {
)
return {
modelName,
modelName: result.modelName,
contextAnalysis: analysis,
requiresCompression: analysis.severity === 'critical',
estimatedTokensAfterSwitch: currentContextTokens,
@ -563,19 +591,69 @@ export class ModelManager {
}
/**
* Get all available models for pointer assignment
* Get all active models for pointer assignment
*/
getAvailableModels(): ModelProfile[] {
return this.modelProfiles.filter(p => p.isActive)
}
/**
* Get all available model names (modelName field)
* Get all configured models (both active and inactive) for switching
*/
getAllConfiguredModels(): ModelProfile[] {
return this.modelProfiles
}
/**
* Get all available model names (modelName field) - active only
*/
getAllAvailableModelNames(): string[] {
return this.getAvailableModels().map(p => p.modelName)
}
/**
* Get all configured model names (both active and inactive)
*/
getAllConfiguredModelNames(): string[] {
return this.getAllConfiguredModels().map(p => p.modelName)
}
/**
* Debug method to get detailed model switching information
*/
getModelSwitchingDebugInfo(): {
totalModels: number
activeModels: number
inactiveModels: number
currentMainModel: string | null
availableModels: Array<{
name: string
modelName: string
provider: string
isActive: boolean
lastUsed?: number
}>
modelPointers: Record<string, string | undefined>
} {
const availableModels = this.getAvailableModels()
const currentMainModelName = this.config.modelPointers?.main
return {
totalModels: this.modelProfiles.length,
activeModels: availableModels.length,
inactiveModels: this.modelProfiles.length - availableModels.length,
currentMainModel: currentMainModelName || null,
availableModels: this.modelProfiles.map(p => ({
name: p.name,
modelName: p.modelName,
provider: p.provider,
isActive: p.isActive,
lastUsed: p.lastUsed,
})),
modelPointers: this.config.modelPointers || {},
}
}
/**
* Remove a model profile
*/