Skip to content

Commit ad674a0

Browse files
committed
feat: 添加审批模式选择命令
1 parent bf5624a commit ad674a0

6 files changed

Lines changed: 428 additions & 1 deletion

File tree

src/commands.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ import {
227227
import rateLimitOptions from './commands/rate-limit-options/index.js'
228228
import statusline from './commands/statusline.js'
229229
import effort from './commands/effort/index.js'
230+
import approval from './commands/approval/index.js'
230231
import stats from './commands/stats/index.js'
231232
// insights.ts is 113KB (3200 lines, includes diffLines/html rendering). Lazy
232233
// shim defers the heavy module until /insights is actually invoked.
@@ -299,6 +300,7 @@ const COMMANDS = memoize((): Command[] => [
299300
addDir,
300301
advisor,
301302
autonomy,
303+
approval,
302304
provider,
303305
agents,
304306
branch,
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { describe, expect, test } from 'bun:test'
2+
import {
3+
formatApprovalMode,
4+
getApprovalModeDescriptor,
5+
parseApprovalModeArg,
6+
} from '../approvalModes.js'
7+
8+
describe('parseApprovalModeArg', () => {
9+
test('treats empty and status arguments as current mode requests', () => {
10+
expect(parseApprovalModeArg('')).toEqual({ type: 'current' })
11+
expect(parseApprovalModeArg('status')).toEqual({ type: 'current' })
12+
expect(parseApprovalModeArg('show')).toEqual({ type: 'current' })
13+
})
14+
15+
test('treats help aliases as help requests', () => {
16+
expect(parseApprovalModeArg('help')).toEqual({ type: 'help' })
17+
expect(parseApprovalModeArg('-h')).toEqual({ type: 'help' })
18+
expect(parseApprovalModeArg('--help')).toEqual({ type: 'help' })
19+
})
20+
21+
test('parses standard approval modes', () => {
22+
expect(parseApprovalModeArg('default')).toEqual({
23+
type: 'mode',
24+
mode: 'default',
25+
})
26+
expect(parseApprovalModeArg('accept-edits')).toEqual({
27+
type: 'mode',
28+
mode: 'acceptEdits',
29+
})
30+
expect(parseApprovalModeArg('plan')).toEqual({ type: 'mode', mode: 'plan' })
31+
expect(parseApprovalModeArg('auto')).toEqual({ type: 'mode', mode: 'auto' })
32+
expect(parseApprovalModeArg('dont-ask')).toEqual({
33+
type: 'mode',
34+
mode: 'dontAsk',
35+
})
36+
})
37+
38+
test('maps full access aliases to bypassPermissions', () => {
39+
expect(parseApprovalModeArg('full-access')).toEqual({
40+
type: 'mode',
41+
mode: 'bypassPermissions',
42+
})
43+
expect(parseApprovalModeArg('full_access')).toEqual({
44+
type: 'mode',
45+
mode: 'bypassPermissions',
46+
})
47+
expect(parseApprovalModeArg('bypass')).toEqual({
48+
type: 'mode',
49+
mode: 'bypassPermissions',
50+
})
51+
expect(parseApprovalModeArg('allow-all')).toEqual({
52+
type: 'mode',
53+
mode: 'bypassPermissions',
54+
})
55+
})
56+
57+
test('normalizes case, spaces, and underscores', () => {
58+
expect(parseApprovalModeArg(' FULL_ACCESS ')).toEqual({
59+
type: 'mode',
60+
mode: 'bypassPermissions',
61+
})
62+
expect(parseApprovalModeArg('ACCEPT EDITS')).toEqual({
63+
type: 'mode',
64+
mode: 'acceptEdits',
65+
})
66+
})
67+
68+
test('reports invalid arguments', () => {
69+
const result = parseApprovalModeArg('wat')
70+
expect(result.type).toBe('invalid')
71+
if (result.type === 'invalid') {
72+
expect(result.message).toContain('Invalid approval mode: wat')
73+
}
74+
})
75+
})
76+
77+
describe('formatApprovalMode', () => {
78+
test('uses user-facing names for regular modes', () => {
79+
expect(formatApprovalMode('default')).toBe('Default')
80+
expect(formatApprovalMode('plan')).toBe('Plan')
81+
})
82+
83+
test('uses the user-facing full access name for bypassPermissions', () => {
84+
expect(formatApprovalMode('bypassPermissions')).toBe(
85+
'Full access (bypassPermissions)',
86+
)
87+
})
88+
89+
test('uses an explicit fallback for internal modes', () => {
90+
expect(formatApprovalMode('bubble')).toBe('Internal/Unknown')
91+
expect(getApprovalModeDescriptor('bubble')).toEqual({
92+
mode: 'bubble',
93+
label: 'Internal/Unknown',
94+
description: 'This approval mode is internal and not user-selectable',
95+
aliases: ['bubble'],
96+
})
97+
})
98+
})

src/commands/approval/approval.tsx

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import * as React from 'react';
2+
import { Box, Text } from '@anthropic/ink';
3+
import type { OptionWithDescription } from '../../components/CustomSelect/select.js';
4+
import { Select } from '../../components/CustomSelect/select.js';
5+
import {
6+
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
7+
logEvent,
8+
} from '../../services/analytics/index.js';
9+
import { useAppState, useSetAppState } from '../../state/AppState.js';
10+
import type { ToolPermissionContext } from '../../Tool.js';
11+
import type { LocalJSXCommandOnDone } from '../../types/command.js';
12+
import type { PermissionMode } from '../../utils/permissions/PermissionMode.js';
13+
import {
14+
getAutoModeUnavailableNotification,
15+
getAutoModeUnavailableReason,
16+
isBypassPermissionsModeDisabled,
17+
transitionPermissionMode,
18+
} from '../../utils/permissions/permissionSetup.js';
19+
import {
20+
APPROVAL_MODE_DESCRIPTORS,
21+
formatApprovalMode,
22+
getApprovalModeDescriptor,
23+
parseApprovalModeArg,
24+
} from './approvalModes.js';
25+
26+
type ApprovalCommandResult = {
27+
message: string;
28+
modeUpdate?: PermissionMode;
29+
};
30+
31+
function getModeUnavailableMessage(mode: PermissionMode, context: ToolPermissionContext): string | undefined {
32+
if (mode === 'bypassPermissions') {
33+
if (isBypassPermissionsModeDisabled()) {
34+
return 'Full access is disabled by settings or organization policy.';
35+
}
36+
if (!context.isBypassPermissionsModeAvailable) {
37+
return 'Full access is not available in this session. Start ccb with --allow-dangerously-skip-permissions to make it selectable, or --dangerously-skip-permissions to start directly in full access mode.';
38+
}
39+
}
40+
41+
if (mode === 'auto') {
42+
const reason = getAutoModeUnavailableReason();
43+
if (reason) {
44+
return `Auto approval is unavailable: ${getAutoModeUnavailableNotification(reason)}`;
45+
}
46+
}
47+
48+
return undefined;
49+
}
50+
51+
function applyApprovalMode(context: ToolPermissionContext, mode: PermissionMode): ApprovalCommandResult {
52+
const unavailableMessage = getModeUnavailableMessage(mode, context);
53+
if (unavailableMessage) {
54+
return { message: unavailableMessage };
55+
}
56+
57+
if (context.mode === mode) {
58+
return { message: `Approval mode is already ${formatApprovalMode(mode)}.` };
59+
}
60+
61+
logEvent('tengu_approval_command', {
62+
mode: mode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
63+
});
64+
65+
const descriptor = getApprovalModeDescriptor(mode);
66+
return {
67+
message: `Approval mode set to ${formatApprovalMode(mode)}: ${descriptor.description}`,
68+
modeUpdate: mode,
69+
};
70+
}
71+
72+
function applyModeUpdate(context: ToolPermissionContext, mode: PermissionMode): ToolPermissionContext {
73+
const next = transitionPermissionMode(context.mode, mode, context);
74+
return { ...next, mode };
75+
}
76+
77+
export function showCurrentApprovalMode(context: ToolPermissionContext): ApprovalCommandResult {
78+
const descriptor = getApprovalModeDescriptor(context.mode);
79+
return {
80+
message: `Current approval mode: ${formatApprovalMode(context.mode)} (${descriptor.description})`,
81+
};
82+
}
83+
84+
export function executeApproval(args: string, context: ToolPermissionContext): ApprovalCommandResult {
85+
const parsed = parseApprovalModeArg(args);
86+
switch (parsed.type) {
87+
case 'current':
88+
return showCurrentApprovalMode(context);
89+
case 'help':
90+
return {
91+
message: formatApprovalHelp(),
92+
};
93+
case 'mode':
94+
return applyApprovalMode(context, parsed.mode);
95+
case 'invalid':
96+
return { message: parsed.message };
97+
}
98+
}
99+
100+
function formatApprovalHelp(): string {
101+
const aliases = APPROVAL_MODE_DESCRIPTORS.map(descriptor => descriptor.aliases[0]).join('|');
102+
const modes = APPROVAL_MODE_DESCRIPTORS.map(
103+
descriptor => `- ${descriptor.aliases[0]}: ${descriptor.description}`,
104+
).join('\n');
105+
return `Usage: /approval [${aliases}]\n\nApproval modes:\n${modes}`;
106+
}
107+
108+
function ApplyApprovalAndClose({
109+
result,
110+
onDone,
111+
}: {
112+
result: ApprovalCommandResult;
113+
onDone: (result: string) => void;
114+
}): React.ReactNode {
115+
const setAppState = useSetAppState();
116+
const { message, modeUpdate } = result;
117+
React.useEffect(() => {
118+
if (modeUpdate) {
119+
setAppState(prev => ({
120+
...prev,
121+
toolPermissionContext: applyModeUpdate(prev.toolPermissionContext, modeUpdate),
122+
}));
123+
}
124+
onDone(message);
125+
}, [setAppState, message, modeUpdate, onDone]);
126+
return null;
127+
}
128+
129+
function ApprovalPicker({ onDone }: { onDone: (result: string) => void }): React.ReactNode {
130+
const toolPermissionContext = useAppState(s => s.toolPermissionContext);
131+
const setAppState = useSetAppState();
132+
133+
const options: OptionWithDescription<PermissionMode>[] = APPROVAL_MODE_DESCRIPTORS.map(descriptor => {
134+
const unavailableMessage = getModeUnavailableMessage(descriptor.mode, toolPermissionContext);
135+
return {
136+
label: descriptor.label,
137+
value: descriptor.mode,
138+
description:
139+
descriptor.mode === toolPermissionContext.mode
140+
? `${descriptor.description} (current)`
141+
: (unavailableMessage ?? descriptor.description),
142+
disabled: unavailableMessage !== undefined,
143+
};
144+
});
145+
146+
function handleSelect(mode: PermissionMode): void {
147+
const result = applyApprovalMode(toolPermissionContext, mode);
148+
if (result.modeUpdate) {
149+
setAppState(prev => ({
150+
...prev,
151+
toolPermissionContext: applyModeUpdate(prev.toolPermissionContext, result.modeUpdate!),
152+
}));
153+
}
154+
onDone(result.message);
155+
}
156+
157+
return (
158+
<Box flexDirection="column" gap={1}>
159+
<Text>Select approval mode</Text>
160+
<Select
161+
options={options}
162+
defaultValue={toolPermissionContext.mode}
163+
defaultFocusValue={toolPermissionContext.mode}
164+
onChange={handleSelect}
165+
onCancel={() => onDone('Approval mode unchanged.')}
166+
visibleOptionCount={6}
167+
/>
168+
</Box>
169+
);
170+
}
171+
172+
export async function call(onDone: LocalJSXCommandOnDone, _context: unknown, args?: string): Promise<React.ReactNode> {
173+
args = args?.trim() || '';
174+
175+
if (!args) {
176+
return <ApprovalPicker onDone={onDone} />;
177+
}
178+
return <ApprovalCommandWithArgs args={args} onDone={onDone} />;
179+
}
180+
181+
function ApprovalCommandWithArgs({
182+
args,
183+
onDone,
184+
}: {
185+
args: string;
186+
onDone: (result: string) => void;
187+
}): React.ReactNode {
188+
const toolPermissionContext = useAppState(s => s.toolPermissionContext);
189+
const result = executeApproval(args, toolPermissionContext);
190+
return <ApplyApprovalAndClose result={result} onDone={onDone} />;
191+
}

0 commit comments

Comments
 (0)