きっかけ
僕のClaude Codeの使い方はちょっと特殊です.
VSCodeのClaude Code拡張をオンにした状態で,
VSCodeのClaude Code拡張は使用せずに,
VSCodeでターミナルを起動して,
その上でClaude Codeを実行しています.
こうすることによって以下のメリットがあります.
- ファイル修正が提案された時,別タブでgit diffで表示される.また,提案された内容を手動で修正したうえで反映させることが出来る
- VSCodeのエディタ上で選択した領域が自動でClaudeに送信される
しかしこの二つ目のメリットが曲者です.
選択していたことを忘れていて,別に送らなくていいのに選択領域がClaudeに送られてしまうことがあります.
指示のノイズになるだけでなく,トークンも無駄に消費するのであまりよくないです.
ということで,何か選択している状態でClaudeに話しかけたら警告を出す拡張機能を作成しました.
拡張機能の仕様
VSCodeでターミナルを開き,claudeまたはccr codeで始まるコマンドでClaude Codeを起動し,その中でEnterキーを押下した時,直前に表示していたエディタに選択範囲があれば警告を出します.
逆に,/exitみたいなコマンドを送信した時や,Claudeから質問されて1. Yesを選択した時なんかにも警告が出てしまいます.しかしこれは回避できないので制限事項です.
また,claude.exeなど,指定以外の方法でClaude Codeを起動した場合もこの拡張機能は動きません.
ターミナルをエディタと同じようにタブで表示していた場合や,そのタブをドラッグアンドドロップして別ウィンドウにした場合にも対応しています.ただし,VSCodeが別インスタンスだった場合には対応していません.そもそもこの場合には,選択した領域が自動で送られることもないですが.
ちなみに,この拡張機能がオンになっている場合はVSCode左下に「Claude Selection Guard」と表示されます.
拡張機能のソース
全部Claude Codeが書いたソースなので僕は中身を何も理解していません.
何か質問があっても答えられません.
不具合があっても責任を取れないので自分で修正してください.
# Claude Selection Guard
VS Code の統合ターミナルで Claude Code を使うとき,エディタに選択範囲が残っている場合だけ,Enter 送信前に確認するためのローカル拡張です.
## 使い方
1. VS Code のコマンドパレットから `拡張機能: VSIXからのインストール...`(`Extensions: Install from VSIX...`)を実行し,`.vsix` ファイルをインストールする
2. 実際に Claude Code を使うプロジェクト側のユーザー設定(またはワークスペース設定)の `settings.json` に,以下を追加する(必須.詳細は下記「必須設定」を参照)
```json
"terminal.integrated.commandsToSkipShell": [
"claudeSelectionGuard.submit"
]
```
3. 統合ターミナルで `claude`(または `ccr code`)を実行し,いつも通り Claude Code を使う
インストール後は,通常の VS Code 上でそのまま動作します.
## 必須設定
上記「使い方」の手順2のとおり,`settings.json`(ユーザー設定またはワークスペース設定)に `terminal.integrated.commandsToSkipShell` の設定を必ず追加してください.
既存のターミナルで使う場合は,そのターミナルで `claude` または `ccr code` から始まるコマンドを実行してください.自動でガードされます.
自動検知できない場合は,対象ターミナルにフォーカスしてから `Claude Selection Guard: Guard Current Terminal` を実行してください.これは手動フォールバックで,解除するまでそのターミナルをガードします.
## 動作
`claude` または `ccr code` から始まるコマンドが実行中のターミナルで Enter を押したとき,直近のアクティブエディタに選択範囲があれば確認します.
- `送信する`: 選択範囲を残したまま Enter を送る
- `選択解除して送信`: エディタの選択範囲を解除してから Enter を送る
- `キャンセル`: Enter を送らない
選択範囲がない場合は,そのまま Enter を送ります.
## 制限事項
- Claude Code の hook ではなく VS Code の keybinding で Enter を仲介しています
- 自動検知には VS Code の shell integration が必要です.対応シェル(PowerShell,bash,zsh など)では既定で有効になっているため,通常は追加の設定は不要です
- `claude` または `ccr code` から始まるコマンドだけを自動検知します.`claude --resume` や `ccr code --foo` のような起動引数つきコマンドも対象ですが, `claude.exe` などは対象外です
- 拡張起動前からすでに Claude Code が動いているターミナルは,自動検知できない場合があります.その場合は `Guard Current Terminal` を使ってください
- 手動で guard したターミナルでは,Claude Code 起動前の通常シェル操作でも Enter が仲介されます
- VS Code API から見える `activeTextEditor` の選択範囲を見ています.Claude Code 側の内部 context を直接検査しているわけではありません
- VS Code 再起動後,手動で guard したターミナル状態は保持しません
- `Unguard Current Terminal` を実行すると,手動 guard 状態だけでなく自動検知していた実行中状態もまとめて解除されます.`claude` コマンドがまだ実行中でもガードが解除され,そのコマンドが終了するまで自動検知は働きません
- ターミナルに何が入力されているかまでは判別できないため,`claude` 実行中であれば `/exit` の実行や,権限確認メニューでの選択(`1. Yes` など)の Enter でも,エディタに選択範囲が残っていれば確認ダイアログが出ます
## 設定
- `claudeSelectionGuard.runningCommandPattern`: 自動で guard する実行コマンドの正規表現.既定値は `^\s*(?:claude|ccr\s+code)(?:\s|$)`.不正な正規表現を設定した場合は警告メッセージが表示され,自動検知は無効になります
- `claudeSelectionGuard.clearSelectionDelayMs`: 選択解除後に Enter を送るまでの待ち時間.既定値は `75`.VS Code の Claude Code IDE 連携が選択解除を認識する前に Enter が送られてしまうと,古い選択範囲がコンテキストとして渡ってしまうことがあるため,その競合を避けるための猶予時間です
const vscode = require("vscode");
const manuallyGuardedTerminals = new Set();
const runningClaudeExecutions = new Map();
let statusBarItem;
let warnedInvalidCommandPattern = false;
// ターミナル(エディタ領域表示を含む)にフォーカスが移りactiveTextEditorがundefinedになっても選択範囲を参照できるよう、直近の本物のテキストエディタを保持する
let lastActiveTextEditor;
async function activate(context) {
statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100);
statusBarItem.text = "$(shield) Claude Selection Guard";
statusBarItem.tooltip = "Selection guard is active for this terminal.";
statusBarItem.command = "claudeSelectionGuard.unguardCurrentTerminal";
lastActiveTextEditor = vscode.window.activeTextEditor;
context.subscriptions.push(statusBarItem);
context.subscriptions.push(vscode.commands.registerCommand("claudeSelectionGuard.guardCurrentTerminal", guardCurrentTerminal));
context.subscriptions.push(vscode.commands.registerCommand("claudeSelectionGuard.unguardCurrentTerminal", unguardCurrentTerminal));
context.subscriptions.push(vscode.commands.registerCommand("claudeSelectionGuard.submit", submitWithSelectionGuard));
context.subscriptions.push(vscode.window.onDidChangeActiveTerminal(updateGuardContext));
context.subscriptions.push(vscode.window.onDidChangeActiveTextEditor((editor) => {
if (editor) {
lastActiveTextEditor = editor;
}
}));
context.subscriptions.push(vscode.window.onDidStartTerminalShellExecution(onDidStartTerminalShellExecution));
context.subscriptions.push(vscode.window.onDidEndTerminalShellExecution(onDidEndTerminalShellExecution));
context.subscriptions.push(vscode.window.onDidCloseTerminal(async (terminal) => {
manuallyGuardedTerminals.delete(terminal);
runningClaudeExecutions.delete(terminal);
await updateGuardContext();
}));
context.subscriptions.push(vscode.workspace.onDidChangeConfiguration(async (event) => {
if (event.affectsConfiguration("claudeSelectionGuard.runningCommandPattern")) {
warnedInvalidCommandPattern = false;
await updateGuardContext();
}
}));
await updateGuardContext();
}
function deactivate() {}
async function guardCurrentTerminal() {
const terminal = vscode.window.activeTerminal;
if (!terminal) {
vscode.window.showWarningMessage("ガード対象にするアクティブなターミナルがありません。");
return;
}
manuallyGuardedTerminals.add(terminal);
await updateGuardContext();
vscode.window.showInformationMessage(`このターミナルを手動ガード対象にしました: ${terminal.name}`);
}
async function unguardCurrentTerminal() {
const terminal = vscode.window.activeTerminal;
if (!terminal) {
vscode.window.showWarningMessage("ガード解除するアクティブなターミナルがありません。");
return;
}
manuallyGuardedTerminals.delete(terminal);
runningClaudeExecutions.delete(terminal);
await updateGuardContext();
vscode.window.showInformationMessage(`このターミナルを Claude Selection Guard の対象から外しました: ${terminal.name}`);
}
async function submitWithSelectionGuard() {
const terminal = vscode.window.activeTerminal;
if (!terminal) {
return;
}
if (!shouldGuardTerminal(terminal)) {
sendEnter(terminal);
return;
}
const selectionInfo = getSelectionInfo();
if (!selectionInfo) {
sendEnter(terminal);
return;
}
// 閉じる操作(Esc/×)もキャンセル扱いにするため、キャンセルボタンをisCloseAffordanceにして重複表示を防ぐ
const cancelItem = { title: "キャンセル", isCloseAffordance: true };
const clearAndSendItem = { title: "選択解除して送信" };
const sendItem = { title: "送信する" };
const choice = await vscode.window.showWarningMessage(
`${selectionInfo.fileLabel} に ${selectionInfo.lineCount} 行の選択範囲があります。Claude Code に送信しますか?`,
{
modal: true,
detail: "VS Code の Claude Code IDE 連携が有効な場合、現在の選択範囲はプロンプト送信時に context として渡されます。"
},
sendItem,
clearAndSendItem,
cancelItem
);
if (!choice || choice === cancelItem) {
return;
}
if (choice === sendItem) {
sendEnter(terminal);
return;
}
if (choice === clearAndSendItem) {
clearSelections(selectionInfo.editor);
await delay(getClearSelectionDelayMs());
sendEnter(terminal);
}
}
async function onDidStartTerminalShellExecution(event) {
const commandLine = event.execution.commandLine.value;
if (!matchesRunningCommandPattern(commandLine)) {
return;
}
runningClaudeExecutions.set(event.terminal, event.execution);
await updateGuardContext();
}
async function onDidEndTerminalShellExecution(event) {
if (runningClaudeExecutions.get(event.terminal) !== event.execution) {
return;
}
runningClaudeExecutions.delete(event.terminal);
await updateGuardContext();
}
function getSelectionInfo() {
// ターミナルがエディタ領域のタブとして開かれフォーカスされている場合、activeTextEditorはundefinedになる。
// その場合は直近にフォーカスされていた本物のテキストエディタを代わりに参照する。
// ターミナルタブと同じグループ内で背後に隠れているだけのエディタはvisibleTextEditorsから外れるため、
// 「表示中かどうか」ではなく「ドキュメントがまだ閉じられていないか」で有効性を判定する。
const editor = vscode.window.activeTextEditor
?? (lastActiveTextEditor && vscode.workspace.textDocuments.includes(lastActiveTextEditor.document) ? lastActiveTextEditor : undefined);
if (!editor) {
return undefined;
}
const selectedRanges = editor.selections.filter((selection) => !selection.isEmpty);
if (selectedRanges.length === 0) {
return undefined;
}
// 複数選択が同じ行を含む場合に二重カウントしないよう、行番号を一意に集計する
const selectedLines = new Set();
for (const selection of selectedRanges) {
for (let line = selection.start.line; line <= selection.end.line; line++) {
selectedLines.add(line);
}
}
const lineCount = selectedLines.size;
const fileLabel = vscode.workspace.asRelativePath(editor.document.uri, false);
return {
editor,
lineCount,
fileLabel
};
}
function clearSelections(editor) {
editor.selections = editor.selections.map((selection) => {
return new vscode.Selection(selection.active, selection.active);
});
}
function sendEnter(terminal) {
terminal.sendText("\r", false);
}
async function updateGuardContext() {
const terminal = vscode.window.activeTerminal;
const active = shouldGuardTerminal(terminal);
await vscode.commands.executeCommand("setContext", "claudeSelectionGuard.guardedTerminalActive", active);
if (active) {
statusBarItem.show();
} else {
statusBarItem.hide();
}
}
function shouldGuardTerminal(terminal) {
if (!terminal) {
return false;
}
if (runningClaudeExecutions.has(terminal)) {
return true;
}
return manuallyGuardedTerminals.has(terminal);
}
function matchesRunningCommandPattern(commandLine) {
const pattern = vscode.workspace
.getConfiguration("claudeSelectionGuard")
.get("runningCommandPattern", "^\\s*(?:claude|ccr\\s+code)(?:\\s|$)");
if (!pattern) {
return false;
}
try {
return new RegExp(pattern).test(commandLine);
} catch (error) {
if (!warnedInvalidCommandPattern) {
warnedInvalidCommandPattern = true;
vscode.window.showWarningMessage(`Claude Selection Guard の runningCommandPattern 設定が不正な正規表現です: ${pattern}`);
}
return false;
}
}
function getClearSelectionDelayMs() {
return vscode.workspace
.getConfiguration("claudeSelectionGuard")
.get("clearSelectionDelayMs", 75);
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
module.exports = {
activate,
deactivate
};