mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-29 12:11:09 +00:00
Merge branch 'main' into refactor/task-to-agent-tool
This commit is contained in:
commit
9d6aca8efe
160 changed files with 24692 additions and 1942 deletions
|
|
@ -18,7 +18,8 @@ import { SettingsDialog } from './SettingsDialog.js';
|
|||
import { QwenOAuthProgress } from './QwenOAuthProgress.js';
|
||||
import { AuthDialog } from '../auth/AuthDialog.js';
|
||||
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
|
||||
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
|
||||
import { TrustDialog } from './TrustDialog.js';
|
||||
import { PermissionsDialog } from './PermissionsDialog.js';
|
||||
import { ModelDialog } from './ModelDialog.js';
|
||||
import { ArenaStartDialog } from './arena/ArenaStartDialog.js';
|
||||
import { ArenaSelectDialog } from './arena/ArenaSelectDialog.js';
|
||||
|
|
@ -314,15 +315,16 @@ export const DialogManager = ({
|
|||
);
|
||||
}
|
||||
}
|
||||
if (uiState.isPermissionsDialogOpen) {
|
||||
if (uiState.isTrustDialogOpen) {
|
||||
return (
|
||||
<PermissionsModifyTrustDialog
|
||||
onExit={uiActions.closePermissionsDialog}
|
||||
addItem={addItem}
|
||||
/>
|
||||
<TrustDialog onExit={uiActions.closeTrustDialog} addItem={addItem} />
|
||||
);
|
||||
}
|
||||
|
||||
if (uiState.isPermissionsDialogOpen) {
|
||||
return <PermissionsDialog onExit={uiActions.closePermissionsDialog} />;
|
||||
}
|
||||
|
||||
if (uiState.isSubagentCreateDialogOpen) {
|
||||
return (
|
||||
<AgentCreationWizard
|
||||
|
|
|
|||
986
packages/cli/src/ui/components/PermissionsDialog.tsx
Normal file
986
packages/cli/src/ui/components/PermissionsDialog.tsx
Normal file
|
|
@ -0,0 +1,986 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as nodePath from 'node:path';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import { TextInput } from './shared/TextInput.js';
|
||||
import { Colors } from '../colors.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import type {
|
||||
PermissionManager,
|
||||
RuleWithSource,
|
||||
RuleType,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { isPathWithinRoot } from '@qwen-code/qwen-code-core';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type TabId = 'allow' | 'ask' | 'deny' | 'workspace';
|
||||
|
||||
interface Tab {
|
||||
id: TabId;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** Internal views for the dialog state machine. */
|
||||
type DialogView =
|
||||
| 'rule-list' // main rule list view
|
||||
| 'add-rule-input' // text input for new rule
|
||||
| 'add-rule-scope' // scope selector after entering a rule
|
||||
| 'delete-confirm' // confirm rule deletion
|
||||
| 'ws-dir-list' // workspace directory list
|
||||
| 'ws-add-dir-input' // text input for adding a directory
|
||||
| 'ws-remove-confirm'; // confirm directory removal
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scope items (matches Claude Code screenshot layout)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PermScopeItem {
|
||||
label: string;
|
||||
description: string;
|
||||
value: SettingScope;
|
||||
key: string;
|
||||
}
|
||||
|
||||
function getPermScopeItems(): PermScopeItem[] {
|
||||
return [
|
||||
{
|
||||
label: t('Project settings'),
|
||||
description: t('Checked in at .qwen/settings.json'),
|
||||
value: SettingScope.Workspace,
|
||||
key: 'project',
|
||||
},
|
||||
{
|
||||
label: t('User settings'),
|
||||
description: t('Saved in at ~/.qwen/settings.json'),
|
||||
value: SettingScope.User,
|
||||
key: 'user',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getTabs(): Tab[] {
|
||||
return [
|
||||
{
|
||||
id: 'allow',
|
||||
label: t('Allow'),
|
||||
description: t("Qwen Code won't ask before using allowed tools."),
|
||||
},
|
||||
{
|
||||
id: 'ask',
|
||||
label: t('Ask'),
|
||||
description: t('Qwen Code will ask before using these tools.'),
|
||||
},
|
||||
{
|
||||
id: 'deny',
|
||||
label: t('Deny'),
|
||||
description: t('Qwen Code is not allowed to use denied tools.'),
|
||||
},
|
||||
{
|
||||
id: 'workspace',
|
||||
label: t('Workspace'),
|
||||
description: t('Manage trusted directories for this workspace.'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function describeRule(raw: string): string {
|
||||
const match = raw.match(/^([^(]+?)(?:\((.+)\))?$/);
|
||||
if (!match) return raw;
|
||||
const toolName = match[1]!.trim();
|
||||
const specifier = match[2]?.trim();
|
||||
if (!specifier) {
|
||||
return t('Any use of the {{tool}} tool', { tool: toolName });
|
||||
}
|
||||
return t("{{tool}} commands matching '{{pattern}}'", {
|
||||
tool: toolName,
|
||||
pattern: specifier,
|
||||
});
|
||||
}
|
||||
|
||||
function scopeLabel(scope: string): string {
|
||||
switch (scope) {
|
||||
case 'user':
|
||||
return t('From user settings');
|
||||
case 'workspace':
|
||||
return t('From project settings');
|
||||
case 'session':
|
||||
return t('From session');
|
||||
default:
|
||||
return scope;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component props
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PermissionsDialogProps {
|
||||
onExit: () => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function PermissionsDialog({
|
||||
onExit,
|
||||
}: PermissionsDialogProps): React.JSX.Element {
|
||||
const config = useConfig();
|
||||
const settings = useSettings();
|
||||
const pm = config.getPermissionManager?.() as PermissionManager | null;
|
||||
|
||||
// --- Tab state ---
|
||||
const tabs = useMemo(() => getTabs(), []);
|
||||
const [activeTabIndex, setActiveTabIndex] = useState(0);
|
||||
const activeTab = tabs[activeTabIndex]!;
|
||||
|
||||
// --- Rule list state ---
|
||||
const [allRules, setAllRules] = useState<RuleWithSource[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isSearchActive, setIsSearchActive] = useState(false);
|
||||
|
||||
// --- Dialog view state machine ---
|
||||
const [view, setView] = useState<DialogView>('rule-list');
|
||||
const [newRuleInput, setNewRuleInput] = useState('');
|
||||
const [pendingRuleText, setPendingRuleText] = useState('');
|
||||
const [deleteTarget, setDeleteTarget] = useState<RuleWithSource | null>(null);
|
||||
|
||||
// --- Workspace directory state ---
|
||||
const workspaceContext = config.getWorkspaceContext();
|
||||
const [newDirInput, setNewDirInput] = useState('');
|
||||
const [dirInputError, setDirInputError] = useState('');
|
||||
const [dirInputRemountKey, setDirInputRemountKey] = useState(0);
|
||||
const [completionIndex, setCompletionIndex] = useState(0);
|
||||
const [removeDirTarget, setRemoveDirTarget] = useState<string | null>(null);
|
||||
const [dirRefreshKey, setDirRefreshKey] = useState(0);
|
||||
|
||||
// Refresh rules from PermissionManager
|
||||
const refreshRules = useCallback(() => {
|
||||
if (pm) {
|
||||
setAllRules(pm.listRules());
|
||||
}
|
||||
}, [pm]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshRules();
|
||||
}, [refreshRules]);
|
||||
|
||||
// --- Workspace directory helpers ---
|
||||
const directories = useMemo(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
dirRefreshKey; // dependency to trigger re-computation
|
||||
return workspaceContext.getDirectories();
|
||||
}, [workspaceContext, dirRefreshKey]);
|
||||
|
||||
const initialDirs = useMemo(
|
||||
() => new Set(workspaceContext.getInitialDirectories()),
|
||||
[workspaceContext],
|
||||
);
|
||||
|
||||
// Filesystem completions based on current input
|
||||
const dirCompletions = useMemo(() => {
|
||||
const trimmed = newDirInput.trim();
|
||||
if (!trimmed) return [];
|
||||
const expanded = trimmed.startsWith('~')
|
||||
? trimmed.replace(/^~/, os.homedir())
|
||||
: trimmed;
|
||||
const endsWithSep =
|
||||
expanded.endsWith('/') || expanded.endsWith(nodePath.sep);
|
||||
const searchDir = endsWithSep ? expanded : nodePath.dirname(expanded);
|
||||
const prefix = endsWithSep ? '' : nodePath.basename(expanded);
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(searchDir, { withFileTypes: true })
|
||||
.filter(
|
||||
(e) =>
|
||||
e.isDirectory() &&
|
||||
e.name.startsWith(prefix) &&
|
||||
!e.name.startsWith('.'),
|
||||
)
|
||||
.map((e) => nodePath.join(searchDir, e.name))
|
||||
.slice(0, 6);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, [newDirInput]);
|
||||
|
||||
const handleDirInputChange = useCallback(
|
||||
(text: string) => {
|
||||
setNewDirInput(text);
|
||||
if (dirInputError) setDirInputError('');
|
||||
},
|
||||
[dirInputError],
|
||||
);
|
||||
|
||||
// Reset selection to first item whenever the completions list changes
|
||||
useEffect(() => {
|
||||
setCompletionIndex(0);
|
||||
}, [dirCompletions]);
|
||||
|
||||
const handleDirTabComplete = useCallback(() => {
|
||||
const selected = dirCompletions[completionIndex] ?? dirCompletions[0];
|
||||
if (selected) {
|
||||
setNewDirInput(selected + '/');
|
||||
setDirInputRemountKey((k) => k + 1);
|
||||
}
|
||||
}, [dirCompletions, completionIndex]);
|
||||
|
||||
const handleDirCompletionUp = useCallback(() => {
|
||||
if (dirCompletions.length === 0) return;
|
||||
setCompletionIndex(
|
||||
(prev) => (prev - 1 + dirCompletions.length) % dirCompletions.length,
|
||||
);
|
||||
}, [dirCompletions.length]);
|
||||
|
||||
const handleDirCompletionDown = useCallback(() => {
|
||||
if (dirCompletions.length === 0) return;
|
||||
setCompletionIndex((prev) => (prev + 1) % dirCompletions.length);
|
||||
}, [dirCompletions.length]);
|
||||
|
||||
const dirListItems = useMemo(() => {
|
||||
const items: Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
key: string;
|
||||
}> = [];
|
||||
// 'Add directory…' always FIRST
|
||||
items.push({
|
||||
label: t('Add directory…'),
|
||||
value: '__add_dir__',
|
||||
key: '__add_dir__',
|
||||
});
|
||||
// Only show non-initial (runtime-added) directories in the selectable list
|
||||
for (const dir of directories) {
|
||||
if (!initialDirs.has(dir)) {
|
||||
items.push({
|
||||
label: dir,
|
||||
value: dir,
|
||||
key: `dir-${dir}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}, [directories, initialDirs]);
|
||||
|
||||
const handleDirListSelect = useCallback(
|
||||
(value: string) => {
|
||||
if (value === '__add_dir__') {
|
||||
setNewDirInput('');
|
||||
setView('ws-add-dir-input');
|
||||
return;
|
||||
}
|
||||
// Selecting a directory → offer to remove if not initial
|
||||
if (!initialDirs.has(value)) {
|
||||
setRemoveDirTarget(value);
|
||||
setView('ws-remove-confirm');
|
||||
}
|
||||
},
|
||||
[initialDirs],
|
||||
);
|
||||
|
||||
const handleAddDirSubmit = useCallback(() => {
|
||||
const trimmed = newDirInput.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
const expanded = trimmed.startsWith('~')
|
||||
? trimmed.replace(/^~/, os.homedir())
|
||||
: trimmed;
|
||||
const absoluteExpanded = nodePath.isAbsolute(expanded)
|
||||
? expanded
|
||||
: nodePath.resolve(expanded);
|
||||
|
||||
// Existence & type checks
|
||||
if (!fs.existsSync(absoluteExpanded)) {
|
||||
setDirInputError(t('Directory does not exist.'));
|
||||
return;
|
||||
}
|
||||
if (!fs.statSync(absoluteExpanded).isDirectory()) {
|
||||
setDirInputError(t('Path is not a directory.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve real path to match what workspaceContext stores
|
||||
let resolved: string;
|
||||
try {
|
||||
resolved = fs.realpathSync(absoluteExpanded);
|
||||
} catch {
|
||||
resolved = absoluteExpanded;
|
||||
}
|
||||
|
||||
// Validate: exact duplicate
|
||||
if ((directories as string[]).includes(resolved)) {
|
||||
setDirInputError(t('This directory is already in the workspace.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate: is a subdirectory of an existing workspace directory
|
||||
for (const existingDir of directories) {
|
||||
if (isPathWithinRoot(resolved, existingDir)) {
|
||||
setDirInputError(
|
||||
t('Already covered by existing directory: {{dir}}', {
|
||||
dir: existingDir,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setDirInputError('');
|
||||
|
||||
// Add to workspace context (already validated)
|
||||
workspaceContext.addDirectory(resolved);
|
||||
|
||||
// Persist directly to project (Workspace) settings
|
||||
const key = 'context.includeDirectories';
|
||||
const currentDirs = (settings.merged as Record<string, unknown>)[
|
||||
'context'
|
||||
] as Record<string, string[]> | undefined;
|
||||
const existingDirs = currentDirs?.['includeDirectories'] ?? [];
|
||||
if (!existingDirs.includes(resolved)) {
|
||||
settings.setValue(SettingScope.Workspace, key, [
|
||||
...existingDirs,
|
||||
resolved,
|
||||
]);
|
||||
}
|
||||
|
||||
setDirRefreshKey((k) => k + 1);
|
||||
setView('ws-dir-list');
|
||||
setNewDirInput('');
|
||||
}, [newDirInput, directories, workspaceContext, settings]);
|
||||
|
||||
const handleRemoveDirConfirm = useCallback(() => {
|
||||
if (!removeDirTarget) return;
|
||||
|
||||
// Remove from workspace context
|
||||
workspaceContext.removeDirectory(removeDirTarget);
|
||||
|
||||
// Remove from settings (try both scopes)
|
||||
for (const scope of [SettingScope.User, SettingScope.Workspace]) {
|
||||
const scopeSettings = settings.forScope(scope).settings;
|
||||
const contextSection = (scopeSettings as Record<string, unknown>)[
|
||||
'context'
|
||||
] as Record<string, string[]> | undefined;
|
||||
const scopeDirs = contextSection?.['includeDirectories'];
|
||||
if (scopeDirs?.includes(removeDirTarget)) {
|
||||
const updated = scopeDirs.filter((d: string) => d !== removeDirTarget);
|
||||
settings.setValue(scope, 'context.includeDirectories', updated);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setDirRefreshKey((k) => k + 1);
|
||||
setRemoveDirTarget(null);
|
||||
setView('ws-dir-list');
|
||||
}, [removeDirTarget, workspaceContext, settings]);
|
||||
|
||||
// Filter rules for current tab
|
||||
const currentTabRules = useMemo(() => {
|
||||
if (activeTab.id === 'workspace') return [];
|
||||
return allRules.filter((r) => r.type === activeTab.id);
|
||||
}, [allRules, activeTab.id]);
|
||||
|
||||
// Search-filtered rules
|
||||
const filteredRules = useMemo(() => {
|
||||
if (!searchQuery.trim()) return currentTabRules;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return currentTabRules.filter(
|
||||
(r) =>
|
||||
r.rule.raw.toLowerCase().includes(q) ||
|
||||
r.rule.toolName.toLowerCase().includes(q),
|
||||
);
|
||||
}, [currentTabRules, searchQuery]);
|
||||
|
||||
// Build radio items: "Add a new rule..." + filtered rules
|
||||
const listItems = useMemo(() => {
|
||||
const items: Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
key: string;
|
||||
}> = [
|
||||
{
|
||||
label: t('Add a new rule…'),
|
||||
value: '__add__',
|
||||
key: '__add__',
|
||||
},
|
||||
];
|
||||
for (const r of filteredRules) {
|
||||
items.push({
|
||||
label: `${r.rule.raw}`,
|
||||
value: r.rule.raw,
|
||||
key: `${r.type}-${r.scope}-${r.rule.raw}`,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}, [filteredRules]);
|
||||
|
||||
// --- Action handlers ---
|
||||
|
||||
const handleTabCycle = useCallback(
|
||||
(direction: 1 | -1) => {
|
||||
const newIndex = (activeTabIndex + direction + tabs.length) % tabs.length;
|
||||
setActiveTabIndex(newIndex);
|
||||
setSearchQuery('');
|
||||
setIsSearchActive(false);
|
||||
setDirInputError('');
|
||||
// Set the appropriate default view for each tab
|
||||
const newTab = tabs[newIndex]!;
|
||||
setView(newTab.id === 'workspace' ? 'ws-dir-list' : 'rule-list');
|
||||
},
|
||||
[activeTabIndex, tabs],
|
||||
);
|
||||
|
||||
const handleListSelect = useCallback(
|
||||
(value: string) => {
|
||||
if (value === '__add__') {
|
||||
setNewRuleInput('');
|
||||
setView('add-rule-input');
|
||||
return;
|
||||
}
|
||||
// Selecting an existing rule → offer to delete
|
||||
const found = filteredRules.find((r) => r.rule.raw === value);
|
||||
if (found) {
|
||||
setDeleteTarget(found);
|
||||
setView('delete-confirm');
|
||||
}
|
||||
},
|
||||
[filteredRules],
|
||||
);
|
||||
|
||||
const handleAddRuleSubmit = useCallback(() => {
|
||||
const trimmed = newRuleInput.trim();
|
||||
if (!trimmed) return;
|
||||
setPendingRuleText(trimmed);
|
||||
setView('add-rule-scope');
|
||||
}, [newRuleInput]);
|
||||
|
||||
const handleScopeSelect = useCallback(
|
||||
(scope: SettingScope) => {
|
||||
if (!pm || activeTab.id === 'workspace') return;
|
||||
const ruleType = activeTab.id as RuleType;
|
||||
|
||||
// Add to PermissionManager in-memory
|
||||
pm.addPersistentRule(pendingRuleText, ruleType);
|
||||
|
||||
// Persist to settings file (with dedup)
|
||||
const key = `permissions.${ruleType}`;
|
||||
const perms = (settings.merged as Record<string, unknown>)[
|
||||
'permissions'
|
||||
] as Record<string, string[]> | undefined;
|
||||
const currentRules = perms?.[ruleType] ?? [];
|
||||
if (!currentRules.includes(pendingRuleText)) {
|
||||
settings.setValue(scope, key, [...currentRules, pendingRuleText]);
|
||||
}
|
||||
|
||||
// Refresh and go back
|
||||
refreshRules();
|
||||
setView('rule-list');
|
||||
setPendingRuleText('');
|
||||
},
|
||||
[pm, activeTab.id, pendingRuleText, settings, refreshRules],
|
||||
);
|
||||
|
||||
const handleDeleteConfirm = useCallback(() => {
|
||||
if (!pm || !deleteTarget) return;
|
||||
const ruleType = deleteTarget.type;
|
||||
|
||||
// Remove from PermissionManager in-memory
|
||||
pm.removePersistentRule(deleteTarget.rule.raw, ruleType);
|
||||
|
||||
// Persist removal — find and remove from settings
|
||||
// We try both User and Workspace scopes
|
||||
for (const scope of [SettingScope.User, SettingScope.Workspace]) {
|
||||
const scopeSettings = settings.forScope(scope).settings;
|
||||
const perms = (scopeSettings as Record<string, unknown>)[
|
||||
'permissions'
|
||||
] as Record<string, string[]> | undefined;
|
||||
const scopeRules = perms?.[ruleType];
|
||||
if (scopeRules?.includes(deleteTarget.rule.raw)) {
|
||||
const updated = scopeRules.filter(
|
||||
(r: string) => r !== deleteTarget.rule.raw,
|
||||
);
|
||||
settings.setValue(scope, `permissions.${ruleType}`, updated);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
refreshRules();
|
||||
setDeleteTarget(null);
|
||||
setView('rule-list');
|
||||
}, [pm, deleteTarget, settings, refreshRules]);
|
||||
|
||||
// --- Keypress handling ---
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (view === 'rule-list') {
|
||||
if (key.name === 'escape') {
|
||||
if (isSearchActive && searchQuery) {
|
||||
setSearchQuery('');
|
||||
setIsSearchActive(false);
|
||||
} else {
|
||||
onExit();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key.name === 'tab') {
|
||||
handleTabCycle(1);
|
||||
return;
|
||||
}
|
||||
if (key.name === 'right' || key.name === 'left') {
|
||||
handleTabCycle(key.name === 'right' ? 1 : -1);
|
||||
return;
|
||||
}
|
||||
// Search input: backspace
|
||||
if (key.name === 'backspace' || key.name === 'delete') {
|
||||
if (searchQuery.length > 0) {
|
||||
setSearchQuery((prev) => prev.slice(0, -1));
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Search input: printable characters
|
||||
if (
|
||||
key.sequence &&
|
||||
!key.ctrl &&
|
||||
!key.meta &&
|
||||
key.sequence.length === 1 &&
|
||||
key.sequence >= ' '
|
||||
) {
|
||||
setSearchQuery((prev) => prev + key.sequence);
|
||||
setIsSearchActive(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (view === 'add-rule-input') {
|
||||
if (key.name === 'escape') {
|
||||
setView('rule-list');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (view === 'add-rule-scope') {
|
||||
if (key.name === 'escape') {
|
||||
setView('add-rule-input');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (view === 'delete-confirm') {
|
||||
if (key.name === 'escape') {
|
||||
setDeleteTarget(null);
|
||||
setView('rule-list');
|
||||
return;
|
||||
}
|
||||
if (key.name === 'return') {
|
||||
handleDeleteConfirm();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Workspace tab views
|
||||
if (view === 'ws-dir-list') {
|
||||
if (key.name === 'escape') {
|
||||
onExit();
|
||||
return;
|
||||
}
|
||||
if (key.name === 'tab') {
|
||||
handleTabCycle(1);
|
||||
return;
|
||||
}
|
||||
if (key.name === 'right' || key.name === 'left') {
|
||||
handleTabCycle(key.name === 'right' ? 1 : -1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (view === 'ws-add-dir-input') {
|
||||
if (key.name === 'escape') {
|
||||
setDirInputError('');
|
||||
setView('ws-dir-list');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (view === 'ws-remove-confirm') {
|
||||
if (key.name === 'escape') {
|
||||
setRemoveDirTarget(null);
|
||||
setView('ws-dir-list');
|
||||
return;
|
||||
}
|
||||
if (key.name === 'return') {
|
||||
handleRemoveDirConfirm();
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
// --- Workspace tab: add directory input ---
|
||||
if (activeTab.id === 'workspace' && view === 'ws-add-dir-input') {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={theme.text.accent}>
|
||||
{t('Add directory to workspace')}
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
<Text color={theme.text.secondary} wrap="wrap">
|
||||
{t(
|
||||
'Qwen Code will be able to read files in this directory and make edits when auto-accept edits is on.',
|
||||
)}
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
<Text>{t('Enter the path to the directory:')}</Text>
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
marginTop={1}
|
||||
>
|
||||
<TextInput
|
||||
key={dirInputRemountKey}
|
||||
value={newDirInput}
|
||||
onChange={handleDirInputChange}
|
||||
onSubmit={handleAddDirSubmit}
|
||||
onTab={dirCompletions.length > 0 ? handleDirTabComplete : undefined}
|
||||
onUp={dirCompletions.length > 0 ? handleDirCompletionUp : undefined}
|
||||
onDown={
|
||||
dirCompletions.length > 0 ? handleDirCompletionDown : undefined
|
||||
}
|
||||
placeholder={t('Enter directory path…')}
|
||||
isActive={true}
|
||||
validationErrors={dirInputError ? [dirInputError] : []}
|
||||
/>
|
||||
</Box>
|
||||
{/* Filesystem completions: ↑/↓ to navigate, Tab to apply */}
|
||||
{dirCompletions.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={1} paddingLeft={2}>
|
||||
{dirCompletions.map((completion, idx) => {
|
||||
const name = nodePath.basename(completion);
|
||||
const isSelected = idx === completionIndex;
|
||||
return (
|
||||
<Box key={completion}>
|
||||
<Text
|
||||
bold={isSelected}
|
||||
color={
|
||||
isSelected ? theme.text.primary : theme.text.secondary
|
||||
}
|
||||
>
|
||||
{`${name}/`}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>{` directory`}</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Tab to complete · Enter to add · Esc to cancel')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Workspace tab: remove directory confirmation ---
|
||||
if (
|
||||
activeTab.id === 'workspace' &&
|
||||
view === 'ws-remove-confirm' &&
|
||||
removeDirTarget
|
||||
) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
>
|
||||
<Text bold>{t('Remove directory?')}</Text>
|
||||
<Box height={1} />
|
||||
<Box marginLeft={2} flexDirection="column">
|
||||
<Text bold>{removeDirTarget}</Text>
|
||||
</Box>
|
||||
<Box height={1} />
|
||||
<Text>
|
||||
{t(
|
||||
'Are you sure you want to remove this directory from the workspace?',
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1} marginLeft={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Enter to confirm · Esc to cancel')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Workspace tab: directory list (default) ---
|
||||
if (activeTab.id === 'workspace') {
|
||||
const initialDirArray = Array.from(initialDirs);
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<TabBar tabs={tabs} activeIndex={activeTabIndex} />
|
||||
<Text color={theme.text.secondary} wrap="wrap">
|
||||
{t(
|
||||
'Qwen Code can read files in the workspace, and make edits when auto-accept edits is on.',
|
||||
)}
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
{/* Initial (non-removable) dirs: shown inline with dash, same visual level as list */}
|
||||
{initialDirArray.map((dir, idx) => (
|
||||
<Box key={dir} marginLeft={2}>
|
||||
<Text color={theme.text.secondary}>{'- '}</Text>
|
||||
<Text>{dir}</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{idx === 0
|
||||
? t(' (Original working directory)')
|
||||
: t(' (from settings)')}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
{/* Selectable list: runtime-added dirs + 'Add directory…' at end */}
|
||||
<RadioButtonSelect
|
||||
items={dirListItems}
|
||||
onSelect={handleDirListSelect}
|
||||
isFocused={view === 'ws-dir-list'}
|
||||
showNumbers={true}
|
||||
showScrollArrows={false}
|
||||
maxItemsToShow={15}
|
||||
/>
|
||||
<FooterHint view={view} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Render views ---
|
||||
|
||||
if (view === 'add-rule-input') {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
>
|
||||
<Text bold>
|
||||
{t('Add {{type}} permission rule', { type: activeTab.id })}
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
<Text wrap="wrap">
|
||||
{t(
|
||||
'Permission rules are a tool name, optionally followed by a specifier in parentheses.',
|
||||
)}
|
||||
</Text>
|
||||
<Text>
|
||||
{t('e.g.,')} <Text bold>WebFetch</Text> {t('or')}{' '}
|
||||
<Text bold>Bash(ls:*)</Text>
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
>
|
||||
<TextInput
|
||||
value={newRuleInput}
|
||||
onChange={setNewRuleInput}
|
||||
onSubmit={handleAddRuleSubmit}
|
||||
placeholder={t('Enter permission rule…')}
|
||||
isActive={true}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box marginTop={1} marginLeft={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Enter to submit · Esc to cancel')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (view === 'add-rule-scope') {
|
||||
const scopeItems = getPermScopeItems();
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
>
|
||||
<Text bold>
|
||||
{t('Add {{type}} permission rule', { type: activeTab.id })}
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
<Box marginLeft={2} flexDirection="column">
|
||||
<Text bold>{pendingRuleText}</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{describeRule(pendingRuleText)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box height={1} />
|
||||
<Text>{t('Where should this rule be saved?')}</Text>
|
||||
<RadioButtonSelect
|
||||
items={scopeItems.map((s) => ({
|
||||
label: `${s.label} ${s.description}`,
|
||||
value: s.value,
|
||||
key: s.key,
|
||||
}))}
|
||||
onSelect={handleScopeSelect}
|
||||
isFocused={true}
|
||||
showNumbers={true}
|
||||
/>
|
||||
</Box>
|
||||
<Box marginTop={1} marginLeft={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Enter to confirm · Esc to cancel')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (view === 'delete-confirm' && deleteTarget) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
>
|
||||
<Text bold>
|
||||
{t('Delete {{type}} rule?', { type: deleteTarget.type })}
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
<Box marginLeft={2} flexDirection="column">
|
||||
<Text bold>{deleteTarget.rule.raw}</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{describeRule(deleteTarget.rule.raw)}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{scopeLabel(deleteTarget.scope)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box height={1} />
|
||||
<Text>
|
||||
{t('Are you sure you want to delete this permission rule?')}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1} marginLeft={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Enter to confirm · Esc to cancel')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Default: rule-list view ---
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<TabBar tabs={tabs} activeIndex={activeTabIndex} />
|
||||
<Text>{activeTab.description}</Text>
|
||||
{/* Search box */}
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
width={60}
|
||||
>
|
||||
<Text color={theme.text.accent}>{'> '}</Text>
|
||||
{searchQuery ? (
|
||||
<Text>{searchQuery}</Text>
|
||||
) : (
|
||||
<Text color={Colors.Gray}>{t('Search…')}</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box height={1} />
|
||||
{/* Rule list */}
|
||||
<RadioButtonSelect
|
||||
items={listItems}
|
||||
onSelect={handleListSelect}
|
||||
isFocused={view === 'rule-list'}
|
||||
showNumbers={true}
|
||||
showScrollArrows={false}
|
||||
maxItemsToShow={15}
|
||||
/>
|
||||
<FooterHint view={view} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TabBar({
|
||||
tabs,
|
||||
activeIndex,
|
||||
}: {
|
||||
tabs: Tab[];
|
||||
activeIndex: number;
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<Box marginBottom={1}>
|
||||
<Text color={theme.text.accent} bold>
|
||||
{t('Permissions:')}{' '}
|
||||
</Text>
|
||||
{tabs.map((tab, i) => (
|
||||
<Box key={tab.id} marginRight={2}>
|
||||
{i === activeIndex ? (
|
||||
<Text
|
||||
bold
|
||||
backgroundColor={theme.text.accent}
|
||||
color={theme.background.primary}
|
||||
>
|
||||
{` ${tab.label} `}
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={theme.text.secondary}>{` ${tab.label} `}</Text>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
<Text color={theme.text.secondary}>{t('(←/→ or tab to cycle)')}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterHint({ view }: { view: DialogView }): React.JSX.Element {
|
||||
if (view !== 'rule-list' && view !== 'ws-dir-list') return <></>;
|
||||
return (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t(
|
||||
'Press ↑↓ to navigate · Enter to select · Type to search · Esc to cancel',
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -33,13 +33,13 @@ describe('ShellConfirmationDialog', () => {
|
|||
expect(select).toContain('Yes, allow once');
|
||||
});
|
||||
|
||||
it('calls onConfirm with ProceedAlways when "Yes, allow always for this session" is selected', () => {
|
||||
it('calls onConfirm with ProceedAlwaysProject when "Always allow in this project" is selected', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<ShellConfirmationDialog request={request} />,
|
||||
);
|
||||
const select = lastFrame()!.toString();
|
||||
// Simulate selecting the second option
|
||||
expect(select).toContain('Yes, allow always for this session');
|
||||
expect(select).toContain('Always allow in this project');
|
||||
});
|
||||
|
||||
it('calls onConfirm with Cancel when "No (esc)" is selected', () => {
|
||||
|
|
|
|||
|
|
@ -57,9 +57,14 @@ export const ShellConfirmationDialog: React.FC<
|
|||
key: 'Yes, allow once',
|
||||
},
|
||||
{
|
||||
label: t('Yes, allow always for this session'),
|
||||
value: ToolConfirmationOutcome.ProceedAlways,
|
||||
key: 'Yes, allow always for this session',
|
||||
label: t('Always allow in this project'),
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysProject,
|
||||
key: 'Always allow in this project',
|
||||
},
|
||||
{
|
||||
label: t('Always allow for this user'),
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysUser,
|
||||
key: 'Always allow for this user',
|
||||
},
|
||||
{
|
||||
label: t('No (esc)'),
|
||||
|
|
|
|||
|
|
@ -9,13 +9,13 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { Mock } from 'vitest';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
|
||||
import { TrustDialog } from './TrustDialog.js';
|
||||
import { TrustLevel } from '../../config/trustedFolders.js';
|
||||
import { waitFor, act } from '@testing-library/react';
|
||||
import * as processUtils from '../../utils/processUtils.js';
|
||||
import { usePermissionsModifyTrust } from '../hooks/usePermissionsModifyTrust.js';
|
||||
import { useTrustModify } from '../hooks/useTrustModify.js';
|
||||
|
||||
// Hoist mocks for dependencies of the usePermissionsModifyTrust hook
|
||||
// Hoist mocks for dependencies of the useTrustModify hook
|
||||
const mockedCwd = vi.hoisted(() => vi.fn());
|
||||
const mockedLoadTrustedFolders = vi.hoisted(() => vi.fn());
|
||||
const mockedIsWorkspaceTrusted = vi.hoisted(() => vi.fn());
|
||||
|
|
@ -39,16 +39,16 @@ vi.mock('../../config/trustedFolders.js', () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/usePermissionsModifyTrust.js');
|
||||
vi.mock('../hooks/useTrustModify.js');
|
||||
|
||||
describe('PermissionsModifyTrustDialog', () => {
|
||||
describe('TrustDialog', () => {
|
||||
let mockUpdateTrustLevel: Mock;
|
||||
let mockCommitTrustLevelChange: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
mockUpdateTrustLevel = vi.fn();
|
||||
mockCommitTrustLevelChange = vi.fn();
|
||||
vi.mocked(usePermissionsModifyTrust).mockReturnValue({
|
||||
vi.mocked(useTrustModify).mockReturnValue({
|
||||
cwd: '/test/dir',
|
||||
currentTrustLevel: TrustLevel.DO_NOT_TRUST,
|
||||
isInheritedTrustFromParent: false,
|
||||
|
|
@ -66,7 +66,7 @@ describe('PermissionsModifyTrustDialog', () => {
|
|||
|
||||
it('should render the main dialog with current trust level', async () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<PermissionsModifyTrustDialog onExit={vi.fn()} addItem={vi.fn()} />,
|
||||
<TrustDialog onExit={vi.fn()} addItem={vi.fn()} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -77,7 +77,7 @@ describe('PermissionsModifyTrustDialog', () => {
|
|||
});
|
||||
|
||||
it('should display the inherited trust note from parent', async () => {
|
||||
vi.mocked(usePermissionsModifyTrust).mockReturnValue({
|
||||
vi.mocked(useTrustModify).mockReturnValue({
|
||||
cwd: '/test/dir',
|
||||
currentTrustLevel: TrustLevel.DO_NOT_TRUST,
|
||||
isInheritedTrustFromParent: true,
|
||||
|
|
@ -88,7 +88,7 @@ describe('PermissionsModifyTrustDialog', () => {
|
|||
isFolderTrustEnabled: true,
|
||||
});
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<PermissionsModifyTrustDialog onExit={vi.fn()} addItem={vi.fn()} />,
|
||||
<TrustDialog onExit={vi.fn()} addItem={vi.fn()} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -99,7 +99,7 @@ describe('PermissionsModifyTrustDialog', () => {
|
|||
});
|
||||
|
||||
it('should display the inherited trust note from IDE', async () => {
|
||||
vi.mocked(usePermissionsModifyTrust).mockReturnValue({
|
||||
vi.mocked(useTrustModify).mockReturnValue({
|
||||
cwd: '/test/dir',
|
||||
currentTrustLevel: TrustLevel.DO_NOT_TRUST,
|
||||
isInheritedTrustFromParent: false,
|
||||
|
|
@ -110,7 +110,7 @@ describe('PermissionsModifyTrustDialog', () => {
|
|||
isFolderTrustEnabled: true,
|
||||
});
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<PermissionsModifyTrustDialog onExit={vi.fn()} addItem={vi.fn()} />,
|
||||
<TrustDialog onExit={vi.fn()} addItem={vi.fn()} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -123,7 +123,7 @@ describe('PermissionsModifyTrustDialog', () => {
|
|||
it('should call onExit when escape is pressed', async () => {
|
||||
const onExit = vi.fn();
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<PermissionsModifyTrustDialog onExit={onExit} addItem={vi.fn()} />,
|
||||
<TrustDialog onExit={onExit} addItem={vi.fn()} />,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(lastFrame()).not.toContain('Loading...'));
|
||||
|
|
@ -141,7 +141,7 @@ describe('PermissionsModifyTrustDialog', () => {
|
|||
const mockRelaunchApp = vi
|
||||
.spyOn(processUtils, 'relaunchApp')
|
||||
.mockResolvedValue(undefined);
|
||||
vi.mocked(usePermissionsModifyTrust).mockReturnValue({
|
||||
vi.mocked(useTrustModify).mockReturnValue({
|
||||
cwd: '/test/dir',
|
||||
currentTrustLevel: TrustLevel.DO_NOT_TRUST,
|
||||
isInheritedTrustFromParent: false,
|
||||
|
|
@ -154,7 +154,7 @@ describe('PermissionsModifyTrustDialog', () => {
|
|||
|
||||
const onExit = vi.fn();
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<PermissionsModifyTrustDialog onExit={onExit} addItem={vi.fn()} />,
|
||||
<TrustDialog onExit={onExit} addItem={vi.fn()} />,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(lastFrame()).not.toContain('Loading...'));
|
||||
|
|
@ -171,7 +171,7 @@ describe('PermissionsModifyTrustDialog', () => {
|
|||
});
|
||||
|
||||
it('should not commit when escape is pressed during restart prompt', async () => {
|
||||
vi.mocked(usePermissionsModifyTrust).mockReturnValue({
|
||||
vi.mocked(useTrustModify).mockReturnValue({
|
||||
cwd: '/test/dir',
|
||||
currentTrustLevel: TrustLevel.DO_NOT_TRUST,
|
||||
isInheritedTrustFromParent: false,
|
||||
|
|
@ -184,7 +184,7 @@ describe('PermissionsModifyTrustDialog', () => {
|
|||
|
||||
const onExit = vi.fn();
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<PermissionsModifyTrustDialog onExit={onExit} addItem={vi.fn()} />,
|
||||
<TrustDialog onExit={onExit} addItem={vi.fn()} />,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(lastFrame()).not.toContain('Loading...'));
|
||||
|
|
@ -8,13 +8,13 @@ import { Box, Text } from 'ink';
|
|||
import type React from 'react';
|
||||
import { TrustLevel } from '../../config/trustedFolders.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { usePermissionsModifyTrust } from '../hooks/usePermissionsModifyTrust.js';
|
||||
import { useTrustModify } from '../hooks/useTrustModify.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
import { relaunchApp } from '../../utils/processUtils.js';
|
||||
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
|
||||
|
||||
interface PermissionsModifyTrustDialogProps {
|
||||
interface TrustDialogProps {
|
||||
onExit: () => void;
|
||||
addItem: UseHistoryManagerReturn['addItem'];
|
||||
}
|
||||
|
|
@ -37,10 +37,10 @@ const TRUST_LEVEL_ITEMS = [
|
|||
},
|
||||
];
|
||||
|
||||
export function PermissionsModifyTrustDialog({
|
||||
export function TrustDialog({
|
||||
onExit,
|
||||
addItem,
|
||||
}: PermissionsModifyTrustDialogProps): React.JSX.Element {
|
||||
}: TrustDialogProps): React.JSX.Element {
|
||||
const {
|
||||
cwd,
|
||||
currentTrustLevel,
|
||||
|
|
@ -49,7 +49,7 @@ export function PermissionsModifyTrustDialog({
|
|||
needsRestart,
|
||||
updateTrustLevel,
|
||||
commitTrustLevelChange,
|
||||
} = usePermissionsModifyTrust(onExit, addItem);
|
||||
} = useTrustModify(onExit, addItem);
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
|
|
@ -7,7 +7,7 @@ exports[`LoopDetectionConfirmation > renders correctly 1`] = `
|
|||
│ This can happen due to repetitive tool calls or other model behavior. Do you want to keep loop │
|
||||
│ detection enabled or disable it for this session? │
|
||||
│ │
|
||||
│ ● 1. Keep loop detection enabled (esc) │
|
||||
│ › 1. Keep loop detection enabled (esc) │
|
||||
│ 2. Disable loop detection for this session │
|
||||
│ │
|
||||
│ Note: To disable loop detection checks for all future sessions, set "model.skipLoopDetection" to │
|
||||
|
|
|
|||
|
|
@ -13,9 +13,10 @@ exports[`ShellConfirmationDialog > renders correctly 1`] = `
|
|||
│ │
|
||||
│ Do you want to proceed? │
|
||||
│ │
|
||||
│ ● 1. Yes, allow once │
|
||||
│ 2. Yes, allow always for this session │
|
||||
│ 3. No (esc) │
|
||||
│ › 1. Yes, allow once │
|
||||
│ 2. Always allow in this project │
|
||||
│ 3. Always allow for this user │
|
||||
│ 4. No (esc) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ exports[`ThemeDialog Snapshots > should render correctly in scope selector mode
|
|||
│ │
|
||||
│ > Apply To │
|
||||
│ │
|
||||
│ ● 1. User Settings │
|
||||
│ › 1. User Settings │
|
||||
│ 2. Workspace Settings │
|
||||
│ │
|
||||
│ (Use Enter to apply scope, Tab to go back) │
|
||||
|
|
@ -19,7 +19,7 @@ exports[`ThemeDialog Snapshots > should render correctly in theme selection mode
|
|||
│ > Select Theme Preview │
|
||||
│ ▲ ┌─────────────────────────────────────────────────┐ │
|
||||
│ 1. Qwen Light Light │ │ │
|
||||
│ ● 2. Qwen Dark Dark │ 1 # function │ │
|
||||
│ › 2. Qwen Dark Dark │ 1 # function │ │
|
||||
│ 3. ANSI Dark │ 2 def fibonacci(n): │ │
|
||||
│ 4. Atom One Dark │ 3 a, b = 0, 1 │ │
|
||||
│ 5. Ayu Dark │ 4 for _ in range(n): │ │
|
||||
|
|
|
|||
|
|
@ -1,33 +1,33 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`ActionSelectionStep Snapshots > should render for active extension without update 1`] = `
|
||||
"● View Details
|
||||
"› View Details
|
||||
Disable Extension
|
||||
Uninstall Extension"
|
||||
`;
|
||||
|
||||
exports[`ActionSelectionStep Snapshots > should render for disabled extension 1`] = `
|
||||
"● View Details
|
||||
"› View Details
|
||||
Enable Extension
|
||||
Uninstall Extension"
|
||||
`;
|
||||
|
||||
exports[`ActionSelectionStep Snapshots > should render for disabled extension with update 1`] = `
|
||||
"● View Details
|
||||
"› View Details
|
||||
Update Extension
|
||||
Enable Extension
|
||||
Uninstall Extension"
|
||||
`;
|
||||
|
||||
exports[`ActionSelectionStep Snapshots > should render for extension with update available 1`] = `
|
||||
"● View Details
|
||||
"› View Details
|
||||
Update Extension
|
||||
Disable Extension
|
||||
Uninstall Extension"
|
||||
`;
|
||||
|
||||
exports[`ActionSelectionStep Snapshots > should render with no extension selected 1`] = `
|
||||
"● View Details
|
||||
"› View Details
|
||||
Enable Extension
|
||||
Uninstall Extension"
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -138,17 +138,17 @@ describe('ToolConfirmationMessage', () => {
|
|||
{
|
||||
description: 'for exec confirmations',
|
||||
details: execConfirmationDetails,
|
||||
alwaysAllowText: 'Yes, allow always',
|
||||
alwaysAllowText: 'Always allow in this project',
|
||||
},
|
||||
{
|
||||
description: 'for info confirmations',
|
||||
details: infoConfirmationDetails,
|
||||
alwaysAllowText: 'Yes, allow always',
|
||||
alwaysAllowText: 'Always allow in this project',
|
||||
},
|
||||
{
|
||||
description: 'for mcp confirmations',
|
||||
details: mcpConfirmationDetails,
|
||||
alwaysAllowText: 'always allow',
|
||||
alwaysAllowText: 'Always allow in this project',
|
||||
},
|
||||
])('$description', ({ details, alwaysAllowText }) => {
|
||||
it('should show "allow always" when folder is trusted', () => {
|
||||
|
|
|
|||
|
|
@ -242,11 +242,19 @@ export const ToolConfirmationMessage: React.FC<
|
|||
value: ToolConfirmationOutcome.ProceedOnce,
|
||||
key: 'Yes, allow once',
|
||||
});
|
||||
if (isTrustedFolder) {
|
||||
if (isTrustedFolder && !confirmationDetails.hideAlwaysAllow) {
|
||||
const rulesLabel = executionProps.permissionRules?.length
|
||||
? ` [${executionProps.permissionRules.join(', ')}]`
|
||||
: '';
|
||||
options.push({
|
||||
label: t('Yes, allow always ...'),
|
||||
value: ToolConfirmationOutcome.ProceedAlways,
|
||||
key: 'Yes, allow always ...',
|
||||
label: t('Always allow in this project') + rulesLabel,
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysProject,
|
||||
key: 'Always allow in this project',
|
||||
});
|
||||
options.push({
|
||||
label: t('Always allow for this user') + rulesLabel,
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysUser,
|
||||
key: 'Always allow for this user',
|
||||
});
|
||||
}
|
||||
options.push({
|
||||
|
|
@ -315,11 +323,21 @@ export const ToolConfirmationMessage: React.FC<
|
|||
value: ToolConfirmationOutcome.ProceedOnce,
|
||||
key: 'Yes, allow once',
|
||||
});
|
||||
if (isTrustedFolder) {
|
||||
if (isTrustedFolder && !confirmationDetails.hideAlwaysAllow) {
|
||||
const rulesLabel =
|
||||
'permissionRules' in infoProps &&
|
||||
(infoProps as { permissionRules?: string[] }).permissionRules?.length
|
||||
? ` [${(infoProps as { permissionRules?: string[] }).permissionRules!.join(', ')}]`
|
||||
: '';
|
||||
options.push({
|
||||
label: t('Yes, allow always'),
|
||||
value: ToolConfirmationOutcome.ProceedAlways,
|
||||
key: 'Yes, allow always',
|
||||
label: t('Always allow in this project') + rulesLabel,
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysProject,
|
||||
key: 'Always allow in this project',
|
||||
});
|
||||
options.push({
|
||||
label: t('Always allow for this user') + rulesLabel,
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysUser,
|
||||
key: 'Always allow for this user',
|
||||
});
|
||||
}
|
||||
options.push({
|
||||
|
|
@ -382,21 +400,19 @@ export const ToolConfirmationMessage: React.FC<
|
|||
value: ToolConfirmationOutcome.ProceedOnce,
|
||||
key: 'Yes, allow once',
|
||||
});
|
||||
if (isTrustedFolder) {
|
||||
if (isTrustedFolder && !confirmationDetails.hideAlwaysAllow) {
|
||||
const rulesLabel = mcpProps.permissionRules?.length
|
||||
? ` [${mcpProps.permissionRules.join(', ')}]`
|
||||
: '';
|
||||
options.push({
|
||||
label: t('Yes, always allow tool "{{tool}}" from server "{{server}}"', {
|
||||
tool: mcpProps.toolName,
|
||||
server: mcpProps.serverName,
|
||||
}),
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysTool, // Cast until types are updated
|
||||
key: `Yes, always allow tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"`,
|
||||
label: t('Always allow in this project') + rulesLabel,
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysProject,
|
||||
key: 'Always allow in this project',
|
||||
});
|
||||
options.push({
|
||||
label: t('Yes, always allow all tools from server "{{server}}"', {
|
||||
server: mcpProps.serverName,
|
||||
}),
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysServer,
|
||||
key: `Yes, always allow all tools from server "${mcpProps.serverName}"`,
|
||||
label: t('Always allow for this user') + rulesLabel,
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysUser,
|
||||
key: 'Always allow for this user',
|
||||
});
|
||||
}
|
||||
options.push({
|
||||
|
|
|
|||
|
|
@ -93,12 +93,12 @@ describe('BaseSelectionList', () => {
|
|||
expect(mockRenderItem).toHaveBeenCalledWith(items[0], expect.any(Object));
|
||||
});
|
||||
|
||||
it('should render the selection indicator (● or space) and layout', () => {
|
||||
it('should render the selection indicator (› or space) and layout', () => {
|
||||
const { lastFrame } = renderComponent({}, 0);
|
||||
const output = lastFrame();
|
||||
|
||||
// Use regex to assert the structure: Indicator + Whitespace + Number + Label
|
||||
expect(output).toMatch(/●\s+1\.\s+Item A/);
|
||||
expect(output).toMatch(/›\s+1\.\s+Item A/);
|
||||
expect(output).toMatch(/\s+2\.\s+Item B/);
|
||||
expect(output).toMatch(/\s+3\.\s+Item C/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ export function BaseSelectionList<
|
|||
color={isSelected ? theme.status.success : theme.text.primary}
|
||||
aria-hidden
|
||||
>
|
||||
{isSelected ? '●' : ' '}
|
||||
{isSelected ? '›' : ' '}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,12 @@ export interface TextInputProps {
|
|||
value: string;
|
||||
onChange: (text: string) => void;
|
||||
onSubmit?: () => void;
|
||||
/** Called when Tab is pressed; if provided, prevents the default tab-insertion behaviour. */
|
||||
onTab?: () => void;
|
||||
/** Called when ↑ is pressed; if provided, prevents cursor-up in the buffer. */
|
||||
onUp?: () => void;
|
||||
/** Called when ↓ is pressed; if provided, prevents cursor-down in the buffer. */
|
||||
onDown?: () => void;
|
||||
placeholder?: string;
|
||||
height?: number; // lines in viewport; >1 enables multiline
|
||||
isActive?: boolean; // when false, ignore keypresses
|
||||
|
|
@ -33,6 +39,9 @@ export function TextInput({
|
|||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onTab,
|
||||
onUp,
|
||||
onDown,
|
||||
placeholder,
|
||||
height = 1,
|
||||
isActive = true,
|
||||
|
|
@ -68,6 +77,22 @@ export function TextInput({
|
|||
(key: Key) => {
|
||||
if (!buffer || !isActive) return;
|
||||
|
||||
// Tab completion: delegate to caller instead of inserting a tab character
|
||||
if (key.name === 'tab') {
|
||||
onTab?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// Arrow-key completion navigation: delegate to caller
|
||||
if (key.name === 'up' && onUp) {
|
||||
onUp();
|
||||
return;
|
||||
}
|
||||
if (key.name === 'down' && onDown) {
|
||||
onDown();
|
||||
return;
|
||||
}
|
||||
|
||||
// Submit on Enter
|
||||
if (keyMatchers[Command.SUBMIT](key) || key.name === 'return') {
|
||||
if (allowMultiline) {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ exports[`DescriptiveRadioButtonSelect > should render correctly with custom prop
|
|||
"▲
|
||||
1. Foo Title
|
||||
This is Foo.
|
||||
● 2. Bar Title
|
||||
› 2. Bar Title
|
||||
This is Bar.
|
||||
3. Baz Title
|
||||
This is Baz.
|
||||
|
|
@ -12,7 +12,7 @@ exports[`DescriptiveRadioButtonSelect > should render correctly with custom prop
|
|||
`;
|
||||
|
||||
exports[`DescriptiveRadioButtonSelect > should render correctly with default props 1`] = `
|
||||
"● Foo Title
|
||||
"› Foo Title
|
||||
This is Foo.
|
||||
Bar Title
|
||||
This is Bar.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue