From 91c25e9dd6ce95c15196c26e006437ef3856fef6 Mon Sep 17 00:00:00 2001 From: ktmage Date: Sun, 1 Mar 2026 02:34:15 +0900 Subject: [PATCH 1/6] feat: add shell command execution via ! prefix - Add executeShell() method to opencode-client.ts - Add executeShell message type to webview protocol - Add handler in chat-view-provider.ts - Detect ! prefix in InputArea and route to onShellExecute - Add shell mode indicator and placeholder - Add locale keys for shell mode (en/ja) --- src/chat-view-provider.ts | 5 ++++ src/opencode-client.ts | 14 +++++++++++ .../organisms/InputArea/InputArea.module.css | 13 ++++++++++ .../organisms/InputArea/InputArea.tsx | 25 ++++++++++++++++--- webview/locales/en.ts | 5 ++++ webview/locales/ja.ts | 5 ++++ webview/vscode-api.ts | 1 + 7 files changed, 65 insertions(+), 3 deletions(-) diff --git a/src/chat-view-provider.ts b/src/chat-view-provider.ts index e16ea55..6f9d041 100644 --- a/src/chat-view-provider.ts +++ b/src/chat-view-provider.ts @@ -67,6 +67,7 @@ export type WebviewToExtMessage = model?: { providerID: string; modelID: string }; files?: FileAttachment[]; } + | { type: "executeShell"; sessionId: string; command: string; model?: { providerID: string; modelID: string } } | { type: "openConfigFile"; filePath: string } | { type: "openTerminal" } | { type: "setModel"; model: string } @@ -271,6 +272,10 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { await this.connection.sendMessage(message.sessionId, message.text, message.model, message.files); break; } + case "executeShell": { + await this.connection.executeShell(message.sessionId, message.command, message.model); + break; + } case "openConfigFile": { const filePath = message.filePath; const uri = vscode.Uri.file(filePath); diff --git a/src/opencode-client.ts b/src/opencode-client.ts index e48bc7e..2697e37 100644 --- a/src/opencode-client.ts +++ b/src/opencode-client.ts @@ -203,6 +203,20 @@ export class OpenCodeConnection { }); } + // --- Shell API --- + + async executeShell( + sessionId: string, + command: string, + model?: { providerID: string; modelID: string }, + ): Promise { + const client = this.requireClient(); + await client.session.shell({ + path: { id: sessionId }, + body: { agent: "default", command, model }, + }); + } + // --- Provider API --- async getProviders(): Promise<{ providers: Provider[]; default: Record }> { diff --git a/webview/components/organisms/InputArea/InputArea.module.css b/webview/components/organisms/InputArea/InputArea.module.css index 98bf839..3418bf3 100644 --- a/webview/components/organisms/InputArea/InputArea.module.css +++ b/webview/components/organisms/InputArea/InputArea.module.css @@ -78,3 +78,16 @@ .expanded { transform: rotate(90deg); } + +.shellIndicator { + display: flex; + align-items: center; + gap: 4px; + padding: 2px 6px; + margin-bottom: 2px; + font-size: 11px; + color: var(--vscode-terminal-ansiYellow, #e5c07b); + background-color: var(--vscode-badge-background); + border-radius: 3px; + width: fit-content; +} diff --git a/webview/components/organisms/InputArea/InputArea.tsx b/webview/components/organisms/InputArea/InputArea.tsx index 40da43c..0ce3b10 100644 --- a/webview/components/organisms/InputArea/InputArea.tsx +++ b/webview/components/organisms/InputArea/InputArea.tsx @@ -17,6 +17,7 @@ import styles from "./InputArea.module.css"; type Props = { onSend: (text: string, files: FileAttachment[]) => void; + onShellExecute: (command: string) => void; onAbort: () => void; isBusy: boolean; providers: Provider[]; @@ -40,6 +41,7 @@ type Props = { export function InputArea({ onSend, + onShellExecute, onAbort, isBusy, providers, @@ -155,16 +157,27 @@ export function InputArea({ } }, [hashTrigger.active, hashQuery]); + // ! プレフィクスでシェルコマンドモードかどうかを判定する + const isShellMode = text.startsWith("!"); + const handleSend = useCallback(() => { const trimmed = text.trim(); if (!trimmed) return; - onSend(trimmed, attachedFiles); + // ! プレフィクスの場合はシェルコマンドとして実行する + if (trimmed.startsWith("!")) { + const command = trimmed.slice(1).trim(); + if (command) { + onShellExecute(command); + } + } else { + onSend(trimmed, attachedFiles); + } setText(""); setAttachedFiles([]); if (textareaRef.current) { textareaRef.current.style.height = "auto"; } - }, [text, attachedFiles, onSend]); + }, [text, attachedFiles, onSend, onShellExecute]); const handleKeyDown = useCallback( (e: KeyboardEvent) => { @@ -291,10 +304,16 @@ export function InputArea({ {/* テキスト入力エリア(# ポップアップ付き) */}
+ {isShellMode && ( +
+ + {t["input.shellMode"]} +
+ )}