Open a kandev-controlled compose box above the terminal to type a follow-up message.
Press Enter to send, Shift+Enter for a newline.
+
The text is delivered straight to the CLI agent's stdin — pending review comments
(if any) are prepended automatically.
@@ -233,6 +256,15 @@ function ChatToggleButton({
);
}
+function PassthroughChatShortcutHint({ shortcut }: { shortcut: KeyboardShortcut }) {
+ if (isUnboundShortcut(shortcut)) return null;
+ return (
+
+ Shortcut: {formatShortcut(shortcut)} toggles this chat input.
+
+ );
+}
+
function commentsToggleClassName(count: number, commentsOpen: boolean): string {
// Vivid amber when there are pending comments so the user sees "something
// to do" — washes back to plain outline once they're cleared / sent.
@@ -321,20 +353,6 @@ function CommentsTooltipBody({ commentsOpen, count }: { commentsOpen: boolean; c
);
}
-function PendingCommentsHint({ count }: { count: number }) {
- return (
-
-
-
- {count} pending review comment{count === 1 ? "" : "s"} will be attached to this message.
-
-
- );
-}
-
function CommentsPanel({
comments,
openFile,
@@ -471,6 +489,7 @@ type StatusRowProps = {
isMoving: boolean;
showProceed: boolean;
composerOpen: boolean;
+ focusShortcut: KeyboardShortcut;
onToggleComposer: () => void;
commentsOpen: boolean;
onToggleComments: () => void;
@@ -484,6 +503,7 @@ function PassthroughStatusRow({
isMoving,
showProceed,
composerOpen,
+ focusShortcut,
onToggleComposer,
commentsOpen,
onToggleComments,
@@ -494,7 +514,11 @@ function PassthroughStatusRow({
data-testid="passthrough-status-row"
className="flex flex-shrink-0 items-center gap-1.5 border-t bg-card px-2 py-1 text-xs text-muted-foreground"
>
-
+
{
- afterEach(() => {
- cleanup();
- });
-
- it("submits trimmed text on Enter and clears the textarea", async () => {
- const onSubmit = vi.fn().mockResolvedValue(undefined);
- render();
-
- const textarea = screen.getByTestId(TEXTAREA_TID) as HTMLTextAreaElement;
- fireEvent.change(textarea, { target: { value: " hello agent " } });
- fireEvent.keyDown(textarea, { key: "Enter" });
-
- await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
- expect(onSubmit).toHaveBeenCalledWith("hello agent");
- await waitFor(() => expect(textarea.value).toBe(""));
- });
-
- it("submits when the send button is clicked", async () => {
- const onSubmit = vi.fn().mockResolvedValue(undefined);
- render();
-
- fireEvent.change(screen.getByTestId(TEXTAREA_TID), {
- target: { value: "ping" },
- });
- fireEvent.click(screen.getByTestId(SUBMIT_TID));
-
- await waitFor(() => expect(onSubmit).toHaveBeenCalledWith("ping"));
- });
-
- it("Shift+Enter inserts a newline and does not submit", () => {
- const onSubmit = vi.fn().mockResolvedValue(undefined);
- render();
-
- const textarea = screen.getByTestId(TEXTAREA_TID);
- fireEvent.change(textarea, { target: { value: "line1" } });
- fireEvent.keyDown(textarea, { key: "Enter", shiftKey: true });
-
- expect(onSubmit).not.toHaveBeenCalled();
- });
-
- it("does not submit empty or whitespace-only input", () => {
- const onSubmit = vi.fn().mockResolvedValue(undefined);
- render();
-
- const textarea = screen.getByTestId(TEXTAREA_TID);
- fireEvent.keyDown(textarea, { key: "Enter" });
- expect(onSubmit).not.toHaveBeenCalled();
-
- fireEvent.change(textarea, { target: { value: " \n " } });
- fireEvent.keyDown(textarea, { key: "Enter" });
- expect(onSubmit).not.toHaveBeenCalled();
-
- const submitBtn = screen.getByTestId(SUBMIT_TID) as HTMLButtonElement;
- expect(submitBtn.disabled).toBe(true);
- });
-
- it("preserves the typed text when onSubmit rejects so the user can retry", async () => {
- const onSubmit = vi.fn().mockRejectedValue(new Error("network down"));
- render();
-
- const textarea = screen.getByTestId(TEXTAREA_TID) as HTMLTextAreaElement;
- fireEvent.change(textarea, { target: { value: "important request" } });
- fireEvent.keyDown(textarea, { key: "Enter" });
-
- await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
- // Composer must NOT clear on failure — losing typed text on a flaky WS
- // disconnect was a real UX regression (issue #989 review feedback).
- expect(textarea.value).toBe("important request");
- // Composer must re-enable so the user can resubmit.
- await waitFor(() => expect(textarea.disabled).toBe(false));
- });
-
- it("disables the composer while a submission is in flight", async () => {
- let resolveSubmit!: () => void;
- const submitPromise = new Promise((resolve) => {
- resolveSubmit = resolve;
- });
- const onSubmit = vi.fn().mockReturnValue(submitPromise);
-
- render();
- const textarea = screen.getByTestId(TEXTAREA_TID) as HTMLTextAreaElement;
- const submitBtn = screen.getByTestId(SUBMIT_TID) as HTMLButtonElement;
- fireEvent.change(textarea, { target: { value: "hi" } });
- fireEvent.keyDown(textarea, { key: "Enter" });
-
- await waitFor(() => expect(textarea.disabled).toBe(true));
- expect(submitBtn.disabled).toBe(true);
-
- resolveSubmit();
- await waitFor(() => expect(textarea.disabled).toBe(false));
- expect(textarea.value).toBe("");
- });
-});
diff --git a/apps/web/components/task/session-context-summary.test.ts b/apps/web/components/task/session-context-summary.test.ts
new file mode 100644
index 0000000000..0f34bc5c35
--- /dev/null
+++ b/apps/web/components/task/session-context-summary.test.ts
@@ -0,0 +1,66 @@
+import { describe, expect, it, vi } from "vitest";
+import { applySummarizeSessionResult } from "./session-context-summary";
+
+describe("applySummarizeSessionResult", () => {
+ it("sanitizes unsafe characters before writing the prompt", () => {
+ const promptRef = { current: { value: "" } as HTMLTextAreaElement };
+ const setContextValue = vi.fn();
+ const setHasPrompt = vi.fn();
+ const toast = vi.fn();
+
+ applySummarizeSessionResult({
+ result: { summary: "line1\r\n\nline2" },
+ promptRef,
+ setContextValue,
+ setHasPrompt,
+ toast,
+ });
+
+ expect(promptRef.current.value).toBe("line1\n unsafe \nline2");
+ expect(setHasPrompt).toHaveBeenCalledWith(true);
+ expect(toast).not.toHaveBeenCalled();
+ });
+
+ it("treats an empty summary string as a successful summary", () => {
+ const promptRef = { current: { value: "existing" } as HTMLTextAreaElement };
+ const setContextValue = vi.fn();
+ const setHasPrompt = vi.fn();
+ const toast = vi.fn();
+
+ applySummarizeSessionResult({
+ result: { summary: "" },
+ promptRef,
+ setContextValue,
+ setHasPrompt,
+ toast,
+ });
+
+ expect(promptRef.current.value).toBe("");
+ expect(setHasPrompt).toHaveBeenCalledWith(true);
+ expect(toast).not.toHaveBeenCalled();
+ });
+
+ it("resets context and prompt state when summary is null", () => {
+ const promptRef = { current: { value: "stale prompt" } as HTMLTextAreaElement };
+ const setContextValue = vi.fn();
+ const setHasPrompt = vi.fn();
+ const toast = vi.fn();
+
+ applySummarizeSessionResult({
+ result: { summary: null, error: "connection refused" },
+ promptRef,
+ setContextValue,
+ setHasPrompt,
+ toast,
+ });
+
+ expect(setContextValue).toHaveBeenCalledWith("blank");
+ expect(promptRef.current.value).toBe("");
+ expect(setHasPrompt).toHaveBeenCalledWith(false);
+ expect(toast).toHaveBeenCalledWith({
+ title: "Summarize failed",
+ description: "connection refused",
+ variant: "error",
+ });
+ });
+});
diff --git a/apps/web/components/task/session-context-summary.ts b/apps/web/components/task/session-context-summary.ts
new file mode 100644
index 0000000000..0ca6dd1019
--- /dev/null
+++ b/apps/web/components/task/session-context-summary.ts
@@ -0,0 +1,47 @@
+import type { RefObject } from "react";
+import type { SummarizeSessionResult } from "@/hooks/use-summarize-session";
+
+export type SummaryToastFn = (opts: {
+ title: string;
+ description?: string;
+ variant?: "error" | "default";
+}) => void;
+
+export function sanitizePromptText(value: string): string {
+ return value.replace(/\r/g, "").replace(/[<>]/g, " ");
+}
+
+export function applySummarizeSessionResult({
+ result,
+ promptRef,
+ setContextValue,
+ setHasPrompt,
+ toast,
+}: {
+ result: SummarizeSessionResult;
+ promptRef: RefObject;
+ setContextValue: (v: string) => void;
+ setHasPrompt: (v: boolean) => void;
+ toast: SummaryToastFn;
+}) {
+ if (result.summary === null) {
+ setContextValue("blank");
+ if (promptRef.current) {
+ promptRef.current.value = "";
+ }
+ setHasPrompt(false);
+ toast({
+ title: "Summarize failed",
+ description:
+ result.error ??
+ "Could not generate a summary. Check that the summarize utility agent is configured and enabled in settings.",
+ variant: "error",
+ });
+ return;
+ }
+
+ if (promptRef.current) {
+ promptRef.current.value = sanitizePromptText(result.summary);
+ setHasPrompt(true);
+ }
+}
diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts
index 35eb276c5f..0b6142145d 100644
--- a/apps/web/e2e/helpers/api-client.ts
+++ b/apps/web/e2e/helpers/api-client.ts
@@ -1222,17 +1222,20 @@ export class ApiClient {
* Used by tests that need a second session on a task with an existing
* environment (multi-session reuse, recovery scenarios).
*/
- async launchSession(payload: {
- task_id: string;
- agent_profile_id: string;
- executor_id?: string;
- executor_profile_id?: string;
- prompt: string;
- intent?: string;
- workflow_step_id?: string;
- auto_start?: boolean;
- }): Promise<{ session_id: string; agent_execution_id: string; state: string }> {
- return this.wsRequest("session.launch", payload);
+ async launchSession(
+ payload: {
+ task_id: string;
+ agent_profile_id: string;
+ executor_id?: string;
+ executor_profile_id?: string;
+ prompt: string;
+ intent?: string;
+ workflow_step_id?: string;
+ auto_start?: boolean;
+ },
+ timeoutMs = 30_000,
+ ): Promise<{ session_id: string; agent_execution_id: string; state: string }> {
+ return this.wsRequest("session.launch", payload, timeoutMs);
}
/** Stop a running session via WS `session.stop` — same path the UI uses. */
diff --git a/apps/web/e2e/helpers/session-store.ts b/apps/web/e2e/helpers/session-store.ts
index d270c0dcc4..333fa46e4e 100644
--- a/apps/web/e2e/helpers/session-store.ts
+++ b/apps/web/e2e/helpers/session-store.ts
@@ -4,6 +4,7 @@ type E2EStoreWindow = Window & {
__KANDEV_E2E_STORE__?: {
getState: () => {
taskSessions: { items: Record> };
+ setAvailableCommands: (sessionId: string, commands: AvailableCommand[]) => void;
};
setState: (
updater: (state: {
@@ -13,6 +14,12 @@ type E2EStoreWindow = Window & {
};
};
+type AvailableCommand = {
+ name: string;
+ description?: string;
+ input_hint?: string;
+};
+
/**
* Simulate a lean session-list / partial WS update: preserve `is_passthrough`
* but drop `agent_profile_snapshot` from the client store.
@@ -42,3 +49,20 @@ export async function stripSessionProfileSnapshot(page: Page, sessionId: string)
}
}, sessionId);
}
+
+export async function seedAvailableCommands(
+ page: Page,
+ sessionId: string,
+ commands: AvailableCommand[],
+): Promise {
+ await page.evaluate(
+ ({ sid, commandList }) => {
+ const store = (window as E2EStoreWindow).__KANDEV_E2E_STORE__;
+ if (!store) {
+ throw new Error("E2E store bridge missing — is __KANDEV_E2E_EXPOSE_STORE__ set?");
+ }
+ store.getState().setAvailableCommands(sid, commandList);
+ },
+ { sid: sessionId, commandList: commands },
+ );
+}
diff --git a/apps/web/e2e/helpers/ssh.ts b/apps/web/e2e/helpers/ssh.ts
index f908486423..dd446dd612 100644
--- a/apps/web/e2e/helpers/ssh.ts
+++ b/apps/web/e2e/helpers/ssh.ts
@@ -284,9 +284,19 @@ function generateKeypair(identityFile: string): void {
* launch. Throws when the command exits non-zero.
*/
export function execInContainer(handle: SSHServerHandle, argv: string[]): string {
- const res = spawnSync("docker", ["exec", handle.containerName, ...argv], {
+ let res = spawnSync("docker", ["exec", handle.containerName, ...argv], {
encoding: "utf8",
});
+ const transientUpgradeFailure =
+ res.status !== 0 &&
+ (res.stderr || res.stdout).includes("unable to upgrade to tcp") &&
+ (res.stderr || res.stdout).includes("409");
+ if (transientUpgradeFailure) {
+ sleep(500);
+ res = spawnSync("docker", ["exec", handle.containerName, ...argv], {
+ encoding: "utf8",
+ });
+ }
if (res.status !== 0) {
throw new Error(
`docker exec ${argv.join(" ")} failed (status=${res.status}): ${res.stderr || res.stdout}`,
diff --git a/apps/web/e2e/tests/cli-mode/comment-run-passthrough.spec.ts b/apps/web/e2e/tests/cli-mode/comment-run-passthrough.spec.ts
index 4b062fdeac..e3da0d227d 100644
--- a/apps/web/e2e/tests/cli-mode/comment-run-passthrough.spec.ts
+++ b/apps/web/e2e/tests/cli-mode/comment-run-passthrough.spec.ts
@@ -91,9 +91,9 @@ test.describe("CLI mode: message.add submits to PTY", () => {
await expect(testPage.getByTestId("passthrough-composer")).toBeVisible({ timeout: 5_000 });
const followUp = "passthrough message.add submit e2e";
- const textarea = testPage.getByTestId("passthrough-composer-textarea");
- await textarea.fill(followUp);
- await textarea.press("Enter");
+ const editor = testPage.getByTestId("passthrough-composer").locator(".tiptap.ProseMirror");
+ await editor.fill(followUp);
+ await testPage.getByTestId("submit-message-button").click();
await expect(testPage.getByTestId("passthrough-composer")).toBeHidden({ timeout: 10_000 });
await session.expectPassthroughHasText(`Processed: ${followUp}`, 20_000);
diff --git a/apps/web/e2e/tests/cli-mode/mobile-passthrough-composer.spec.ts b/apps/web/e2e/tests/cli-mode/mobile-passthrough-composer.spec.ts
new file mode 100644
index 0000000000..a3dd046479
--- /dev/null
+++ b/apps/web/e2e/tests/cli-mode/mobile-passthrough-composer.spec.ts
@@ -0,0 +1,139 @@
+// Filename starts with "mobile-" so this runs in the mobile-chrome project.
+import { test, expect } from "../../fixtures/test-base";
+import type { ApiClient } from "../../helpers/api-client";
+import { seedAvailableCommands } from "../../helpers/session-store";
+import { SessionPage } from "../../pages/session-page";
+
+async function createPassthroughProfile(apiClient: ApiClient, name: string): Promise {
+ const { agents } = await apiClient.listAgents();
+ if (agents.length === 0) throw new Error("no agents registered in this e2e profile");
+ const profile = await apiClient.createAgentProfile(agents[0].id, name, {
+ model: "mock-fast",
+ auto_approve: true,
+ cli_passthrough: true,
+ });
+ return profile.id;
+}
+
+async function openMobilePassthroughTask(
+ testPage: import("@playwright/test").Page,
+ apiClient: ApiClient,
+ seedData: {
+ workspaceId: string;
+ workflowId: string;
+ startStepId: string;
+ repositoryId: string;
+ },
+ profileName: string,
+ taskTitle: string,
+) {
+ const profileId = await createPassthroughProfile(apiClient, profileName);
+ const task = await apiClient.createTaskWithAgent(seedData.workspaceId, taskTitle, profileId, {
+ description: "initial prompt",
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ repository_ids: [seedData.repositoryId],
+ });
+ if (!task.session_id) throw new Error("expected passthrough task to start a session");
+
+ await testPage.goto(`/t/${task.id}`);
+ const session = new SessionPage(testPage);
+ await session.waitForPassthroughLoad(20_000);
+ await session.waitForPassthroughLoaded(20_000);
+ await session.expectPassthroughHasText("Processed:", 20_000);
+ return { task, session };
+}
+
+test.describe("mobile CLI mode: passthrough composer", () => {
+ test("slash remains literal and only passthrough composer controls are available", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(90_000);
+
+ const profileId = await createPassthroughProfile(apiClient, "Mobile CLI Commands");
+ const task = await apiClient.createTaskWithAgent(
+ seedData.workspaceId,
+ "Mobile CLI Commands Task",
+ profileId,
+ {
+ description: "initial prompt",
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ repository_ids: [seedData.repositoryId],
+ },
+ );
+ if (!task.session_id) throw new Error("expected passthrough task to start a session");
+
+ await testPage.goto(`/t/${task.id}`);
+ const session = new SessionPage(testPage);
+ await session.waitForPassthroughLoad(20_000);
+ await session.waitForPassthroughLoaded(20_000);
+ await session.expectPassthroughHasText("Processed:", 20_000);
+ await seedAvailableCommands(testPage, task.session_id, [
+ { name: "slow", description: "Run slowly" },
+ { name: "error", description: "Trigger an error" },
+ ]);
+
+ await testPage.getByTestId("passthrough-toggle-composer").tap();
+ const composer = testPage.getByTestId("passthrough-composer");
+ const editor = composer.locator(".tiptap.ProseMirror");
+ await expect(editor).toBeVisible({ timeout: 5_000 });
+ await expect(testPage.getByTestId("plan-mode-toggle-button")).toBeVisible();
+ await expect(testPage.getByTestId("chat-context-button")).toBeVisible();
+ await expect(testPage.getByTestId("chat-attachments-button")).toBeVisible();
+ await expect(testPage.getByTestId("toolbar-item-mcp")).toHaveCount(0);
+ await expect(testPage.getByTestId("toolbar-item-mode")).toHaveCount(0);
+ await expect(testPage.getByTestId("toolbar-item-model")).toHaveCount(0);
+ await expect(testPage.getByTestId("toolbar-item-reset-context")).toHaveCount(0);
+ await expect(testPage.getByTestId("toolbar-item-enhance")).toHaveCount(0);
+
+ await editor.fill("/s");
+
+ await expect(testPage.getByRole("listbox", { name: "Command suggestions" })).toHaveCount(0);
+ await expect(editor).toHaveText("/s");
+ });
+
+ test("context prompt selection creates a chip on mobile", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(90_000);
+
+ const promptName = `mobile-pt-prompt-${Date.now()}`;
+ const promptContent = "MOBILE_PASSTHROUGH_PROMPT_MARKER";
+ await apiClient.createPrompt(promptName, promptContent);
+ const { session } = await openMobilePassthroughTask(
+ testPage,
+ apiClient,
+ seedData,
+ "Mobile CLI Context",
+ "Mobile CLI Context Task",
+ );
+
+ await testPage.getByTestId("passthrough-toggle-composer").tap();
+ const composer = testPage.getByTestId("passthrough-composer");
+ const editor = composer.locator(".tiptap.ProseMirror");
+ await expect(editor).toBeVisible({ timeout: 5_000 });
+
+ await testPage.getByTestId("chat-context-button").tap();
+ const searchInput = testPage.getByPlaceholder("Search files and prompts...");
+ await expect(searchInput).toBeVisible({ timeout: 5_000 });
+ await searchInput.fill(promptName);
+ await testPage.getByText(promptName, { exact: true }).tap();
+
+ await expect(composer.getByText(promptName, { exact: true })).toBeVisible({
+ timeout: 5_000,
+ });
+
+ await editor.fill("mobile context e2e");
+ await testPage.getByTestId("submit-message-button").tap();
+ await expect(composer).toBeHidden({ timeout: 10_000 });
+
+ await session.expectPassthroughHasText("mobile context e2e", 15_000);
+ await session.expectPassthroughHasText("CONTEXT PROMPTS", 15_000);
+ await session.expectPassthroughHasText(promptContent, 15_000);
+ });
+});
diff --git a/apps/web/e2e/tests/cli-mode/passthrough-toolbar.spec.ts b/apps/web/e2e/tests/cli-mode/passthrough-toolbar.spec.ts
index 9b6ed92000..a8df5cdfc7 100644
--- a/apps/web/e2e/tests/cli-mode/passthrough-toolbar.spec.ts
+++ b/apps/web/e2e/tests/cli-mode/passthrough-toolbar.spec.ts
@@ -1,8 +1,19 @@
import { expect } from "@playwright/test";
+import fs from "node:fs";
+import path from "node:path";
import { test } from "../../fixtures/test-base";
import { KanbanPage } from "../../pages/kanban-page";
import { SessionPage } from "../../pages/session-page";
import type { ApiClient } from "../../helpers/api-client";
+import { seedAvailableCommands } from "../../helpers/session-store";
+import { GitHelper, makeGitEnv } from "../../helpers/git-helper";
+
+type PersistedAttachment = {
+ type?: string;
+ name?: string;
+ mime_type?: string;
+ delivery_mode?: string;
+};
/**
* CLI-mode parity: PassthroughToolbar mounts above the PTY in passthrough
@@ -46,6 +57,56 @@ test.describe("CLI mode: passthrough toolbar", () => {
await session.waitForPassthroughLoaded(20_000);
}
+ function passthroughEditor(testPage: import("@playwright/test").Page) {
+ return testPage.getByTestId("passthrough-composer").locator(".tiptap.ProseMirror");
+ }
+
+ function passthroughComposer(testPage: import("@playwright/test").Page) {
+ return testPage.getByTestId("passthrough-composer");
+ }
+
+ async function waitForPassthroughPromptable(
+ apiClient: ApiClient,
+ taskId: string,
+ sessionId: string,
+ ) {
+ await expect
+ .poll(
+ async () => {
+ const { sessions } = await apiClient.listTaskSessions(taskId);
+ return sessions.find((s) => s.id === sessionId)?.state ?? null;
+ },
+ { timeout: 20_000, message: "Wait for passthrough session to accept a follow-up" },
+ )
+ .toBe("WAITING_FOR_INPUT");
+ }
+
+ async function waitForPersistedAttachment(
+ apiClient: ApiClient,
+ sessionId: string,
+ content: string,
+ ): Promise {
+ await expect
+ .poll(
+ async () => {
+ const { messages } = await apiClient.listSessionMessages(sessionId);
+ const message = messages.find(
+ (m) => m.author_type === "user" && m.content.includes(content),
+ );
+ const attachments = message?.metadata?.attachments;
+ return Array.isArray(attachments) ? attachments.length : 0;
+ },
+ { timeout: 15_000, message: `Wait for persisted attachment on "${content}"` },
+ )
+ .toBeGreaterThan(0);
+
+ const { messages } = await apiClient.listSessionMessages(sessionId);
+ const message = messages.find((m) => m.author_type === "user" && m.content.includes(content));
+ const attachments = message?.metadata?.attachments;
+ if (!Array.isArray(attachments)) throw new Error("Persisted user message has no attachments");
+ return attachments[0] as PersistedAttachment;
+ }
+
test("toolbar renders for passthrough sessions on the task page", async ({
testPage,
apiClient,
@@ -70,6 +131,25 @@ test.describe("CLI mode: passthrough toolbar", () => {
await expect(testPage.getByTestId("passthrough-toggle-composer")).toBeVisible({
timeout: 5_000,
});
+
+ const chatToggle = testPage.getByTestId("passthrough-toggle-composer");
+ await chatToggle.hover();
+ const tooltip = testPage.getByRole("tooltip").filter({ hasText: "Shortcut:" });
+ await expect(tooltip).toContainText(/Ctrl\+Shift\+Y|Cmd\+Shift\+Y/);
+
+ await chatToggle.click();
+ await expect(testPage.getByTestId("passthrough-composer")).toBeVisible({ timeout: 5_000 });
+ await expect(testPage.getByTestId("plan-mode-toggle-button")).toBeVisible();
+ await expect(testPage.getByTestId("chat-attachments-button")).toBeVisible();
+ await expect(testPage.getByTestId("chat-context-button")).toBeVisible();
+ await expect(testPage.getByTestId("toolbar-item-mcp")).toHaveCount(0);
+ await expect(testPage.getByTestId("toolbar-item-mode")).toHaveCount(0);
+ await expect(testPage.getByTestId("toolbar-item-model")).toHaveCount(0);
+ await expect(testPage.getByTestId("toolbar-item-reset-context")).toHaveCount(0);
+ await expect(testPage.getByTestId("toolbar-item-enhance")).toHaveCount(0);
+
+ await chatToggle.click();
+ await expect(testPage.getByTestId("passthrough-composer")).toBeHidden({ timeout: 5_000 });
});
test("composer toggle + send forwards message to the agent", async ({
@@ -99,9 +179,9 @@ test.describe("CLI mode: passthrough toolbar", () => {
await testPage.getByTestId("passthrough-toggle-composer").click();
await expect(testPage.getByTestId("passthrough-composer")).toBeVisible({ timeout: 5_000 });
- const textarea = testPage.getByTestId("passthrough-composer-textarea");
- await textarea.fill("hello from e2e");
- await textarea.press("Enter");
+ const editor = passthroughEditor(testPage);
+ await editor.fill("hello from e2e");
+ await testPage.getByTestId("submit-message-button").click();
// The composer closes on successful send
await expect(testPage.getByTestId("passthrough-composer")).toBeHidden({ timeout: 10_000 });
@@ -110,6 +190,267 @@ test.describe("CLI mode: passthrough toolbar", () => {
await session.expectPassthroughHasText("hello from e2e", 15_000);
});
+ test("composer keeps slash literal and does not show command suggestions", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ const profileId = await createPassthroughProfile(apiClient, "CLI Toolbar Commands");
+
+ const task = await apiClient.createTaskWithAgent(
+ seedData.workspaceId,
+ "Toolbar Commands Task",
+ profileId,
+ {
+ description: "initial prompt",
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ repository_ids: [seedData.repositoryId],
+ },
+ );
+ if (!task.session_id) throw new Error("expected passthrough task to start a session");
+
+ const kanban = new KanbanPage(testPage);
+ await kanban.goto();
+
+ const session = new SessionPage(testPage);
+ await openTaskAndWaitForTerminal(testPage, kanban, session, "Toolbar Commands Task");
+ await session.expectPassthroughHasText("Processed:", 20_000);
+ await seedAvailableCommands(testPage, task.session_id, [
+ { name: "slow", description: "Run slowly" },
+ { name: "error", description: "Trigger an error" },
+ ]);
+
+ await testPage.getByTestId("passthrough-toggle-composer").click();
+ const editor = passthroughEditor(testPage);
+ await expect(editor).toBeVisible({ timeout: 5_000 });
+
+ await editor.fill("/s");
+
+ await expect(testPage.getByRole("listbox", { name: "Command suggestions" })).toHaveCount(0);
+ await expect(editor).toHaveText("/s");
+ });
+
+ test("supports @ prompt/file lookup chips and sends context to passthrough stdin", async ({
+ testPage,
+ apiClient,
+ seedData,
+ backend,
+ }) => {
+ test.setTimeout(90_000);
+
+ const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
+ const git = new GitHelper(repoDir, makeGitEnv(backend.tmpDir));
+ const contextFile = `ctx/passthrough-context-${Date.now()}.ts`;
+ git.createFile(contextFile, "export const passthroughContextMarker = true;\n");
+ git.stageAll();
+ git.commit("seed passthrough context lookup");
+
+ const promptName = `pt-prompt-${Date.now()}`;
+ const promptContent = "PASSTHROUGH_PROMPT_CONTEXT_MARKER";
+ await apiClient.createPrompt(promptName, promptContent);
+ const profileId = await createPassthroughProfile(apiClient, "CLI Toolbar Context");
+
+ await apiClient.createTaskWithAgent(seedData.workspaceId, "Toolbar Context Task", profileId, {
+ description: "initial prompt",
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ repository_ids: [seedData.repositoryId],
+ });
+
+ const kanban = new KanbanPage(testPage);
+ await kanban.goto();
+
+ const session = new SessionPage(testPage);
+ await openTaskAndWaitForTerminal(testPage, kanban, session, "Toolbar Context Task");
+ await session.expectPassthroughHasText("Processed:", 20_000);
+
+ await testPage.getByTestId("passthrough-toggle-composer").click();
+ const editor = passthroughEditor(testPage);
+ await expect(editor).toBeVisible({ timeout: 5_000 });
+
+ await editor.click();
+ await editor.pressSequentially(`context lookup e2e @${promptName}`);
+ await expect(testPage.getByText("Mention tasks, files, prompts")).toBeVisible({
+ timeout: 5_000,
+ });
+ await testPage.getByRole("button", { name: new RegExp(promptName) }).click();
+ await expect(editor).toContainText(promptName);
+
+ const contextFileName = path.basename(contextFile);
+ await editor.pressSequentially(` @${contextFileName}`);
+ await expect(testPage.getByText("Mention tasks, files, prompts")).toBeVisible({
+ timeout: 10_000,
+ });
+ await testPage.getByRole("button", { name: new RegExp(contextFileName) }).click();
+ await expect(editor).toContainText(contextFileName);
+
+ await testPage.getByTestId("submit-message-button").click();
+ await expect(passthroughComposer(testPage)).toBeHidden({ timeout: 10_000 });
+
+ await session.expectPassthroughHasText("context lookup e2e", 15_000);
+ await session.expectPassthroughHasText("CONTEXT PROMPTS", 15_000);
+ await session.expectPassthroughHasText(promptContent, 15_000);
+ await session.expectPassthroughHasText("CONTEXT FILES", 15_000);
+ await session.expectPassthroughHasText(contextFile, 15_000);
+ });
+
+ test("expands @ Plan context into passthrough stdin instead of sending a literal mention", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(90_000);
+
+ const profileId = await createPassthroughProfile(apiClient, "CLI Toolbar Plan Context");
+ const task = await apiClient.createTaskWithAgent(
+ seedData.workspaceId,
+ "Toolbar Plan Context Task",
+ profileId,
+ {
+ description: "initial prompt",
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ repository_ids: [seedData.repositoryId],
+ },
+ );
+ if (!task.session_id) throw new Error("expected passthrough task to start a session");
+
+ const planContent = "PASSTHROUGH_PLAN_CONTEXT_MARKER";
+ await apiClient.wsRequest("task.plan.create", {
+ task_id: task.id,
+ title: "Plan",
+ content: `## Plan\n\n${planContent}`,
+ created_by: "user",
+ });
+
+ const kanban = new KanbanPage(testPage);
+ await kanban.goto();
+
+ const session = new SessionPage(testPage);
+ await openTaskAndWaitForTerminal(testPage, kanban, session, "Toolbar Plan Context Task");
+ await session.expectPassthroughHasText("Processed:", 20_000);
+
+ await testPage.getByTestId("passthrough-toggle-composer").click();
+ const editor = passthroughEditor(testPage);
+ await expect(editor).toBeVisible({ timeout: 5_000 });
+
+ await editor.click();
+ await editor.pressSequentially("@Plan");
+ await expect(testPage.getByText("Mention tasks, files, prompts")).toBeVisible({
+ timeout: 5_000,
+ });
+ await testPage.getByRole("button", { name: /^Plan Include the plan as context$/ }).click();
+ await expect(editor).toContainText("Plan");
+
+ await testPage.getByTestId("submit-message-button").click();
+ await expect(passthroughComposer(testPage)).toBeHidden({ timeout: 10_000 });
+
+ await session.expectPassthroughHasText("CONTEXT PLAN", 15_000);
+ await session.expectPassthroughHasText(planContent, 15_000);
+ });
+
+ test("uploads attachments and delivers them as workspace file paths", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }, testInfo) => {
+ test.setTimeout(90_000);
+
+ const profileId = await createPassthroughProfile(apiClient, "CLI Toolbar Attachment");
+ const task = await apiClient.createTaskWithAgent(
+ seedData.workspaceId,
+ "Toolbar Attachment Task",
+ profileId,
+ {
+ description: "initial prompt",
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ repository_ids: [seedData.repositoryId],
+ },
+ );
+ if (!task.session_id) throw new Error("expected passthrough task to start a session");
+
+ const kanban = new KanbanPage(testPage);
+ await kanban.goto();
+
+ const session = new SessionPage(testPage);
+ await openTaskAndWaitForTerminal(testPage, kanban, session, "Toolbar Attachment Task");
+ await session.expectPassthroughHasText("Processed:", 20_000);
+ await waitForPassthroughPromptable(apiClient, task.id, task.session_id);
+
+ fs.mkdirSync(testInfo.outputDir, { recursive: true });
+ const attachmentPath = path.join(testInfo.outputDir, "passthrough-upload.txt");
+ fs.writeFileSync(attachmentPath, "passthrough attachment body");
+
+ await testPage.getByTestId("passthrough-toggle-composer").click();
+ const composer = passthroughComposer(testPage);
+ await composer.locator('input[type="file"]').setInputFiles(attachmentPath);
+ await expect(composer.getByText("passthrough-upload.txt", { exact: true })).toBeVisible({
+ timeout: 10_000,
+ });
+
+ await passthroughEditor(testPage).fill("read the attached file");
+ await testPage.getByTestId("submit-message-button").click();
+ await expect(composer).toBeHidden({ timeout: 10_000 });
+
+ await session.expectPassthroughHasText("read the attached file", 15_000);
+ await session.expectPassthroughHasText("passthrough-upload.txt", 15_000);
+ const attachment = await waitForPersistedAttachment(
+ apiClient,
+ task.session_id,
+ "read the attached file",
+ );
+ expect(attachment).toMatchObject({
+ type: "resource",
+ name: "passthrough-upload.txt",
+ mime_type: "text/plain",
+ delivery_mode: "path",
+ });
+ });
+
+ test("slash stays in the terminal while the dedicated shortcut focuses the composer", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(90_000);
+
+ const profileId = await createPassthroughProfile(apiClient, "CLI Toolbar Focus Shortcut");
+ const task = await apiClient.createTaskWithAgent(
+ seedData.workspaceId,
+ "Toolbar Focus Shortcut Task",
+ profileId,
+ {
+ description: "initial prompt",
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ repository_ids: [seedData.repositoryId],
+ },
+ );
+ if (!task.session_id) throw new Error("expected passthrough task to start a session");
+
+ const kanban = new KanbanPage(testPage);
+ await kanban.goto();
+
+ const session = new SessionPage(testPage);
+ await openTaskAndWaitForTerminal(testPage, kanban, session, "Toolbar Focus Shortcut Task");
+ await session.expectPassthroughHasText("Processed:", 20_000);
+ await waitForPassthroughPromptable(apiClient, task.id, task.session_id);
+
+ await session.passthroughTerminal.locator(".xterm").click();
+ await testPage.keyboard.type("/terminal-slash-e2e");
+ await expect(passthroughComposer(testPage)).toHaveCount(0);
+ await session.expectPassthroughHasText("/terminal-slash-e2e", 5_000);
+
+ const modifier = process.platform === "darwin" ? "Meta" : "Control";
+ await testPage.keyboard.press(`${modifier}+Shift+y`);
+
+ const composer = passthroughComposer(testPage);
+ await expect(composer).toBeVisible({ timeout: 5_000 });
+ await expect(passthroughEditor(testPage)).toBeFocused();
+ });
+
test("toolbar omits a Stop button; cancel is via Ctrl-C in the terminal", async ({
testPage,
apiClient,
diff --git a/apps/web/e2e/tests/docker/docker-launch.spec.ts b/apps/web/e2e/tests/docker/docker-launch.spec.ts
index ab1a079777..5aa5d7a632 100644
--- a/apps/web/e2e/tests/docker/docker-launch.spec.ts
+++ b/apps/web/e2e/tests/docker/docker-launch.spec.ts
@@ -77,13 +77,16 @@ test.describe("Docker executor — launch + reuse + recovery", () => {
const session = new SessionPage(testPage);
await session.waitForLoad();
- const launchPromise = apiClient.launchSession({
- task_id: task.id,
- agent_profile_id: seedData.agentProfileId,
- executor_profile_id: profile.id,
- workflow_step_id: seedData.startStepId,
- prompt: "/e2e:simple-message",
- });
+ const launchPromise = apiClient.launchSession(
+ {
+ task_id: task.id,
+ agent_profile_id: seedData.agentProfileId,
+ executor_profile_id: profile.id,
+ workflow_step_id: seedData.startStepId,
+ prompt: "/e2e:simple-message",
+ },
+ 90_000,
+ );
const panel = testPage.getByTestId("prepare-progress-panel");
await expect(panel).toBeVisible({ timeout: 15_000 });
diff --git a/apps/web/e2e/tests/settings/keyboard-shortcuts.spec.ts b/apps/web/e2e/tests/settings/keyboard-shortcuts.spec.ts
index 47ba7c9450..013aaba991 100644
--- a/apps/web/e2e/tests/settings/keyboard-shortcuts.spec.ts
+++ b/apps/web/e2e/tests/settings/keyboard-shortcuts.spec.ts
@@ -21,6 +21,8 @@ test.describe("Keyboard Shortcuts Settings", () => {
await expect(testPage.getByTestId("shortcut-recorder-COMMAND_PANEL")).toBeVisible();
await expect(testPage.getByTestId("shortcut-recorder-NEW_TASK")).toBeVisible();
await expect(testPage.getByTestId("shortcut-recorder-FOCUS_INPUT")).toBeVisible();
+ await expect(testPage.getByTestId("shortcut-recorder-FOCUS_PASSTHROUGH_INPUT")).toBeVisible();
+ await expect(testPage.getByText("Focus CLI Chat Input")).toBeVisible();
await expect(testPage.getByTestId("shortcut-recorder-TOGGLE_PLAN_MODE")).toBeVisible();
await expect(testPage.getByTestId("shortcut-recorder-TASK_SWITCHER")).toBeVisible();
await expect(testPage.getByTestId("shortcut-recorder-TASK_SWITCHER_REVERSE")).toBeVisible();
diff --git a/apps/web/hooks/use-keyboard-shortcut.ts b/apps/web/hooks/use-keyboard-shortcut.ts
index 20a33f15b4..be10ea6255 100644
--- a/apps/web/hooks/use-keyboard-shortcut.ts
+++ b/apps/web/hooks/use-keyboard-shortcut.ts
@@ -13,6 +13,8 @@ export type KeyboardShortcutOptions = {
preventDefault?: boolean;
/** Whether to stop event propagation (default: false) */
stopPropagation?: boolean;
+ /** Whether to listen during capture phase (default: false) */
+ capture?: boolean;
/** Target element (default: window) */
target?: HTMLElement | Window | null;
};
@@ -34,6 +36,7 @@ export function useKeyboardShortcut(
enabled = true,
preventDefault = true,
stopPropagation = false,
+ capture = false,
target = typeof window !== "undefined" ? window : null,
} = options;
@@ -61,12 +64,12 @@ export function useKeyboardShortcut(
useEffect(() => {
if (!enabled || !target) return;
- target.addEventListener("keydown", handleKeyDown as EventListener);
+ target.addEventListener("keydown", handleKeyDown as EventListener, { capture });
return () => {
- target.removeEventListener("keydown", handleKeyDown as EventListener);
+ target.removeEventListener("keydown", handleKeyDown as EventListener, { capture });
};
- }, [enabled, target, handleKeyDown]);
+ }, [enabled, target, handleKeyDown, capture]);
}
/**
diff --git a/apps/web/hooks/use-message-handler.ts b/apps/web/hooks/use-message-handler.ts
index 2983fec930..983d648f46 100644
--- a/apps/web/hooks/use-message-handler.ts
+++ b/apps/web/hooks/use-message-handler.ts
@@ -78,7 +78,10 @@ export function buildTaskMentionsContext(tasks: TaskMentionData[], state: AppSta
);
}
-function buildContextFilesContext(contextFiles: ContextFile[], prompts: CustomPrompt[]): string {
+export function buildContextFilesContext(
+ contextFiles: ContextFile[],
+ prompts: CustomPrompt[],
+): string {
const files = contextFiles.filter(
(f) => !f.path.startsWith("prompt:") && f.path !== "plan:context",
);
diff --git a/apps/web/hooks/use-summarize-session.test.ts b/apps/web/hooks/use-summarize-session.test.ts
new file mode 100644
index 0000000000..65f2e84767
--- /dev/null
+++ b/apps/web/hooks/use-summarize-session.test.ts
@@ -0,0 +1,51 @@
+import { act, renderHook } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { useSummarizeSession } from "./use-summarize-session";
+
+const mockListMessages = vi.fn();
+const mockExecuteUtilityPrompt = vi.fn();
+
+vi.mock("@/lib/api/domains/session-api", () => ({
+ listTaskSessionMessages: (...args: unknown[]) => mockListMessages(...args),
+}));
+
+vi.mock("@/lib/api/domains/utility-api", () => ({
+ executeUtilityPrompt: (...args: unknown[]) => mockExecuteUtilityPrompt(...args),
+}));
+
+describe("useSummarizeSession", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("returns the generated summary", async () => {
+ mockListMessages.mockResolvedValue({
+ messages: [{ type: "message", author_type: "user", content: "hello" }],
+ });
+ mockExecuteUtilityPrompt.mockResolvedValue({ success: true, response: "summary" });
+ const { result } = renderHook(() => useSummarizeSession());
+
+ let summary;
+ await act(async () => {
+ summary = await result.current.summarize("session-1");
+ });
+
+ expect(summary).toEqual({ summary: "summary" });
+ });
+
+ it("returns backend execution errors instead of swallowing them", async () => {
+ mockListMessages.mockResolvedValue({
+ messages: [{ type: "message", author_type: "user", content: "hello" }],
+ });
+ mockExecuteUtilityPrompt.mockRejectedValue(new Error("connection refused"));
+ const { result } = renderHook(() => useSummarizeSession());
+
+ let summary;
+ await act(async () => {
+ summary = await result.current.summarize("session-1");
+ });
+
+ expect(summary).toEqual({ summary: null, error: "connection refused" });
+ expect(result.current.isSummarizing).toBe(false);
+ });
+});
diff --git a/apps/web/hooks/use-summarize-session.ts b/apps/web/hooks/use-summarize-session.ts
index a10838f6d9..d8f7846487 100644
--- a/apps/web/hooks/use-summarize-session.ts
+++ b/apps/web/hooks/use-summarize-session.ts
@@ -3,6 +3,11 @@ import { executeUtilityPrompt } from "@/lib/api/domains/utility-api";
import { listTaskSessionMessages } from "@/lib/api/domains/session-api";
import type { Message } from "@/lib/types/http";
+export type SummarizeSessionResult = {
+ summary: string | null;
+ error?: string;
+};
+
function formatTranscript(messages: Message[]): string {
return messages
.filter((m) => m.type === "message" || m.type === "content")
@@ -16,16 +21,16 @@ function formatTranscript(messages: Message[]): string {
export function useSummarizeSession() {
const [isSummarizing, setIsSummarizing] = useState(false);
- const summarize = useCallback(async (sessionId: string): Promise => {
+ const summarize = useCallback(async (sessionId: string): Promise => {
setIsSummarizing(true);
try {
// Fetch messages from API — they may not be in the store for non-active sessions
const resp = await listTaskSessionMessages(sessionId, { sort: "asc" });
const messages = resp.messages ?? [];
- if (!messages.length) return null;
+ if (!messages.length) return { summary: null };
const transcript = formatTranscript(messages);
- if (!transcript) return null;
+ if (!transcript) return { summary: null };
// Sessionless: handoff often runs against a completed session whose
// agentctl is gone. Host utility executes the builtin summarize agent.
@@ -33,9 +38,15 @@ export function useSummarizeSession() {
utility_agent_id: "builtin-summarize-session",
conversation_history: transcript,
});
- return result.success ? (result.response ?? null) : null;
- } catch {
- return null;
+ if (!result.success) {
+ return { summary: null, error: result.error || "Summarize utility returned no result" };
+ }
+ return { summary: result.response ?? null };
+ } catch (error) {
+ return {
+ summary: null,
+ error: error instanceof Error ? error.message : "Could not generate a summary",
+ };
} finally {
setIsSummarizing(false);
}
diff --git a/apps/web/lib/keyboard/constants.ts b/apps/web/lib/keyboard/constants.ts
index 5779de424d..7936f3b104 100644
--- a/apps/web/lib/keyboard/constants.ts
+++ b/apps/web/lib/keyboard/constants.ts
@@ -121,6 +121,10 @@ export const SHORTCUTS = {
FOCUS_INPUT: {
key: KEYS.SLASH,
},
+ FOCUS_PASSTHROUGH_INPUT: {
+ key: KEYS.Y,
+ modifiers: { ctrlOrCmd: true, shift: true },
+ },
COMMAND_PANEL: {
key: KEYS.P,
modifiers: { ctrlOrCmd: true },
diff --git a/apps/web/lib/keyboard/shortcut-overrides.test.ts b/apps/web/lib/keyboard/shortcut-overrides.test.ts
index f239d00c46..56bf7d31d1 100644
--- a/apps/web/lib/keyboard/shortcut-overrides.test.ts
+++ b/apps/web/lib/keyboard/shortcut-overrides.test.ts
@@ -20,12 +20,13 @@ describe("CONFIGURABLE_SHORTCUTS", () => {
expect(ids).toContain("COMMAND_PANEL");
expect(ids).toContain("NEW_TASK");
expect(ids).toContain("FOCUS_INPUT");
+ expect(ids).toContain("FOCUS_PASSTHROUGH_INPUT");
expect(ids).toContain("TOGGLE_PLAN_MODE");
expect(ids).toContain("TASK_SWITCHER");
expect(ids).toContain("TASK_SWITCHER_REVERSE");
expect(ids).toContain("VOICE_INPUT_TOGGLE");
expect(ids).toContain("REVERSE_SEARCH");
- expect(ids).toHaveLength(13);
+ expect(ids).toHaveLength(14);
});
it("each entry has a label and default matching SHORTCUTS", () => {
@@ -44,6 +45,11 @@ describe("CONFIGURABLE_SHORTCUTS", () => {
expect(CONFIGURABLE_SHORTCUTS.FOCUS_INPUT.label).toBe("Focus Chat Input");
expect(CONFIGURABLE_SHORTCUTS.FOCUS_INPUT.default).toBe(SHORTCUTS.FOCUS_INPUT);
+ expect(CONFIGURABLE_SHORTCUTS.FOCUS_PASSTHROUGH_INPUT.label).toBe("Focus CLI Chat Input");
+ expect(CONFIGURABLE_SHORTCUTS.FOCUS_PASSTHROUGH_INPUT.default).toBe(
+ SHORTCUTS.FOCUS_PASSTHROUGH_INPUT,
+ );
+
expect(CONFIGURABLE_SHORTCUTS.TOGGLE_PLAN_MODE.label).toBe("Toggle Plan Mode");
expect(CONFIGURABLE_SHORTCUTS.TOGGLE_PLAN_MODE.default).toBe(SHORTCUTS.TOGGLE_PLAN_MODE);
diff --git a/apps/web/lib/keyboard/shortcut-overrides.ts b/apps/web/lib/keyboard/shortcut-overrides.ts
index cbbafcdeed..4245de85cc 100644
--- a/apps/web/lib/keyboard/shortcut-overrides.ts
+++ b/apps/web/lib/keyboard/shortcut-overrides.ts
@@ -9,6 +9,7 @@ export type ConfigurableShortcutId =
| "COMMAND_PANEL"
| "NEW_TASK"
| "FOCUS_INPUT"
+ | "FOCUS_PASSTHROUGH_INPUT"
| "TOGGLE_PLAN_MODE"
| "TASK_SWITCHER"
| "TASK_SWITCHER_REVERSE"
@@ -43,6 +44,10 @@ export const CONFIGURABLE_SHORTCUTS: Record<
COMMAND_PANEL: { label: "Command Panel (Alt)", default: SHORTCUTS.COMMAND_PANEL },
NEW_TASK: { label: "New Task", default: SHORTCUTS.NEW_TASK },
FOCUS_INPUT: { label: "Focus Chat Input", default: SHORTCUTS.FOCUS_INPUT },
+ FOCUS_PASSTHROUGH_INPUT: {
+ label: "Focus CLI Chat Input",
+ default: SHORTCUTS.FOCUS_PASSTHROUGH_INPUT,
+ },
TOGGLE_PLAN_MODE: { label: "Toggle Plan Mode", default: SHORTCUTS.TOGGLE_PLAN_MODE },
TASK_SWITCHER: { label: "Recent Task Switcher", default: SHORTCUTS.TASK_SWITCHER },
TASK_SWITCHER_REVERSE: {
diff --git a/docs/specs/cli-mode-parity/spec.md b/docs/specs/cli-mode-parity/spec.md
index 397d4939f0..d6c2a2e7ba 100644
--- a/docs/specs/cli-mode-parity/spec.md
+++ b/docs/specs/cli-mode-parity/spec.md
@@ -38,11 +38,11 @@ No per-agent pattern matchers. The existing idle window is the only readiness si
For the Claude case: `claude_acp.go` sets `AutoInjectPrompt: true`, `DisableBracketedPaste: true` (Claude Code already enables bracketed-paste *mode* in its Ink TUI — injecting `ESC[200~`…`ESC[201~` delimiters breaks the prompt), and `SubmitViaBackslashEnter: true` (PTY writes: prompt, then `\`, then `\r` per [Claude terminal docs](https://code.claude.com/docs/en/terminal-config)). Ink may still treat programmatic Enter as newline only ([anthropics/claude-code#15553](https://github.com/anthropics/claude-code/issues/15553)) — if auto-submit fails, the user confirms with Enter. Other passthrough-capable agents use bracketed-paste delimiters for multi-line prompts and stay default-off for auto-inject unless configured.
-### Follow-up prompts via PTY stdin (backend route landed; UI surface deferred)
+### Follow-up prompts via PTY stdin
The orchestrator's `PromptTask` handler branches on `IsPassthroughSession(sessionID)` and writes the prompt text + `SubmitSequence` to the agent's PTY stdin instead of sending over ACP. This is the same path used by auto-injection and is reachable today from any caller that hits `agent.prompt` for a passthrough session.
-**UI gap (deferred):** kandev's chat compose box is rendered by `ChatInputArea`, which is replaced entirely by `PassthroughTerminal` when `session.is_passthrough` is true. There is no compose surface in passthrough mode today. Users send follow-ups by typing directly into the terminal (xterm forwards each keystroke to the PTY) — which works perfectly for the Claude CLI use case. A dedicated kandev compose box that drives the PTY (without replacing the terminal) is a follow-up.
+`PassthroughToolbar` provides the UI surface for this route. Its chat control opens `PassthroughComposerPanel`, which wraps the shared `ChatInputContainer` so passthrough follow-ups use the same rich input, context chips, attachment control, and plan-mode toggle as ACP task chat. Submissions go through `message.add` for the current passthrough session and are written to PTY stdin by the backend.
### Kandev MCP tools are wired for CLI mode
@@ -50,11 +50,11 @@ When a passthrough agent supports an MCP config flag, kandev generates a per-ses
For the Claude case, the passthrough command includes `--mcp-config ` with an `mcpServers.kandev` HTTP entry for the local agentctl `/mcp` endpoint.
-### Stop sends Ctrl-C (backend route landed; UI surface deferred)
+### Stop sends Ctrl-C
The orchestrator's `CancelAgent` handler branches on `IsPassthroughSession(sessionID)` and writes `\x03` to the PTY's stdin instead of sending an ACP cancel. DB reconciliation still runs so the UI unsticks regardless of the write outcome.
-**UI gap (deferred):** Same root cause as the compose-box gap — `ChatInputArea` (which hosts the Stop button) is not rendered in passthrough mode. Users today press Ctrl-C directly in the xterm terminal, which travels through the same PTY stdin path. A dedicated Stop button overlay in `PassthroughTerminal` is a follow-up.
+Users can still press Ctrl-C directly inside the xterm terminal. A dedicated toolbar button that calls the same cancel route remains a follow-up.
## Scenarios
@@ -108,7 +108,7 @@ The orchestrator's `CancelAgent` handler branches on `IsPassthroughSession(sessi
`PassthroughToolbar` (`apps/web/components/task/passthrough-toolbar.tsx`) wraps `PassthroughTerminal` with the same kandev controls that ACP sessions get from `ChatStatusBar` + `ChatInputArea`. It is mounted:
- Full-page task view: `dockview-shared.tsx` (`DockviewSharedContent`) renders it for passthrough sessions in place of the chat input area; the dockview desktop layout in `task-center-panel.tsx` uses the same shared content.
-- Kanban Preview tab: `preview-session-tabs.tsx` renders `PassthroughComposer` directly (compose-only, no terminal wrap) for passthrough sessions in the side-peek surface.
+- Kanban Preview tab: `preview-session-tabs.tsx` renders `PassthroughToolbar` for passthrough sessions in the side-peek surface, so the preview and full-page task views share the same terminal, status row, and composer behavior.
### What it surfaces
@@ -117,31 +117,42 @@ The status row at the bottom of `PassthroughToolbar` contains, left to right:
- `PRStatusChip` — PR open/merged/draft indicator for the task.
- `PRMergedBanner` — banner shown when the PR is merged.
- `passthrough-proceed-next-step` button — moves the task to the next workflow step. Only shown when `nextStepName` is non-null and the agent is not busy (`sessionState` is not `RUNNING` or `STARTING`).
-- `ChatToggleButton` — toggles the `PassthroughComposer` open and closed. Shows `variant="default"` when the composer is open or when there are pending review comments; shows a numeric chip (`data-testid="passthrough-pending-count"`) when collapsed with pending comments.
-- Stop button (`data-testid="passthrough-stop"`) — disabled when the session is not `RUNNING` or `STARTING`.
+- `ChatToggleButton` — toggles `PassthroughComposerPanel` open and closed. Shows `variant="default"` when the composer is open or when there are pending review comments; shows a numeric chip (`data-testid="passthrough-pending-count"`) when collapsed with pending comments.
### Default focus contract
The terminal (`PassthroughTerminal`) renders above the status row and fills the remaining height. The compose box is collapsed by default so xterm retains keyboard focus for raw PTY interaction. The user opts in to the kandev composer explicitly via the Chat button.
+Passthrough sessions have a dedicated configurable focus shortcut, `FOCUS_PASSTHROUGH_INPUT` (`Focus CLI Chat Input`, default `Cmd/Ctrl+Shift+Y`). It opens the composer and focuses the rich input when the user is not already typing in another input. The global ACP chat focus shortcut remains separate so pressing `/` while the terminal has focus continues to go to the PTY.
+
+### Composer assistance
+
+`PassthroughComposerPanel` delegates authoring to `ChatInputContainer` with passthrough-specific command behavior:
+
+- Slash command suggestions are disabled (`hasAgentCommands={false}`) because passthrough sessions do not receive ACP-reported slash-command capabilities. A literal `/` typed in the composer remains text, and a `/` typed while the terminal has focus goes to the PTY.
+- `@` file, prompt, plan, and task mentions use the shared rich-input lookup. Selected items render as the same context chips used by ACP chat.
+- The top chip bar shows selected context items. The bottom toolbar provides plan mode, attachment upload, and context-item add controls.
+- Uploaded attachments are sent through `message.add`; for passthrough sessions the backend materializes them under `.kandev/attachments//...` and appends their file paths to the PTY prompt.
+- File and prompt context items are expanded into `` context blocks before sending. Real filesystem-backed files are also sent as `context_files` metadata.
+
### How the composer formats and sends pending review comments
-When `PassthroughComposer` submits:
+When `PassthroughComposerPanel` submits:
1. `usePendingDiffCommentsByFile(sessionId)` provides comments grouped by file path.
-2. Non-empty `pendingComments` are formatted via `formatReviewCommentsAsMarkdown` (produces `### Review Comments\n…`) and prepended to the typed text.
-3. The combined string is sent via `client.request("message.add", { task_id, session_id, content }, 10_000)`.
-4. `markCommentsSent(ids)` is called only on WS success — a failed send leaves both the composer open (typed text preserved for retry) and the comments pending.
-5. A `PendingCommentsBanner` (`data-testid="passthrough-pending-comments-banner"`) renders inside the open composer when `pendingCount > 0`.
+2. Non-empty pending diff comments are formatted via `formatReviewCommentsAsMarkdown` or the shared `buildSubmitMessage` path, then included with the typed text.
+3. Selected context files, prompt mentions, and task mentions are appended as system context blocks. Real context files are included in `context_files`; uploaded attachments are included in `attachments`.
+4. The combined payload is sent via `client.request("message.add", { task_id, session_id, content, attachments?, context_files? }, timeout)`.
+5. `markCommentsSent(ids)` is called only on WS success — a failed send leaves both the composer open (typed text preserved for retry) and the comments pending.
+6. A `PendingCommentsBanner` (`data-testid="passthrough-pending-comments-banner"`) renders inside the open composer when `pendingCount > 0`.
### How Stop maps to Ctrl-C
-Clicking the Stop button calls `client.request("agent.cancel", { session_id }, 10_000)` over WS. The WS handler calls `Service.CancelAgent` → `agentManager.CancelAgentBySessionID` in `apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go`, which branches on `execution.PassthroughProcessID != ""` and writes `\x03` to PTY stdin. DB reconciliation completes regardless of the write outcome so the UI unsticks.
+Pressing Ctrl-C inside the passthrough terminal writes `\x03` to PTY stdin. A future Stop affordance in `PassthroughTerminal` can call `client.request("agent.cancel", { session_id }, 10_000)` over WS; that route reaches `Service.CancelAgent` -> `agentManager.CancelAgentBySessionID` in `apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go`, which branches on `execution.PassthroughProcessID != ""` and writes `\x03` to PTY stdin. DB reconciliation completes regardless of the write outcome so the UI unsticks.
### Not included
- `TodoIndicator` — ACP-only (requires message stream); not rendered in `PassthroughToolbar`.
-- Plan-comments and PR-feedback attachment — same pattern as diff comments if needed in future; deferred.
- Per-message model/mode picker — ACP-only feature; not applicable to PTY sessions.
## Out of scope
@@ -155,7 +166,6 @@ Clicking the Stop button calls `client.request("agent.cancel", { session_id }, 1
## Follow-ups
-- **Compose box in passthrough mode.** Surface a dedicated kandev compose input (alongside the PTY terminal) that writes to PTY stdin via the now-wired backend route. Today users type into xterm directly, which works but is inconsistent with kandev's ACP UX.
- **Stop button overlay in `PassthroughTerminal`.** Small affordance that calls the existing cancel route. Today users press Ctrl-C inside xterm directly.
- **AutoInjectPrompt on other passthrough agents** (Codex CLI, OpenCode TUI, etc.) — only Claude is opted in for v1. Adding others requires verifying their submit sequence and idle behavior.