feat: UI許可システムの実装

- permissionModeを設定可能に変更(bypassPermissions, default, acceptEdits, plan)
- defaultモード時のUI許可ダイアログを実装
  - canUseTool callbackによるプログラマティックな許可処理
  - SSEを使用したリアルタイム通信
  - 許可/拒否の選択UI
- PermissionDialog機能
  - 大きなダイアログサイズ(max-w-4xl, max-h-[80vh])
  - パラメータの折りたたみ表示
  - 各パラメータのコピーボタン
  - 長いテキストのスクロール対応
  - レスポンシブデザイン対応
This commit is contained in:
dobachi
2025-09-24 01:37:53 +09:00
parent a35cba7a21
commit b7e9947efb
13 changed files with 541 additions and 11 deletions

View File

@@ -0,0 +1,195 @@
"use client";
import { ChevronDown, ChevronRight, Copy } from "lucide-react";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type {
PermissionRequest,
PermissionResponse,
} from "@/types/permissions";
interface PermissionDialogProps {
permissionRequest: PermissionRequest | null;
isOpen: boolean;
onResponse: (response: PermissionResponse) => void;
}
export const PermissionDialog = ({
permissionRequest,
isOpen,
onResponse,
}: PermissionDialogProps) => {
const [isResponding, setIsResponding] = useState(false);
const [isParametersExpanded, setIsParametersExpanded] = useState(false);
if (!permissionRequest) return null;
const handleResponse = async (decision: "allow" | "deny") => {
setIsResponding(true);
const response: PermissionResponse = {
permissionRequestId: permissionRequest.id,
decision,
};
try {
await onResponse(response);
} finally {
setIsResponding(false);
}
};
const formatValue = (value: unknown): string => {
if (value === null) return "null";
if (value === undefined) return "undefined";
if (typeof value === "boolean") return value.toString();
if (typeof value === "number") return value.toString();
if (typeof value === "string") return value;
return JSON.stringify(value, null, 2);
};
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
} catch (error) {
console.error("Failed to copy to clipboard:", error);
}
};
const renderParameterValue = (key: string, value: unknown) => {
const formattedValue = formatValue(value);
const isLong = formattedValue.length > 100;
return (
<div key={key} className="border rounded-lg p-3 space-y-2">
<div className="flex items-center justify-between">
<span className="font-medium text-sm">{key}</span>
<Button
variant="ghost"
size="sm"
onClick={() => copyToClipboard(formattedValue)}
className="h-6 w-6 p-0"
>
<Copy className="h-3 w-3" />
</Button>
</div>
<div
className={`text-xs font-mono bg-muted p-2 rounded ${isLong ? "max-h-32 overflow-y-auto" : ""}`}
>
<pre className="whitespace-pre-wrap break-words">
{formattedValue}
</pre>
</div>
</div>
);
};
const parameterEntries = Object.entries(permissionRequest.toolInput);
const hasParameters = parameterEntries.length > 0;
return (
<Dialog open={isOpen} onOpenChange={() => !isResponding}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle className="flex items-center gap-2">
<span className="text-orange-600"></span>
Claude Code Permission Request
</DialogTitle>
<DialogDescription>
Claude Code wants to execute the following tool and needs your
permission.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto space-y-4 pr-2">
{/* Tool Information */}
<div className="rounded-lg border p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-sm">
{permissionRequest.toolName}
</Badge>
</div>
<span className="text-xs text-muted-foreground">
{new Date(permissionRequest.timestamp).toLocaleTimeString()}
</span>
</div>
</div>
{/* Parameters Section */}
{hasParameters && (
<div className="rounded-lg border">
<Collapsible
open={isParametersExpanded}
onOpenChange={setIsParametersExpanded}
>
<CollapsibleTrigger asChild>
<div className="flex items-center justify-between p-4 hover:bg-muted/50 cursor-pointer">
<div className="flex items-center gap-2">
<span className="font-medium">Tool Parameters</span>
<Badge variant="outline" className="text-xs">
{parameterEntries.length} parameter
{parameterEntries.length !== 1 ? "s" : ""}
</Badge>
</div>
{isParametersExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</div>
</CollapsibleTrigger>
<CollapsibleContent className="px-4 pb-4">
<div className="space-y-3">
{parameterEntries.map(([key, value]) =>
renderParameterValue(key, value),
)}
</div>
</CollapsibleContent>
</Collapsible>
</div>
)}
{!hasParameters && (
<div className="rounded-lg border p-4 text-center text-muted-foreground">
This tool has no parameters.
</div>
)}
</div>
{/* Action Buttons */}
<div className="flex-shrink-0 flex gap-3 justify-end pt-4 border-t">
<Button
variant="outline"
onClick={() => handleResponse("deny")}
disabled={isResponding}
className="min-w-20"
>
Deny
</Button>
<Button
onClick={() => handleResponse("allow")}
disabled={isResponding}
className="min-w-20"
>
Allow
</Button>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -32,6 +32,7 @@ export const SettingsControls: FC<SettingsControlsProps> = ({
}: SettingsControlsProps) => {
const checkboxId = useId();
const enterKeyBehaviorId = useId();
const permissionModeId = useId();
const { config, updateConfig } = useConfig();
const queryClient = useQueryClient();
@@ -74,6 +75,19 @@ export const SettingsControls: FC<SettingsControlsProps> = ({
await onConfigChanged();
};
const handlePermissionModeChange = async (value: string) => {
const newConfig = {
...config,
permissionMode: value as
| "acceptEdits"
| "bypassPermissions"
| "default"
| "plan",
};
updateConfig(newConfig);
await onConfigChanged();
};
return (
<div className={`space-y-4 ${className}`}>
<div className="flex items-center space-x-2">
@@ -148,6 +162,41 @@ export const SettingsControls: FC<SettingsControlsProps> = ({
</p>
)}
</div>
<div className="space-y-2">
{showLabels && (
<label
htmlFor={permissionModeId}
className="text-sm font-medium leading-none"
>
Permission Mode
</label>
)}
<Select
value={config?.permissionMode || "default"}
onValueChange={handlePermissionModeChange}
>
<SelectTrigger id={permissionModeId} className="w-full">
<SelectValue placeholder="Select permission mode" />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default (Ask permission)</SelectItem>
<SelectItem value="acceptEdits">
Accept Edits (Auto-approve file edits)
</SelectItem>
<SelectItem value="bypassPermissions">
Bypass Permissions (No prompts)
</SelectItem>
<SelectItem value="plan">Plan Mode (Planning only)</SelectItem>
</SelectContent>
</Select>
{showDescriptions && (
<p className="text-xs text-muted-foreground mt-1">
Control how Claude Code handles permission requests for file
operations
</p>
)}
</div>
</div>
);
};