add workspace scope tool usage for edit / delete file (#12)

This commit is contained in:
davi0015 2026-04-22 21:59:56 +08:00 committed by GitHub
parent 85a539d49d
commit f9cb764fbc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 238 additions and 16 deletions

69
.cursorignore Normal file
View file

@ -0,0 +1,69 @@
# .cursorignore — paths excluded from Cursor's indexing / @-mentions / semantic search.
# Keep this list tight: anything here won't be searchable or available for AI context.
# Rule of thumb: exclude large generated/vendored content, keep source + real tests.
# -------- Dependencies (~1.4 GB) --------
node_modules/
build/node_modules/
remote/node_modules/
# -------- Build outputs --------
# Top-level VSCode build artifacts (/out*/ pattern from .gitignore)
out/
out-build/
out-vscode/
out-vscode-min/
out-vscode-web/
out-vscode-web-min/
.build/
# Void scratch / dev-mode user data
.tmp/
.tmp2/
.vscode-test/
# Coverage / test outputs (sources stay indexable)
coverage/
test-results/
test-results.xml
test/smoke/out/
test/unit/out/
test/integration/out/
test_data/
.profile-oss
# -------- Void React bundle (generated) --------
# `react/src2/` is scope-tailwind's generated copy of `react/src/`.
# `react/out/` is the esbuild bundle. Indexing src/ (the real source) is enough.
src/vs/workbench/contrib/void/browser/react/src2/
src/vs/workbench/contrib/void/browser/react/out/
# -------- Extension build outputs --------
.vscode/extensions/**/out/
extensions/**/out/
extensions/**/dist/
dist/
# -------- CLI / native builds --------
cli/target/
cli/openssl/
# -------- Generated / minified (noise in search) --------
*.min.js
*.map
*.tsbuildinfo
*.snap.actual
vscode.lsif
vscode.db
product.overrides.json
# -------- OS / editor junk --------
.DS_Store
Thumbs.db
npm-debug.log
*.log
.cache
# -------- Local notes (optional: keep indexed if you want Cursor to see them) --------
# Uncomment to hide from Cursor search:
# mynote.md

View file

@ -16,7 +16,7 @@ import { AnthropicReasoning, getErrorMessage, type LLMUsage, RawToolCallObj, Raw
import { generateUuid } from '../../../../base/common/uuid.js';
import { FeatureName, ModelSelection, ModelSelectionOptions } from '../common/voidSettingsTypes.js';
import { IVoidSettingsService } from '../common/voidSettingsService.js';
import { approvalTypeOfBuiltinToolName, BuiltinToolCallParams, ToolCallParams, ToolName, ToolResult } from '../common/toolsServiceTypes.js';
import { approvalIsWorkspaceScoped, approvalTypeOfBuiltinToolName, BuiltinToolCallParams, normalizeAutoApproveMode, ToolCallParams, ToolName, ToolResult } from '../common/toolsServiceTypes.js';
import { IToolsService } from './toolsService.js';
import { CancellationToken } from '../../../../base/common/cancellation.js';
import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js';
@ -862,7 +862,27 @@ class ChatThreadService extends Disposable implements IChatThreadService {
const approvalType = isBuiltInTool ? approvalTypeOfBuiltinToolName[toolName] : 'MCP tools'
if (approvalType) {
const autoApprove = this._settingsService.state.globalSettings.autoApprove[approvalType]
const mode = normalizeAutoApproveMode(this._settingsService.state.globalSettings.autoApprove[approvalType])
// Tri-state resolution:
// 'off' → always prompt
// 'all' → skip prompt
// 'workspace' → skip prompt iff target URI is inside an open workspace folder.
// For non-workspace-scoped tiers ('terminal', 'MCP tools'),
// 'workspace' is semantically equivalent to 'all' — commands/MCPs
// don't have a single target URI to scope against and can
// legitimately operate outside the workspace.
let autoApprove = false
if (mode === 'all') {
autoApprove = true
} else if (mode === 'workspace') {
if (approvalIsWorkspaceScoped(approvalType) && isBuiltInTool) {
const targetUri = (toolParams as { uri?: URI } | undefined)?.uri
autoApprove = !!targetUri && this._workspaceContextService.isInsideWorkspace(targetUri)
} else {
autoApprove = true
}
}
// add a tool_request because we use it for UI if a tool is loading (this should be improved in the future)
this._addMessageToThread(threadId, { role: 'tool', type: 'tool_request', content: '(Awaiting user permission...)', result: null, name: toolName, params: toolParams, id: toolId, rawParams: opts.unvalidatedToolParams, rawParamsStr, mcpServerName })
if (!autoApprove) {

View file

@ -1217,6 +1217,69 @@ export const VoidSwitch = ({
// N-way segmented control used for tri-state (or bi-state) settings like the tool auto-approve
// tier mode (off / workspace / all). Renders as a row of individually-rounded pill buttons with
// visible gaps between them, so each option reads as a distinct clickable target. The active pill
// fills with a high-contrast color (matching VoidSwitch's active state) and the inactive pills
// have a subtle bordered look so they're visible against the panel background.
//
// Uses explicit zinc-* Tailwind colors rather than theme variables for the active fill, because
// `void-*` color variables can be subtle in some themes and we want consistent high contrast.
export const VoidSegmentedControl = <T extends string>({
value,
onChange,
options,
size = 'xs',
disabled = false,
}: {
value: T;
onChange: (value: T) => void;
options: { value: T; label: string; title?: string }[];
size?: 'xxs' | 'xs' | 'sm';
disabled?: boolean;
}) => {
// Bigger padding + slightly larger font than VoidSwitch because labels are text, not a dot.
const btnPad =
size === 'xxs' ? 'px-2 py-0.5 text-xs' :
size === 'xs' ? 'px-3 py-1 text-sm' :
'px-3.5 py-1.5 text-sm'
return (
<div
role="radiogroup"
className={`inline-flex items-center gap-1 ${disabled ? 'opacity-50 pointer-events-none' : ''}`}
>
{options.map((opt) => {
const active = opt.value === value
return (
<button
key={opt.value}
type="button"
role="radio"
aria-checked={active}
title={opt.title ?? opt.label}
onClick={() => !disabled && !active && onChange(opt.value)}
className={`
${btnPad}
rounded
leading-none
whitespace-nowrap
transition-colors duration-75
border
${active
? 'bg-zinc-900 text-white border-zinc-900 dark:bg-white dark:text-zinc-900 dark:border-white font-medium'
: 'bg-transparent text-void-fg-3 border-void-border-2 hover:text-void-fg-1 hover:border-void-fg-3 hover:bg-void-bg-2-hover cursor-pointer'}
`}
>
{opt.label}
</button>
)
})}
</div>
)
}
export const VoidCheckBox = ({ label, value, onClick, className }: { label: string, value: boolean, onClick: (checked: boolean) => void, className?: string }) => {
const divRef = useRef<HTMLDivElement | null>(null)
const instanceRef = useRef<Checkbox | null>(null)

View file

@ -6,7 +6,7 @@
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useState, useRef } from 'react';
import { ProviderName, SettingName, displayInfoOfSettingName, providerNames, VoidStatefulModelInfo, customSettingNamesOfProvider, RefreshableProviderName, refreshableProviderNames, displayInfoOfProviderName, nonlocalProviderNames, localProviderNames, GlobalSettingName, featureNames, displayInfoOfFeatureName, isProviderNameDisabled, FeatureName, hasDownloadButtonsOnModelsProviderNames, subTextMdOfProviderName } from '../../../../common/voidSettingsTypes.js'
import ErrorBoundary from '../sidebar-tsx/ErrorBoundary.js'
import { VoidButtonBgDarken, VoidCustomDropdownBox, VoidInputBox2, VoidSimpleInputBox, VoidSwitch } from '../util/inputs.js'
import { VoidButtonBgDarken, VoidCustomDropdownBox, VoidInputBox2, VoidSegmentedControl, VoidSimpleInputBox, VoidSwitch } from '../util/inputs.js'
import { useAccessor, useIsDark, useIsOptedOut, useRefreshModelListener, useRefreshModelState, useSettingsState } from '../util/services.js'
import { X, RefreshCw, Loader2, Check, Asterisk, Plus, GripVertical } from 'lucide-react'
import { URI } from '../../../../../../../base/common/uri.js'
@ -15,7 +15,7 @@ import { ChatMarkdownRender } from '../markdown/ChatMarkdownRender.js'
import { WarningBox } from './WarningBox.js'
import { os } from '../../../../common/helpers/systemInfo.js'
import { IconLoading } from '../sidebar-tsx/SidebarChat.js'
import { ToolApprovalType, toolApprovalTypes } from '../../../../common/toolsServiceTypes.js'
import { AutoApproveMode, approvalIsWorkspaceScoped, normalizeAutoApproveMode, ToolApprovalType, toolApprovalTypes } from '../../../../common/toolsServiceTypes.js'
import Severity from '../../../../../../../base/common/severity.js'
import { getModelCapabilities, modelOverrideKeys, ModelOverrides } from '../../../../common/modelCapabilities.js';
import { TransferEditorType, TransferFilesInfo } from '../../../extensionTransferTypes.js';
@ -1107,25 +1107,55 @@ const RedoOnboardingButton = ({ className }: { className?: string }) => {
// Renders the auto-approve control for a given tool tier. For workspace-scoped tiers
// ('edits', 'delete') this is a 3-way segmented control (Off / Workspace / Everywhere). For
// unscoped tiers ('terminal', 'MCP tools'), where workspace scoping has no meaning, we keep the
// original compact on/off `VoidSwitch` — the extra radio options would just be UI noise. Under
// the hood the tri-state storage is preserved so the check in `chatThreadService` can be uniform:
// the switch maps Off → 'off' and On → 'all'.
export const ToolApprovalTypeSwitch = ({ approvalType, size, desc }: { approvalType: ToolApprovalType, size: "xxs" | "xs" | "sm" | "sm+" | "md", desc: string }) => {
const accessor = useAccessor()
const voidSettingsService = accessor.get('IVoidSettingsService')
const voidSettingsState = useSettingsState()
const metricsService = accessor.get('IMetricsService')
const onToggleAutoApprove = useCallback((approvalType: ToolApprovalType, newValue: boolean) => {
const writeMode = useCallback((newMode: AutoApproveMode) => {
voidSettingsService.setGlobalSetting('autoApprove', {
...voidSettingsService.state.globalSettings.autoApprove,
[approvalType]: newValue
[approvalType]: newMode,
})
metricsService.capture('Tool Auto-Accept Toggle', { enabled: newValue })
}, [voidSettingsService, metricsService])
metricsService.capture('Tool Auto-Accept Toggle', { enabled: newMode !== 'off', mode: newMode, tier: approvalType })
}, [voidSettingsService, metricsService, approvalType])
const currentMode = normalizeAutoApproveMode(voidSettingsState.globalSettings.autoApprove[approvalType])
const isScoped = approvalIsWorkspaceScoped(approvalType)
if (!isScoped) {
// Unscoped tier (terminal, MCP tools): simple boolean switch. Store Off→'off', On→'all'.
return <>
<VoidSwitch
size={size}
value={currentMode !== 'off'}
onChange={(newVal) => writeMode(newVal ? 'all' : 'off')}
/>
<span className="text-void-fg-3 text-xs">{desc}</span>
</>
}
// Workspace-scoped tier (edits, delete): tri-state radio.
const segSize: 'xxs' | 'xs' | 'sm' = size === 'xxs' ? 'xxs' : size === 'sm' || size === 'sm+' || size === 'md' ? 'sm' : 'xs'
const options: { value: AutoApproveMode; label: string; title?: string }[] = [
{ value: 'off', label: 'Off', title: 'Always ask for permission' },
{ value: 'workspace', label: 'Workspace', title: 'Auto-approve only when the file is inside an open workspace folder' },
{ value: 'all', label: 'Everywhere', title: 'Auto-approve regardless of path (including outside workspace)' },
]
return <>
<VoidSwitch
size={size}
value={voidSettingsState.globalSettings.autoApprove[approvalType] ?? false}
onChange={(newVal) => onToggleAutoApprove(approvalType, newVal)}
<VoidSegmentedControl
size={segSize}
value={currentMode}
onChange={writeMode}
options={options}
/>
<span className="text-void-fg-3 text-xs">{desc}</span>
</>

View file

@ -18,9 +18,19 @@ export type ShallowDirectoryItem = {
}
export const approvalTypeOfBuiltinToolName: Partial<{ [T in BuiltinToolName]?: 'edits' | 'terminal' | 'MCP tools' }> = {
// Approval tiers for built-in tools. Kept separate so users can opt into auto-approve at different
// safety levels:
// - 'edits' = reversible changes (edit_file always revertable via checkpoint, create/rewrite
// likewise). Workspace-scoped when auto-approved (see chatThreadService).
// - 'delete' = irreversible destructive ops (delete_file_or_folder). Split out from 'edits' so
// auto-approving normal edits doesn't silently enable auto-delete. Also workspace-
// scoped when auto-approved.
// - 'terminal' = run_command and persistent terminal ops. NOT workspace-scoped (commands can
// legitimately operate outside the workspace, e.g. `brew install`).
// - 'MCP tools'= all MCP tools; unscoped.
export const approvalTypeOfBuiltinToolName: Partial<{ [T in BuiltinToolName]?: 'edits' | 'delete' | 'terminal' | 'MCP tools' }> = {
'create_file_or_folder': 'edits',
'delete_file_or_folder': 'edits',
'delete_file_or_folder': 'delete',
'rewrite_file': 'edits',
'edit_file': 'edits',
'run_command': 'terminal',
@ -39,6 +49,31 @@ export const toolApprovalTypes = new Set<ToolApprovalType>([
])
// Auto-approve mode per tier. Tri-state:
// 'off' — always prompt
// 'workspace' — auto-approve when the target is inside an open workspace folder; prompt otherwise
// 'all' — auto-approve regardless of path
// For tiers that don't have a meaningful workspace scope ('terminal', 'MCP tools'), 'workspace'
// is rendered in UI as a simple on/off and stored as 'all' when enabled. See
// `approvalIsWorkspaceScoped` below.
export type AutoApproveMode = 'off' | 'workspace' | 'all'
// Returns true if the tier's behavior differs between a workspace-internal vs external path.
// Only file-modification tiers are workspace-scoped today: `edits` and `delete`.
export const approvalIsWorkspaceScoped = (t: ToolApprovalType): boolean =>
t === 'edits' || t === 'delete'
// Normalizes stored auto-approve values to the tri-state enum. Accepts the legacy boolean shape
// that was written before the tri-state was introduced:
// true → 'workspace' (safe default — opt into 'all' explicitly via the radio)
// false → 'off'
export const normalizeAutoApproveMode = (raw: AutoApproveMode | boolean | undefined): AutoApproveMode => {
if (raw === undefined) return 'off'
if (typeof raw === 'boolean') return raw ? 'workspace' : 'off'
return raw
}
// PARAMS OF TOOL CALL

View file

@ -5,7 +5,7 @@
*--------------------------------------------------------------------------------------*/
import { defaultModelsOfProvider, defaultProviderSettings, ModelOverrides } from './modelCapabilities.js';
import { ToolApprovalType } from './toolsServiceTypes.js';
import { AutoApproveMode, ToolApprovalType } from './toolsServiceTypes.js';
import { VoidSettingsState } from './voidSettingsService.js'
@ -446,7 +446,12 @@ export type GlobalSettings = {
syncSCMToChat: boolean;
enableFastApply: boolean;
chatMode: ChatMode;
autoApprove: { [approvalType in ToolApprovalType]?: boolean };
// Per-tier auto-approve configuration. Stored as the tri-state `AutoApproveMode`
// ('off' | 'workspace' | 'all'). For backward compatibility with settings files written before
// the tri-state was introduced, a legacy `boolean` value is also accepted at read time and
// normalized via `normalizeAutoApproveMode` in `toolsServiceTypes` (true → 'workspace',
// false → 'off'). All code reading this field should go through `normalizeAutoApproveMode`.
autoApprove: { [approvalType in ToolApprovalType]?: AutoApproveMode | boolean };
showInlineSuggestions: boolean;
includeToolLintErrors: boolean;
isOnboardingComplete: boolean;