Compare commits

...

5 Commits

Author SHA1 Message Date
claude-code-best
0a90b218c3 fix: 同步 permissionModeTitle 测试断言与 bypassPermissions 的新 title 值
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 15:07:50 +08:00
claude-code-best
de9494c0a3 feat: 添加 RSS 内存指示器并解绑 auto 权限模式与 TRANSCRIPT_CLASSIFIER
- 在 REPL 底栏添加 RSS 内存使用显示,512MB 以下 dimColor,512MB-1GB warning 色,1GB 以上 error 色
- auto 权限模式不再依赖 TRANSCRIPT_CLASSIFIER feature flag,classifier 不可用时 fallback 到 prompting
- Config 面板 defaultPermissionMode 使用类型安全的 permissionModeFromString,显示改用 shortTitle
- bypassPermissions title 缩短为 Bypass 与 shortTitle 一致

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 15:01:57 +08:00
claude-code-best
e7e1f7a34d fix: 修复 PowerShellTool.isSearchOrReadCommand 在 input 为 undefined 时崩溃的问题
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 15:01:57 +08:00
claude-code-best
9a5998eaef fix: 修复 Config 面板第二次进入时左右键无反应的问题
将左右键枚举值切换从依赖 DOM 焦点的 onKeyDown 改为 useKeybindings 系统,
确保按键在任何焦点状态下都能正确响应。同时修复 isSearchMode 初始值和布局问题。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 15:01:57 +08:00
claude-code-best
dff678924e refactor: 将 convertMessagesToLangfuse 参数类型从 unknown 收窄为联合类型
将 readonly unknown[] 改为 readonly LangfuseInputMessage[],
其中 LangfuseInputMessage = UserMessage | AssistantMessage | ChatCompletionMessageParam,
让调用方获得编译期类型检查。
2026-04-26 15:01:57 +08:00
11 changed files with 82 additions and 40 deletions

View File

@@ -421,7 +421,7 @@ export const PowerShellTool = buildTool({
isSearch: boolean isSearch: boolean
isRead: boolean isRead: boolean
} { } {
if (!input.command) { if (!input?.command) {
return { isSearch: false, isRead: false } return { isSearch: false, isRead: false }
} }
return isSearchOrReadPowerShellCommand(input.command) return isSearchOrReadPowerShellCommand(input.command)

View File

@@ -42,7 +42,7 @@ import { usePrStatus } from '../../hooks/usePrStatus.js'
import { Byline, KeyboardShortcutHint } from '@anthropic/ink' import { Byline, KeyboardShortcutHint } from '@anthropic/ink'
import { useTerminalSize } from '../../hooks/useTerminalSize.js' import { useTerminalSize } from '../../hooks/useTerminalSize.js'
import { useTasksV2 } from '../../hooks/useTasksV2.js' import { useTasksV2 } from '../../hooks/useTasksV2.js'
import { formatDuration } from '../../utils/format.js' import { formatDuration, formatFileSize } from '../../utils/format.js'
import { VoiceWarmupHint } from './VoiceIndicator.js' import { VoiceWarmupHint } from './VoiceIndicator.js'
import { useVoiceEnabled } from '../../hooks/useVoiceEnabled.js' import { useVoiceEnabled } from '../../hooks/useVoiceEnabled.js'
import { useVoiceState } from '../../context/voice.js' import { useVoiceState } from '../../context/voice.js'
@@ -63,6 +63,26 @@ const NO_OP_SUBSCRIBE = (_cb: () => void) => () => {}
const NULL = () => null const NULL = () => null
const MAX_VOICE_HINT_SHOWS = 3 const MAX_VOICE_HINT_SHOWS = 3
const RSS_UPDATE_INTERVAL_MS = 5_000
type RssState = { text: string; level: 'normal' | 'warning' | 'error' }
function useRssDisplay(): RssState | null {
const [state, setState] = useState<RssState | null>(null)
useEffect(() => {
function update(): void {
const mb = process.memoryUsage().rss / (1024 * 1024)
const level = mb >= 1024 ? 'error' : mb >= 512 ? 'warning' : 'normal'
const text = formatFileSize(mb * 1024 * 1024)
setState(prev => (prev?.text === text ? prev : { text, level }))
}
update()
const timer = setInterval(update, RSS_UPDATE_INTERVAL_MS)
return () => clearInterval(timer)
}, [])
return state
}
type Props = { type Props = {
exitMessage: { exitMessage: {
show: boolean show: boolean
@@ -315,6 +335,7 @@ function ModeIndicator({
const isKillAgentsConfirmShowing = useAppState( const isKillAgentsConfirmShowing = useAppState(
s => s.notifications.current?.key === 'kill-agents-confirm', s => s.notifications.current?.key === 'kill-agents-confirm',
) )
const rssState = useRssDisplay()
// Derive team info from teamContext (no filesystem I/O needed) // Derive team info from teamContext (no filesystem I/O needed)
// Match the same logic as TeamStatus to avoid trailing separator // Match the same logic as TeamStatus to avoid trailing separator
@@ -428,6 +449,18 @@ function ModeIndicator({
/>, />,
] ]
: []), : []),
// RSS memory indicator — always visible
...(rssState
? [
<Text
key="rss"
dimColor={rssState.level === 'normal'}
color={rssState.level === 'error' ? 'error' : rssState.level === 'warning' ? 'warning' : undefined}
>
{rssState.text}
</Text>,
]
: []),
] ]
// Check if any in-process teammates exist (for hint text cycling) // Check if any in-process teammates exist (for hint text cycling)

View File

@@ -16,6 +16,7 @@ import {
import chalk from 'chalk'; import chalk from 'chalk';
import { import {
permissionModeTitle, permissionModeTitle,
permissionModeShortTitle,
permissionModeFromString, permissionModeFromString,
toExternalPermissionMode, toExternalPermissionMode,
isExternalPermissionMode, isExternalPermissionMode,
@@ -153,7 +154,7 @@ export function Config({
const initialLanguage = React.useRef(currentLanguage); const initialLanguage = React.useRef(currentLanguage);
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
const [scrollOffset, setScrollOffset] = useState(0); const [scrollOffset, setScrollOffset] = useState(0);
const [isSearchMode, setIsSearchMode] = useState(true); const [isSearchMode, setIsSearchMode] = useState(false);
const isTerminalFocused = useTerminalFocus(); const isTerminalFocused = useTerminalFocus();
const { rows } = useTerminalSize(); const { rows } = useTerminalSize();
// contentHeight is set by Settings.tsx (same value passed to Tabs to fix // contentHeight is set by Settings.tsx (same value passed to Tabs to fix
@@ -167,6 +168,9 @@ export function Config({
const thinkingEnabled = useAppState(s => s.thinkingEnabled); const thinkingEnabled = useAppState(s => s.thinkingEnabled);
const isFastMode = useAppState(s => (isFastModeEnabled() ? s.fastMode : false)); const isFastMode = useAppState(s => (isFastModeEnabled() ? s.fastMode : false));
const promptSuggestionEnabled = useAppState(s => s.promptSuggestionEnabled); const promptSuggestionEnabled = useAppState(s => s.promptSuggestionEnabled);
const currentDefaultPermissionMode = permissionModeFromString(
settingsData?.permissions?.defaultMode ?? 'default',
);
// Show auto in the default-mode dropdown when the user has opted in OR the // Show auto in the default-mode dropdown when the user has opted in OR the
// config is fully 'enabled' — even if currently circuit-broken ('disabled'), // config is fully 'enabled' — even if currently circuit-broken ('disabled'),
// an opted-in user should still see it in settings (it's a temporary state). // an opted-in user should still see it in settings (it's a temporary state).
@@ -558,27 +562,23 @@ export function Config({
{ {
id: 'defaultPermissionMode', id: 'defaultPermissionMode',
label: 'Default permission mode', label: 'Default permission mode',
value: settingsData?.permissions?.defaultMode || 'default', value: currentDefaultPermissionMode,
options: (() => { options: (() => {
const priorityOrder: PermissionMode[] = ['default', 'plan']; const priorityOrder: PermissionMode[] = ['default', 'plan'];
const allModes: readonly PermissionMode[] = feature('TRANSCRIPT_CLASSIFIER') return [...priorityOrder, ...PERMISSION_MODES.filter(m => !priorityOrder.includes(m))];
? PERMISSION_MODES
: EXTERNAL_PERMISSION_MODES;
const excluded: PermissionMode[] = ['bypassPermissions'];
if (feature('TRANSCRIPT_CLASSIFIER') && !showAutoInDefaultModePicker) {
excluded.push('auto');
}
return [...priorityOrder, ...allModes.filter(m => !priorityOrder.includes(m) && !excluded.includes(m))];
})(), })(),
type: 'enum' as const, type: 'enum' as const,
onChange(mode: string) { onChange(mode: string) {
const parsedMode = permissionModeFromString(mode); const parsedMode = permissionModeFromString(mode);
// Internal modes (e.g. auto) are stored directly // auto is an internal-only mode — store it directly, don't convert
const validatedMode = isExternalPermissionMode(parsedMode) ? toExternalPermissionMode(parsedMode) : parsedMode; // to its external mapping ('default') which would make it invisible.
const validatedMode = parsedMode === 'auto'
? parsedMode
: (isExternalPermissionMode(parsedMode) ? toExternalPermissionMode(parsedMode) : parsedMode);
const result = updateSettingsForSource('userSettings', { const result = updateSettingsForSource('userSettings', {
permissions: { permissions: {
...settingsData?.permissions, ...settingsData?.permissions,
defaultMode: validatedMode as ExternalPermissionMode, defaultMode: validatedMode as (typeof PERMISSION_MODES)[number],
}, },
}); });
@@ -1548,6 +1548,8 @@ export function Config({
'scroll:lineUp': () => moveSelection(-1), 'scroll:lineUp': () => moveSelection(-1),
'scroll:lineDown': () => moveSelection(1), 'scroll:lineDown': () => moveSelection(1),
'select:accept': toggleSetting, 'select:accept': toggleSetting,
'select:previousValue': () => toggleSetting(),
'select:nextValue': () => toggleSetting(),
'settings:search': () => { 'settings:search': () => {
setIsSearchMode(true); setIsSearchMode(true);
setSearchQuery(''); setSearchQuery('');
@@ -1936,13 +1938,13 @@ export function Config({
return ( return (
<React.Fragment key={setting.id}> <React.Fragment key={setting.id}>
<Box> <Box width="100%">
<Box width={44}> <Box width={44}>
<Text color={isSelected ? 'suggestion' : undefined}> <Text color={isSelected ? 'suggestion' : undefined}>
{isSelected ? figures.pointer : ' '} {setting.label} {isSelected ? figures.pointer : ' '} {setting.label}
</Text> </Text>
</Box> </Box>
<Box key={isSelected ? 'selected' : 'unselected'}> <Box flexGrow={1}>
{setting.type === 'boolean' ? ( {setting.type === 'boolean' ? (
<> <>
<Text color={isSelected ? 'suggestion' : undefined}>{setting.value.toString()}</Text> <Text color={isSelected ? 'suggestion' : undefined}>{setting.value.toString()}</Text>
@@ -1963,7 +1965,7 @@ export function Config({
</Text> </Text>
) : setting.id === 'defaultPermissionMode' ? ( ) : setting.id === 'defaultPermissionMode' ? (
<Text color={isSelected ? 'suggestion' : undefined}> <Text color={isSelected ? 'suggestion' : undefined}>
{permissionModeTitle(setting.value as PermissionMode)} {permissionModeShortTitle(setting.value as PermissionMode)}
</Text> </Text>
) : setting.id === 'autoUpdatesChannel' && autoUpdaterDisabledReason ? ( ) : setting.id === 'autoUpdatesChannel' && autoUpdaterDisabledReason ? (
<Box flexDirection="column"> <Box flexDirection="column">

View File

@@ -117,6 +117,9 @@ export const DEFAULT_BINDINGS: KeybindingBlock[] = [
j: 'select:next', j: 'select:next',
'ctrl+p': 'select:previous', 'ctrl+p': 'select:previous',
'ctrl+n': 'select:next', 'ctrl+n': 'select:next',
// Cycle enum values left/right (same as left/right arrow in handleKeyDown)
left: 'select:previousValue',
right: 'select:nextValue',
// Toggle/activate the selected setting (space only — enter saves & closes) // Toggle/activate the selected setting (space only — enter saves & closes)
space: 'select:accept', space: 'select:accept',
// Save and close the config panel // Save and close the config panel

View File

@@ -168,6 +168,8 @@ export const KEYBINDING_ACTIONS = [
'settings:search', 'settings:search',
'settings:retry', 'settings:retry',
'settings:close', 'settings:close',
'select:previousValue',
'select:nextValue',
// Voice actions // Voice actions
'voice:pushToTalk', 'voice:pushToTalk',
] as const ] as const

View File

@@ -231,6 +231,7 @@ describe('Langfuse integration', () => {
test('merges assistant tool calls from OpenAI-style array content', async () => { test('merges assistant tool calls from OpenAI-style array content', async () => {
const { convertMessagesToLangfuse } = await import('../convert.js') const { convertMessagesToLangfuse } = await import('../convert.js')
// Content part with embedded tool_calls is non-standard; cast for defensive test
const result = convertMessagesToLangfuse([ const result = convertMessagesToLangfuse([
{ {
role: 'assistant', role: 'assistant',
@@ -255,7 +256,7 @@ describe('Langfuse integration', () => {
}, },
], ],
}, },
]) ] as any)
expect(result).toEqual([ expect(result).toEqual([
{ {

View File

@@ -10,7 +10,8 @@
* - tool_result blocks → separate { role: 'tool' } messages * - tool_result blocks → separate { role: 'tool' } messages
*/ */
import type { AssistantMessage } from 'src/types/message.js' import type { AssistantMessage, UserMessage } from 'src/types/message.js'
import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions/completions.mjs'
type LangfuseContentPart = type LangfuseContentPart =
| { type: 'text'; text: string } | { type: 'text'; text: string }
@@ -79,6 +80,12 @@ function mergeToolCalls(
return [...merged.values()] return [...merged.values()]
} }
/** Union of all message formats accepted by Langfuse converters. */
type LangfuseInputMessage =
| UserMessage
| AssistantMessage
| ChatCompletionMessageParam
/** Normalize a content block into a LangfuseContentPart (non-tool_use, non-tool_result) */ /** Normalize a content block into a LangfuseContentPart (non-tool_use, non-tool_result) */
function toContentPart(block: Record<string, unknown>): LangfuseContentPart | null { function toContentPart(block: Record<string, unknown>): LangfuseContentPart | null {
const type = block.type as string | undefined const type = block.type as string | undefined
@@ -178,7 +185,7 @@ function toRoleFromWrappedMessage(msg: Record<string, unknown>): 'user' | 'assis
/** Convert internal or OpenAI-style messages → Langfuse input format */ /** Convert internal or OpenAI-style messages → Langfuse input format */
export function convertMessagesToLangfuse( export function convertMessagesToLangfuse(
messages: readonly unknown[], messages: readonly LangfuseInputMessage[],
systemPrompt?: readonly string[], systemPrompt?: readonly string[],
): LangfuseChatMessage[] { ): LangfuseChatMessage[] {
const result: LangfuseChatMessage[] = [] const result: LangfuseChatMessage[] = []

View File

@@ -30,9 +30,11 @@ export type PermissionMode = InternalPermissionMode
// Runtime validation set: modes that are user-addressable (settings.json // Runtime validation set: modes that are user-addressable (settings.json
// defaultMode, --permission-mode CLI flag, conversation recovery). // defaultMode, --permission-mode CLI flag, conversation recovery).
// 'auto' is always available — when TRANSCRIPT_CLASSIFIER is off, the
// classifier is unavailable and auto mode falls back to prompting.
export const INTERNAL_PERMISSION_MODES = [ export const INTERNAL_PERMISSION_MODES = [
...EXTERNAL_PERMISSION_MODES, ...EXTERNAL_PERMISSION_MODES,
...(feature('TRANSCRIPT_CLASSIFIER') ? (['auto'] as const) : ([] as const)), 'auto' as const,
] as const satisfies readonly PermissionMode[] ] as const satisfies readonly PermissionMode[]
export const PERMISSION_MODES = INTERNAL_PERMISSION_MODES export const PERMISSION_MODES = INTERNAL_PERMISSION_MODES

View File

@@ -64,7 +64,7 @@ const PERMISSION_MODE_CONFIG: Partial<
external: 'acceptEdits', external: 'acceptEdits',
}, },
bypassPermissions: { bypassPermissions: {
title: 'Bypass Permissions', title: 'Bypass',
shortTitle: 'Bypass', shortTitle: 'Bypass',
symbol: '⏵⏵', symbol: '⏵⏵',
color: 'error', color: 'error',
@@ -77,17 +77,13 @@ const PERMISSION_MODE_CONFIG: Partial<
color: 'error', color: 'error',
external: 'dontAsk', external: 'dontAsk',
}, },
...(feature('TRANSCRIPT_CLASSIFIER') auto: {
? { title: 'Auto',
auto: { shortTitle: 'Auto',
title: 'Auto mode', symbol: '⏵⏵',
shortTitle: 'Auto', color: 'warning' as ModeColorKey,
symbol: '⏵⏵', external: 'default' as ExternalPermissionMode,
color: 'warning' as ModeColorKey, },
external: 'default' as ExternalPermissionMode,
},
}
: {}),
} }
/** /**

View File

@@ -70,7 +70,7 @@ describe("permissionModeTitle", () => {
expect(permissionModeTitle("default")).toBe("Default"); expect(permissionModeTitle("default")).toBe("Default");
expect(permissionModeTitle("plan")).toBe("Plan Mode"); expect(permissionModeTitle("plan")).toBe("Plan Mode");
expect(permissionModeTitle("acceptEdits")).toBe("Accept edits"); expect(permissionModeTitle("acceptEdits")).toBe("Accept edits");
expect(permissionModeTitle("bypassPermissions")).toBe("Bypass Permissions"); expect(permissionModeTitle("bypassPermissions")).toBe("Bypass");
expect(permissionModeTitle("dontAsk")).toBe("Don't Ask"); expect(permissionModeTitle("dontAsk")).toBe("Don't Ask");
}); });

View File

@@ -57,11 +57,7 @@ export const PermissionsSchema = lazySchema(() =>
'List of permission rules that should always prompt for confirmation', 'List of permission rules that should always prompt for confirmation',
), ),
defaultMode: z defaultMode: z
.enum( .enum(PERMISSION_MODES)
feature('TRANSCRIPT_CLASSIFIER')
? PERMISSION_MODES
: EXTERNAL_PERMISSION_MODES,
)
.optional() .optional()
.describe('Default permission mode when Claude Code needs access'), .describe('Default permission mode when Claude Code needs access'),
disableBypassPermissionsMode: z disableBypassPermissionsMode: z