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/App.tsx b/webview/App.tsx index 9f01436..875cf43 100644 --- a/webview/App.tsx +++ b/webview/App.tsx @@ -151,6 +151,22 @@ export function App() { [session.activeSession, prov.selectedModel], ); + // ! プレフィクスで入力されたシェルコマンドを session.shell API 経由で実行する + const handleShellExecute = useCallback( + (command: string) => { + if (!session.activeSession) return; + // 次に到着する assistant メッセージをシェル結果としてタグ付けする準備 + msg.markPendingShell(); + postMessage({ + type: "executeShell", + sessionId: session.activeSession.id, + command, + model: prov.selectedModel ?? undefined, + }); + }, + [session.activeSession, prov.selectedModel, msg.markPendingShell], + ); + const handleAbort = useCallback(() => { if (!session.activeSession) return; postMessage({ type: "abort", sessionId: session.activeSession.id }); @@ -236,6 +252,8 @@ export function App() { openEditors, workspaceFiles, onSend: handleSend, + onShellExecute: handleShellExecute, + isShellMessage: msg.isShellMessage, onAbort: handleAbort, onCompress: handleCompress, isCompressing: !!session.activeSession?.time?.compacting, @@ -279,6 +297,7 @@ export function App() { {msg.latestTodos.length > 0 && } { + // --- Completed command --- + context("完了したコマンドを表示する場合", () => { + // renders Shell header + it("Shell ヘッダーを表示すること", () => { + const parts = [ + createToolPart("bash", { + state: { status: "completed", title: "ls", input: { command: "ls" }, output: "file.txt" }, + } as any), + ]; + render(); + expect(screen.getByText("Shell")).toBeInTheDocument(); + }); + + // renders $ prompt with command + it("$ プロンプトとコマンドを表示すること", () => { + const parts = [ + createToolPart("bash", { + state: { status: "completed", title: "ls -la", input: { command: "ls -la" }, output: "total 0" }, + } as any), + ]; + render(); + expect(screen.getByText("$")).toBeInTheDocument(); + expect(screen.getByText("ls -la")).toBeInTheDocument(); + }); + + // renders output text + it("出力テキストを表示すること", () => { + const parts = [ + createToolPart("bash", { + state: { status: "completed", title: "echo", input: { command: "echo hello" }, output: "hello" }, + } as any), + ]; + render(); + expect(screen.getByText("hello")).toBeInTheDocument(); + }); + + // is expanded by default + it("デフォルトで展開状態であること", () => { + const parts = [ + createToolPart("bash", { + state: { status: "completed", title: "ls", input: { command: "ls" }, output: "file.txt" }, + } as any), + ]; + render(); + expect(screen.getByText("file.txt")).toBeInTheDocument(); + }); + }); + + // --- Collapse/expand toggle --- + context("ヘッダーをクリックした場合", () => { + // hides output when collapsed + it("出力が非表示になること", () => { + const parts = [ + createToolPart("bash", { + state: { status: "completed", title: "ls", input: { command: "ls" }, output: "file.txt" }, + } as any), + ]; + render(); + fireEvent.click(screen.getByText("Shell")); + expect(screen.queryByText("file.txt")).not.toBeInTheDocument(); + }); + + // shows output when expanded again + it("再度クリックで出力が表示されること", () => { + const parts = [ + createToolPart("bash", { + state: { status: "completed", title: "ls", input: { command: "ls" }, output: "file.txt" }, + } as any), + ]; + render(); + fireEvent.click(screen.getByText("Shell")); + fireEvent.click(screen.getByText("Shell")); + expect(screen.getByText("file.txt")).toBeInTheDocument(); + }); + }); + + // --- Running command --- + context("実行中のコマンドを表示する場合", () => { + // renders spinner + it("スピナーを表示すること", () => { + const parts = [ + createToolPart("bash", { + state: { status: "running", title: "sleep", input: { command: "sleep 10" } }, + } as any), + ]; + const { container } = render(); + expect(container.querySelector("[class*='spinner']")).toBeInTheDocument(); + }); + + // does not render output + it("出力を表示しないこと", () => { + const parts = [ + createToolPart("bash", { + state: { status: "running", title: "sleep", input: { command: "sleep 10" } }, + } as any), + ]; + const { container } = render(); + expect(container.querySelector("[class*='output']")).not.toBeInTheDocument(); + }); + }); + + // --- Pending command --- + context("pending 状態のコマンドを表示する場合", () => { + // renders spinner for pending status + it("スピナーを表示すること", () => { + const parts = [ + createToolPart("bash", { + state: { status: "pending" }, + } as any), + ]; + const { container } = render(); + expect(container.querySelector("[class*='spinner']")).toBeInTheDocument(); + }); + }); + + // --- Error command --- + context("エラーのコマンドを表示する場合", () => { + // renders error output + it("エラー出力を表示すること", () => { + const parts = [ + createToolPart("bash", { + state: { status: "error", title: "bad", input: { command: "bad-cmd" }, error: "command not found" }, + } as any), + ]; + render(); + expect(screen.getByText("command not found")).toBeInTheDocument(); + }); + + // applies error styling + it("エラー用のスタイルが適用されること", () => { + const parts = [ + createToolPart("bash", { + state: { status: "error", title: "bad", input: { command: "bad-cmd" }, error: "command not found" }, + } as any), + ]; + const { container } = render(); + expect(container.querySelector("[class*='outputError']")).toBeInTheDocument(); + }); + }); + + // --- Multiple entries --- + context("複数のコマンド結果がある場合", () => { + // renders all entries + it("すべてのコマンドエントリを表示すること", () => { + const parts = [ + createToolPart("bash", { + state: { status: "completed", title: "ls", input: { command: "ls" }, output: "file1.txt" }, + } as any), + createToolPart("bash", { + state: { status: "completed", title: "pwd", input: { command: "pwd" }, output: "/home" }, + } as any), + ]; + render(); + expect(screen.getByText("file1.txt")).toBeInTheDocument(); + expect(screen.getByText("/home")).toBeInTheDocument(); + }); + + // renders multiple $ prompts + it("複数の $ プロンプトを表示すること", () => { + const parts = [ + createToolPart("bash", { + state: { status: "completed", title: "ls", input: { command: "ls" }, output: "file1.txt" }, + } as any), + createToolPart("bash", { + state: { status: "completed", title: "pwd", input: { command: "pwd" }, output: "/home" }, + } as any), + ]; + render(); + expect(screen.getAllByText("$")).toHaveLength(2); + }); + }); + + // --- Empty parts --- + context("パーツが空の場合", () => { + // renders header without error + it("ヘッダーのみ表示してエラーにならないこと", () => { + render(); + expect(screen.getByText("Shell")).toBeInTheDocument(); + }); + }); + + // --- Non-tool parts are filtered --- + context("tool 以外のパーツが含まれる場合", () => { + // filters out non-tool parts + it("tool 以外のパーツが除外されること", () => { + const parts = [ + { id: "p1", type: "text", text: "ignored", messageID: "m1" } as any, + createToolPart("bash", { + state: { status: "completed", title: "ls", input: { command: "ls" }, output: "ok" }, + } as any), + ]; + render(); + expect(screen.getAllByText("$")).toHaveLength(1); + }); + }); +}); diff --git a/webview/__tests__/components/organisms/MessageItem.test.tsx b/webview/__tests__/components/organisms/MessageItem.test.tsx index ed8df7e..3cdc4a7 100644 --- a/webview/__tests__/components/organisms/MessageItem.test.tsx +++ b/webview/__tests__/components/organisms/MessageItem.test.tsx @@ -1,10 +1,24 @@ -import { fireEvent, render } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; import { describe, expect, it, vi } from "vitest"; import type { MessageWithParts } from "../../../App"; import { MessageItem } from "../../../components/organisms/MessageItem"; +import { AppContextProvider, type AppContextValue } from "../../../contexts/AppContext"; import { createMessage, createTextPart, createToolPart } from "../../factories"; +/** AppContext 必須の値を最小限で提供するラッパー */ +function createContextWrapper(overrides: Partial = {}) { + const contextValue = { + isShellMessage: () => false, + ...overrides, + } as unknown as AppContextValue; + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + describe("MessageItem", () => { + const wrapper = createContextWrapper(); const defaultProps = { activeSessionId: "session-1", permissions: new Map(), @@ -20,19 +34,19 @@ describe("MessageItem", () => { // renders as user message it("ユーザーメッセージとしてレンダリングすること", () => { - const { container } = render(); + const { container } = render(, { wrapper }); expect(container.querySelector(".user")).toBeInTheDocument(); }); // renders user text it("ユーザーテキストを表示すること", () => { - const { container } = render(); + const { container } = render(, { wrapper }); expect(container.querySelector(".content")?.textContent).toBe("Hello"); }); // shows edit icon it("編集アイコンを表示すること", () => { - const { container } = render(); + const { container } = render(, { wrapper }); expect(container.querySelector(".editIcon")).toBeInTheDocument(); }); }); @@ -46,7 +60,7 @@ describe("MessageItem", () => { // enters edit mode it("編集モードに入ること", () => { - const { container } = render(); + const { container } = render(, { wrapper }); fireEvent.click(container.querySelector(".userBubble")!); expect(container.querySelector(".editTextarea")).toBeInTheDocument(); }); @@ -61,14 +75,55 @@ describe("MessageItem", () => { // renders as assistant message it("アシスタントメッセージとしてレンダリングすること", () => { - const { container } = render(); + const { container } = render(, { wrapper }); expect(container.querySelector(".assistant")).toBeInTheDocument(); }); // renders text and tool parts it("テキストとツールパートをレンダリングすること", () => { - const { container } = render(); + const { container } = render(, { wrapper }); expect(container.querySelector(".root")).toBeInTheDocument(); }); }); + + // when rendered with a shell assistant message + context("シェルコマンド結果のアシスタントメッセージの場合", () => { + const shellWrapper = createContextWrapper({ isShellMessage: (id: string) => id === "shell-msg" }); + const shellMsg: MessageWithParts = { + info: createMessage({ id: "shell-msg", role: "assistant" }), + parts: [ + createTextPart("The following tool was executed by the user"), + createToolPart("bash", { + state: { status: "completed", title: "ls", input: { command: "ls" }, output: "file.txt" }, + } as any), + ], + }; + + // renders ShellResultView instead of ToolPartView + it("ShellResultView をレンダリングすること", () => { + render(, { wrapper: shellWrapper }); + expect(screen.getByText("Shell")).toBeInTheDocument(); + }); + + // does not render the synthetic text part + it("テキストパートを表示しないこと", () => { + render(, { wrapper: shellWrapper }); + expect(screen.queryByText("The following tool was executed by the user")).not.toBeInTheDocument(); + }); + }); + + // when rendered with a shell user message + context("シェルコマンドのユーザーメッセージの場合", () => { + const shellWrapper = createContextWrapper({ isShellMessage: (id: string) => id === "shell-user" }); + const shellUserMsg: MessageWithParts = { + info: createMessage({ id: "shell-user", role: "user" }), + parts: [createTextPart("!ls -la")], + }; + + // hides user bubble + it("ユーザー吹き出しが非表示であること", () => { + const { container } = render(, { wrapper: shellWrapper }); + expect(container.querySelector("[class*='userBubble']")).not.toBeInTheDocument(); + }); + }); }); diff --git a/webview/__tests__/components/organisms/MessagesArea.test.tsx b/webview/__tests__/components/organisms/MessagesArea.test.tsx index 133fe6d..01b840e 100644 --- a/webview/__tests__/components/organisms/MessagesArea.test.tsx +++ b/webview/__tests__/components/organisms/MessagesArea.test.tsx @@ -1,9 +1,21 @@ import { render } from "@testing-library/react"; +import type { ReactNode } from "react"; import { describe, expect, it, vi } from "vitest"; import type { MessageWithParts } from "../../../App"; import { MessagesArea } from "../../../components/organisms/MessagesArea"; +import { AppContextProvider, type AppContextValue } from "../../../contexts/AppContext"; import { createMessage, createTextPart } from "../../factories"; +/** AppContext 必須の値を最小限で提供するラッパー */ +function createContextWrapper() { + const contextValue = { + isShellMessage: () => false, + } as unknown as AppContextValue; + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + const userMsg: MessageWithParts = { info: createMessage({ role: "user" }), parts: [createTextPart("Hello")], @@ -24,11 +36,13 @@ const defaultProps = { }; describe("MessagesArea", () => { + const wrapper = createContextWrapper(); + // when rendered with messages context("メッセージがある場合", () => { // renders message items it("メッセージアイテムをレンダリングすること", () => { - const { container } = render(); + const { container } = render(, { wrapper }); expect(container.querySelectorAll(".message").length).toBeGreaterThan(0); }); }); @@ -37,7 +51,7 @@ describe("MessagesArea", () => { context("セッションが busy の場合", () => { // renders streaming indicator it("StreamingIndicator をレンダリングすること", () => { - const { container } = render(); + const { container } = render(, { wrapper }); expect(container.querySelector("[data-testid='streaming-indicator']")).toBeInTheDocument(); }); }); @@ -46,7 +60,7 @@ describe("MessagesArea", () => { context("セッションが busy でない場合", () => { // does not render streaming indicator it("StreamingIndicator をレンダリングしないこと", () => { - const { container } = render(); + const { container } = render(, { wrapper }); expect(container.querySelector("[data-testid='streaming-indicator']")).not.toBeInTheDocument(); }); }); @@ -59,7 +73,7 @@ describe("MessagesArea", () => { { info: createMessage({ role: "assistant" }), parts: [createTextPart("Reply")] }, { info: createMessage({ role: "user" }), parts: [createTextPart("Follow up")] }, ]; - const { container } = render(); + const { container } = render(, { wrapper }); expect(container.querySelector(".checkpointDivider")).toBeInTheDocument(); }); }); diff --git a/webview/__tests__/hooks/useMessages.test.ts b/webview/__tests__/hooks/useMessages.test.ts index 85ed6b8..ab8cf52 100644 --- a/webview/__tests__/hooks/useMessages.test.ts +++ b/webview/__tests__/hooks/useMessages.test.ts @@ -150,6 +150,95 @@ describe("useMessages", () => { }); }); + // markPendingShell and isShellMessage + context("markPendingShell を呼び出した場合", () => { + // tags next assistant message as shell + it("次の assistant メッセージがシェルメッセージとしてタグ付けされること", () => { + const { result } = renderHook(() => useMessages()); + act(() => result.current.markPendingShell()); + const event = { + type: "message.updated", + properties: { info: { id: "shell-a1", role: "assistant" } }, + } as unknown as Event; + act(() => result.current.handleMessageEvent(event)); + expect(result.current.isShellMessage("shell-a1")).toBe(true); + }); + + // tags user message as shell + it("user メッセージもシェルメッセージとしてタグ付けされること", () => { + const { result } = renderHook(() => useMessages()); + act(() => result.current.markPendingShell()); + const event = { + type: "message.updated", + properties: { info: { id: "shell-u1", role: "user" } }, + } as unknown as Event; + act(() => result.current.handleMessageEvent(event)); + expect(result.current.isShellMessage("shell-u1")).toBe(true); + }); + + // clears pending flag after assistant message + it("assistant メッセージ後にフラグがクリアされること", () => { + const { result } = renderHook(() => useMessages()); + act(() => result.current.markPendingShell()); + // user message arrives first + act(() => + result.current.handleMessageEvent({ + type: "message.updated", + properties: { info: { id: "u1", role: "user" } }, + } as unknown as Event), + ); + // assistant message arrives and clears the flag + act(() => + result.current.handleMessageEvent({ + type: "message.updated", + properties: { info: { id: "a1", role: "assistant" } }, + } as unknown as Event), + ); + // next message should NOT be tagged + act(() => + result.current.handleMessageEvent({ + type: "message.updated", + properties: { info: { id: "a2", role: "assistant" } }, + } as unknown as Event), + ); + expect(result.current.isShellMessage("a2")).toBe(false); + }); + + // does not clear pending flag on user message alone + it("user メッセージだけではフラグがクリアされないこと", () => { + const { result } = renderHook(() => useMessages()); + act(() => result.current.markPendingShell()); + act(() => + result.current.handleMessageEvent({ + type: "message.updated", + properties: { info: { id: "u1", role: "user" } }, + } as unknown as Event), + ); + // next assistant should still be tagged + act(() => + result.current.handleMessageEvent({ + type: "message.updated", + properties: { info: { id: "a1", role: "assistant" } }, + } as unknown as Event), + ); + expect(result.current.isShellMessage("a1")).toBe(true); + }); + }); + + // isShellMessage returns false for normal messages + context("markPendingShell を呼び出していない場合", () => { + // returns false for normal messages + it("通常メッセージの isShellMessage が false を返すこと", () => { + const { result } = renderHook(() => useMessages()); + const event = { + type: "message.updated", + properties: { info: { id: "m1", role: "assistant" } }, + } as unknown as Event; + act(() => result.current.handleMessageEvent(event)); + expect(result.current.isShellMessage("m1")).toBe(false); + }); + }); + // latestTodos derivation context("messages に todowrite ツールの完了出力がある場合", () => { // parses todos from completed tool output diff --git a/webview/__tests__/scenarios/14-shell-command.test.tsx b/webview/__tests__/scenarios/14-shell-command.test.tsx new file mode 100644 index 0000000..368a391 --- /dev/null +++ b/webview/__tests__/scenarios/14-shell-command.test.tsx @@ -0,0 +1,322 @@ +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { postMessage } from "../../vscode-api"; +import { createAllProvidersData, createMessage, createProvider, createSession, createToolPart } from "../factories"; +import { renderApp, sendExtMessage } from "../helpers"; + +/** アクティブセッションを持つ状態をセットアップする */ +async function setupActiveSession() { + renderApp(); + const session = createSession({ id: "s1", title: "Chat" }); + await sendExtMessage({ type: "activeSession", session }); + vi.mocked(postMessage).mockClear(); + return session; +} + +// Shell command execution +describe("シェルコマンド実行", () => { + // ! prefix sends executeShell instead of sendMessage + it("! プレフィクスで executeShell が送信されること", async () => { + const session = await setupActiveSession(); + const user = userEvent.setup(); + + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "!ls -la{Enter}"); + + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "executeShell", + sessionId: session.id, + command: "ls -la", + }), + ); + }); + + // ! prefix does not send sendMessage + it("! プレフィクスの場合 sendMessage が送信されないこと", async () => { + await setupActiveSession(); + const user = userEvent.setup(); + + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "!echo hello{Enter}"); + + expect(postMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "sendMessage" })); + }); + + // ! only (no command) does not send + it("! のみでは送信されないこと", async () => { + await setupActiveSession(); + const user = userEvent.setup(); + + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "!{Enter}"); + + expect(postMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "executeShell" })); + expect(postMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "sendMessage" })); + }); + + // Shell mode indicator appears when typing ! + context("! を入力した場合", () => { + beforeEach(async () => { + await setupActiveSession(); + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "!"); + }); + + // Shell mode indicator is displayed + it("シェルモードインジケーターが表示されること", () => { + expect(screen.getByText("Shell mode")).toBeInTheDocument(); + }); + + // Placeholder changes to shell-specific text + it("プレースホルダーがシェルコマンド用に変わること", () => { + expect(screen.getByPlaceholderText("Enter shell command...")).toBeInTheDocument(); + }); + }); + + // Shell mode indicator disappears when ! is removed + context("! を削除した場合", () => { + beforeEach(async () => { + await setupActiveSession(); + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "!"); + await user.clear(textarea); + }); + + // Shell mode indicator is hidden + it("シェルモードインジケーターが非表示になること", () => { + expect(screen.queryByText("Shell mode")).not.toBeInTheDocument(); + }); + }); + + // Text without ! prefix sends normal message + it("! なしのテキストは通常の sendMessage として送信されること", async () => { + const session = await setupActiveSession(); + const user = userEvent.setup(); + + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "Hello world{Enter}"); + + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "sendMessage", + sessionId: session.id, + text: "Hello world", + }), + ); + expect(postMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "executeShell" })); + }); + + // executeShell includes selectedModel + it("executeShell に selectedModel が含まれること", async () => { + renderApp(); + + const provider = createProvider("anthropic", { + "claude-4-opus": { id: "claude-4-opus", name: "Claude 4 Opus", limit: { context: 200000, output: 4096 } }, + }); + await sendExtMessage({ + type: "providers", + providers: [provider], + allProviders: createAllProvidersData( + ["anthropic"], + [ + { + id: "anthropic", + name: "Anthropic", + models: { + "claude-4-opus": { id: "claude-4-opus", name: "Claude 4 Opus", limit: { context: 200000, output: 4096 } }, + }, + }, + ], + ), + default: { general: "anthropic/claude-4-opus" }, + configModel: "anthropic/claude-4-opus", + }); + + const session = createSession({ id: "s1" }); + await sendExtMessage({ type: "activeSession", session }); + vi.mocked(postMessage).mockClear(); + + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "!git status{Enter}"); + + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "executeShell", + sessionId: "s1", + command: "git status", + model: { providerID: "anthropic", modelID: "claude-4-opus" }, + }), + ); + }); + + // Input is cleared after shell command execution + it("シェルコマンド実行後に入力欄がクリアされること", async () => { + await setupActiveSession(); + const user = userEvent.setup(); + + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "!pwd{Enter}"); + + expect(textarea).toHaveValue(""); + }); + + // Shell result is rendered with ShellResultView (terminal-style) + context("シェルコマンドの結果を受信した場合", () => { + beforeEach(async () => { + await setupActiveSession(); + const user = userEvent.setup(); + + // ! プレフィクスでシェルコマンドを送信 + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "!ls{Enter}"); + + // サーバーからの応答をシミュレート: user メッセージ(シェルコマンドのトリガー) + const userMsg = createMessage({ id: "shell-u1", sessionID: "s1", role: "user" }); + await sendExtMessage({ + type: "event", + event: { type: "message.updated", properties: { info: userMsg } } as any, + }); + + // サーバーからの応答をシミュレート: message.updated(新しい assistant メッセージ) + const shellMsg = createMessage({ id: "shell-m1", sessionID: "s1", role: "assistant" }); + await sendExtMessage({ + type: "event", + event: { type: "message.updated", properties: { info: shellMsg } } as any, + }); + + // ツールパート(bash の実行結果) + const shellPart = createToolPart("bash", { + messageID: "shell-m1", + state: { + status: "completed", + title: "ls", + input: { command: "ls" }, + output: "file1.txt\nfile2.txt", + }, + } as any); + await sendExtMessage({ + type: "event", + event: { type: "message.part.updated", properties: { part: shellPart } } as any, + }); + }); + + // Shell result is displayed in terminal style (shows "Shell" header) + it("ターミナル風のシェル結果ヘッダーが表示されること", () => { + expect(screen.getByText("Shell")).toBeInTheDocument(); + }); + + // Shell output is visible without clicking (expanded by default) + it("シェル出力がデフォルトで展開表示されること", () => { + expect(screen.getByText(/file1\.txt/)).toBeInTheDocument(); + }); + + // User message bubble is hidden for shell commands + it("シェルコマンドのユーザー吹き出しが非表示であること", () => { + // ユーザーメッセージは ShellResultView の "$ command" で表示されるため + // 通常のユーザー吹き出し(クリックで編集可能な要素)は表示されない + const userBubbles = document.querySelectorAll("[class*='userBubble']"); + expect(userBubbles).toHaveLength(0); + }); + + // $ prompt is rendered for the command + it("$ プロンプトとコマンドが表示されること", () => { + expect(screen.getByText("$")).toBeInTheDocument(); + expect(screen.getByText("ls")).toBeInTheDocument(); + }); + }); + + // Shell result with error + context("シェルコマンドがエラーを返した場合", () => { + beforeEach(async () => { + await setupActiveSession(); + const user = userEvent.setup(); + + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "!bad-cmd{Enter}"); + + const userMsg = createMessage({ id: "err-u1", sessionID: "s1", role: "user" }); + await sendExtMessage({ + type: "event", + event: { type: "message.updated", properties: { info: userMsg } } as any, + }); + + const shellMsg = createMessage({ id: "err-m1", sessionID: "s1", role: "assistant" }); + await sendExtMessage({ + type: "event", + event: { type: "message.updated", properties: { info: shellMsg } } as any, + }); + + const shellPart = createToolPart("bash", { + messageID: "err-m1", + state: { + status: "error", + title: "bad-cmd", + input: { command: "bad-cmd" }, + error: "command not found: bad-cmd", + }, + } as any); + await sendExtMessage({ + type: "event", + event: { type: "message.part.updated", properties: { part: shellPart } } as any, + }); + }); + + // error message is displayed + it("エラーメッセージが表示されること", () => { + expect(screen.getByText("command not found: bad-cmd")).toBeInTheDocument(); + }); + + // error styling is applied + it("エラー用のスタイルが適用されること", () => { + const errorOutput = document.querySelector("[class*='outputError']"); + expect(errorOutput).toBeInTheDocument(); + }); + }); + + // Shell result in running state + context("シェルコマンドが実行中の場合", () => { + beforeEach(async () => { + await setupActiveSession(); + const user = userEvent.setup(); + + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "!sleep 30{Enter}"); + + const userMsg = createMessage({ id: "run-u1", sessionID: "s1", role: "user" }); + await sendExtMessage({ + type: "event", + event: { type: "message.updated", properties: { info: userMsg } } as any, + }); + + const shellMsg = createMessage({ id: "run-m1", sessionID: "s1", role: "assistant" }); + await sendExtMessage({ + type: "event", + event: { type: "message.updated", properties: { info: shellMsg } } as any, + }); + + const shellPart = createToolPart("bash", { + messageID: "run-m1", + state: { + status: "running", + title: "sleep 30", + input: { command: "sleep 30" }, + }, + } as any); + await sendExtMessage({ + type: "event", + event: { type: "message.part.updated", properties: { part: shellPart } } as any, + }); + }); + + // spinner is displayed + it("スピナーが表示されること", () => { + const spinner = document.querySelector("[class*='spinner']"); + expect(spinner).toBeInTheDocument(); + }); + }); +}); diff --git a/webview/components/molecules/ShellResultView/ShellResultView.module.css b/webview/components/molecules/ShellResultView/ShellResultView.module.css new file mode 100644 index 0000000..df95ed4 --- /dev/null +++ b/webview/components/molecules/ShellResultView/ShellResultView.module.css @@ -0,0 +1,116 @@ +.root { + border-radius: 4px; + overflow: hidden; + border: 1px solid var(--vscode-panel-border); + margin: 4px 0; + background-color: var(--vscode-terminal-background, #1e1e1e); +} + +.header { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + cursor: pointer; + user-select: none; + font-size: 12px; + background-color: var(--vscode-toolbar-hoverBackground, rgba(90, 93, 94, 0.31)); +} + +.header:hover { + opacity: 0.85; +} + +.headerIcon { + display: flex; + align-items: center; + color: var(--vscode-terminal-ansiYellow, #e5c07b); +} + +.headerLabel { + font-weight: 500; + color: var(--vscode-foreground); +} + +.chevron { + display: inline-flex; + align-items: center; + margin-left: auto; + transition: transform 0.15s ease; + color: var(--vscode-descriptionForeground, #808080); +} + +.expanded { + transform: rotate(90deg); +} + +.terminal { + background-color: var(--vscode-terminal-background, #1e1e1e); + padding: 8px 12px; + font-family: var(--vscode-editor-font-family, "Menlo", "Monaco", "Courier New", monospace); + font-size: 12px; + line-height: 1.5; +} + +.entry { + margin-bottom: 4px; +} + +.entry:last-child { + margin-bottom: 0; +} + +.promptLine { + display: flex; + gap: 6px; + color: var(--vscode-terminal-foreground, #cccccc); +} + +.dollar { + color: var(--vscode-terminal-ansiGreen, #6a9955); + font-weight: bold; + flex-shrink: 0; +} + +.command { + color: var(--vscode-terminal-foreground, #cccccc); + word-break: break-all; +} + +.running { + padding: 4px 0; + color: var(--vscode-terminal-ansiBrightBlack, #808080); +} + +.spinner { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.output { + margin: 4px 0 0; + padding: 0; + white-space: pre-wrap; + word-break: break-all; + color: var(--vscode-terminal-foreground, #cccccc); + font-family: inherit; + font-size: inherit; + line-height: inherit; + background-color: inherit; + border: none; + overflow-x: auto; + max-height: 400px; + overflow-y: auto; +} + +.outputError { + color: var(--vscode-terminal-ansiRed, #f44747); +} diff --git a/webview/components/molecules/ShellResultView/ShellResultView.tsx b/webview/components/molecules/ShellResultView/ShellResultView.tsx new file mode 100644 index 0000000..758da45 --- /dev/null +++ b/webview/components/molecules/ShellResultView/ShellResultView.tsx @@ -0,0 +1,68 @@ +import type { ToolPart } from "@opencode-ai/sdk"; +import { useMemo, useState } from "react"; +import { useLocale } from "../../../locales"; +import { ChevronRightIcon, SpinnerIcon, TerminalIcon } from "../../atoms/icons"; +import styles from "./ShellResultView.module.css"; + +type Props = { + parts: ToolPart[]; +}; + +/** + * ユーザーが ! プレフィクスで実行したシェルコマンドの結果をターミナル風に表示する。 + * 通常の ToolPartView(折りたたみカード)ではなく、コマンドと出力を一体で見せる。 + */ +export function ShellResultView({ parts }: Props) { + const t = useLocale(); + const [expanded, setExpanded] = useState(true); + + const entries = useMemo(() => { + return parts + .filter((p) => p.type === "tool") + .map((p) => { + const { state } = p; + const input = (state.status !== "pending" ? state.input : null) as Record | null; + const command = (input?.command as string) ?? ""; + const isRunning = state.status === "running" || state.status === "pending"; + const isError = state.status === "error"; + const output = state.status === "completed" ? state.output : null; + const error = state.status === "error" ? state.error : null; + return { command, output, error, isRunning, isError }; + }); + }, [parts]); + + return ( +
+
setExpanded((s) => !s)}> + + + + {t["shell.title"]} + + + +
+ {expanded && ( +
+ {entries.map((entry, i) => ( +
+
+ $ + {entry.command} +
+ {entry.isRunning && ( +
+ +
+ )} + {entry.output &&
{entry.output}
} + {entry.isError && entry.error && ( +
{entry.error}
+ )} +
+ ))} +
+ )} +
+ ); +} diff --git a/webview/components/molecules/ShellResultView/index.ts b/webview/components/molecules/ShellResultView/index.ts new file mode 100644 index 0000000..0e3e8cf --- /dev/null +++ b/webview/components/molecules/ShellResultView/index.ts @@ -0,0 +1 @@ +export { ShellResultView } from "./ShellResultView"; diff --git a/webview/components/molecules/TextPartView/TextPartView.tsx b/webview/components/molecules/TextPartView/TextPartView.tsx index 853124a..655a8a2 100644 --- a/webview/components/molecules/TextPartView/TextPartView.tsx +++ b/webview/components/molecules/TextPartView/TextPartView.tsx @@ -19,5 +19,5 @@ export function TextPartView({ part }: Props) { }, [part.text]); // biome-ignore lint/security/noDangerouslySetInnerHtml: DOMPurify でサニタイズ済みの HTML を描画する - return ; + return ; } 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"]} +
+ )}