Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/chat-view-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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);
Expand Down
14 changes: 14 additions & 0 deletions src/opencode-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,20 @@ export class OpenCodeConnection {
});
}

// --- Shell API ---

async executeShell(
sessionId: string,
command: string,
model?: { providerID: string; modelID: string },
): Promise<void> {
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<string, string> }> {
Expand Down
19 changes: 19 additions & 0 deletions webview/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -279,6 +297,7 @@ export function App() {
{msg.latestTodos.length > 0 && <TodoHeader todos={msg.latestTodos} />}
<InputArea
onSend={handleSend}
onShellExecute={handleShellExecute}
onAbort={handleAbort}
isBusy={session.sessionBusy}
providers={prov.providers}
Expand Down
202 changes: 202 additions & 0 deletions webview/__tests__/components/molecules/ShellResultView.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { ShellResultView } from "../../../components/molecules/ShellResultView";
import { createToolPart } from "../../factories";

describe("ShellResultView", () => {
// --- 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(<ShellResultView parts={parts} />);
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(<ShellResultView parts={parts} />);
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(<ShellResultView parts={parts} />);
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(<ShellResultView parts={parts} />);
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(<ShellResultView parts={parts} />);
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(<ShellResultView parts={parts} />);
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(<ShellResultView parts={parts} />);
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(<ShellResultView parts={parts} />);
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(<ShellResultView parts={parts} />);
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(<ShellResultView parts={parts} />);
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(<ShellResultView parts={parts} />);
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(<ShellResultView parts={parts} />);
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(<ShellResultView parts={parts} />);
expect(screen.getAllByText("$")).toHaveLength(2);
});
});

// --- Empty parts ---
context("パーツが空の場合", () => {
// renders header without error
it("ヘッダーのみ表示してエラーにならないこと", () => {
render(<ShellResultView parts={[]} />);
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(<ShellResultView parts={parts} />);
expect(screen.getAllByText("$")).toHaveLength(1);
});
});
});
69 changes: 62 additions & 7 deletions webview/__tests__/components/organisms/MessageItem.test.tsx
Original file line number Diff line number Diff line change
@@ -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<AppContextValue> = {}) {
const contextValue = {
isShellMessage: () => false,
...overrides,
} as unknown as AppContextValue;
return function Wrapper({ children }: { children: ReactNode }) {
return <AppContextProvider value={contextValue}>{children}</AppContextProvider>;
};
}

describe("MessageItem", () => {
const wrapper = createContextWrapper();
const defaultProps = {
activeSessionId: "session-1",
permissions: new Map(),
Expand All @@ -20,19 +34,19 @@ describe("MessageItem", () => {

// renders as user message
it("ユーザーメッセージとしてレンダリングすること", () => {
const { container } = render(<MessageItem {...defaultProps} message={userMsg} />);
const { container } = render(<MessageItem {...defaultProps} message={userMsg} />, { wrapper });
expect(container.querySelector(".user")).toBeInTheDocument();
});

// renders user text
it("ユーザーテキストを表示すること", () => {
const { container } = render(<MessageItem {...defaultProps} message={userMsg} />);
const { container } = render(<MessageItem {...defaultProps} message={userMsg} />, { wrapper });
expect(container.querySelector(".content")?.textContent).toBe("Hello");
});

// shows edit icon
it("編集アイコンを表示すること", () => {
const { container } = render(<MessageItem {...defaultProps} message={userMsg} />);
const { container } = render(<MessageItem {...defaultProps} message={userMsg} />, { wrapper });
expect(container.querySelector(".editIcon")).toBeInTheDocument();
});
});
Expand All @@ -46,7 +60,7 @@ describe("MessageItem", () => {

// enters edit mode
it("編集モードに入ること", () => {
const { container } = render(<MessageItem {...defaultProps} message={userMsg} />);
const { container } = render(<MessageItem {...defaultProps} message={userMsg} />, { wrapper });
fireEvent.click(container.querySelector(".userBubble")!);
expect(container.querySelector(".editTextarea")).toBeInTheDocument();
});
Expand All @@ -61,14 +75,55 @@ describe("MessageItem", () => {

// renders as assistant message
it("アシスタントメッセージとしてレンダリングすること", () => {
const { container } = render(<MessageItem {...defaultProps} message={assistantMsg} />);
const { container } = render(<MessageItem {...defaultProps} message={assistantMsg} />, { wrapper });
expect(container.querySelector(".assistant")).toBeInTheDocument();
});

// renders text and tool parts
it("テキストとツールパートをレンダリングすること", () => {
const { container } = render(<MessageItem {...defaultProps} message={assistantMsg} />);
const { container } = render(<MessageItem {...defaultProps} message={assistantMsg} />, { 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(<MessageItem {...defaultProps} message={shellMsg} />, { wrapper: shellWrapper });
expect(screen.getByText("Shell")).toBeInTheDocument();
});

// does not render the synthetic text part
it("テキストパートを表示しないこと", () => {
render(<MessageItem {...defaultProps} message={shellMsg} />, { 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(<MessageItem {...defaultProps} message={shellUserMsg} />, { wrapper: shellWrapper });
expect(container.querySelector("[class*='userBubble']")).not.toBeInTheDocument();
});
});
});
Loading