Skip to content
Draft
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { describe, expect, it, mock } from "bun:test";
import { dispatchSelection } from "./dispatchSelection";

const PROMPT = "In src/a.ts:L1-L2: do it\n```\nconst a = 1;\n```";

function makeDeps(overrides?: {
sendToTerminalResolves?: boolean;
createReturns?: { terminalId: string } | null;
}) {
const sendToTerminalAgent = mock(async () => {
if (overrides?.sendToTerminalResolves === false) {
throw new Error("writeInput rejected");
}
});
const createResult: { terminalId: string } | null =
overrides && "createReturns" in overrides
? (overrides.createReturns ?? null)
: { terminalId: "new-term" };
const onCreateNewAgentSession = mock(
async (): Promise<{ terminalId: string } | null> => createResult,
);
const onMissingLauncher = mock(() => {});
return { sendToTerminalAgent, onCreateNewAgentSession, onMissingLauncher };
}

describe("dispatchSelection — target resolution", () => {
it("dispatches into the existing terminal via sendToTerminalAgent", async () => {
const deps = makeDeps();

const outcome = await dispatchSelection({
workspaceId: "ws1",
text: PROMPT,
target: { kind: "existing", terminalId: "term-7" },
...deps,
});

expect(outcome).toBe("sent");
expect(deps.sendToTerminalAgent).toHaveBeenCalledTimes(1);
expect(deps.sendToTerminalAgent).toHaveBeenCalledWith({
workspaceId: "ws1",
terminalId: "term-7",
text: PROMPT,
});
expect(deps.onCreateNewAgentSession).not.toHaveBeenCalled();
});

it("launches a new session when the resolved target is {kind:'new'} (no live session)", async () => {
const deps = makeDeps();

const outcome = await dispatchSelection({
workspaceId: "ws1",
text: PROMPT,
target: { kind: "new", configId: "cfg-1", placement: "split-pane" },
...deps,
});

expect(outcome).toBe("sent");
expect(deps.onCreateNewAgentSession).toHaveBeenCalledTimes(1);
expect(deps.onCreateNewAgentSession).toHaveBeenCalledWith({
configId: "cfg-1",
placement: "split-pane",
prompt: PROMPT,
});
expect(deps.sendToTerminalAgent).not.toHaveBeenCalled();
});

it("never silent-drops: a null target with no launcher signals the missing launcher", async () => {
const deps = makeDeps();

const outcome = await dispatchSelection({
workspaceId: "ws1",
text: PROMPT,
target: null,
sendToTerminalAgent: deps.sendToTerminalAgent,
onCreateNewAgentSession: undefined,
onMissingLauncher: deps.onMissingLauncher,
});

expect(outcome).toBe("no-launcher");
expect(deps.onMissingLauncher).toHaveBeenCalledTimes(1);
expect(deps.sendToTerminalAgent).not.toHaveBeenCalled();
});

it("signals the missing launcher when a new session is required but onCreateNewAgentSession is unwired", async () => {
const deps = makeDeps();

const outcome = await dispatchSelection({
workspaceId: "ws1",
text: PROMPT,
target: { kind: "new", configId: "cfg-1", placement: "split-pane" },
sendToTerminalAgent: deps.sendToTerminalAgent,
onCreateNewAgentSession: undefined,
onMissingLauncher: deps.onMissingLauncher,
});

expect(outcome).toBe("no-launcher");
expect(deps.onMissingLauncher).toHaveBeenCalledTimes(1);
});
});

describe("dispatchSelection — error handling", () => {
it("returns 'failed' (does not throw) when the terminal write rejects, so the caller can keep state for retry", async () => {
const deps = makeDeps({ sendToTerminalResolves: false });

const outcome = await dispatchSelection({
workspaceId: "ws1",
text: PROMPT,
target: { kind: "existing", terminalId: "term-7" },
...deps,
});

expect(outcome).toBe("failed");
});

it("returns 'failed' when a new-session launch resolves null (launch did not start)", async () => {
const deps = makeDeps({ createReturns: null });

const outcome = await dispatchSelection({
workspaceId: "ws1",
text: PROMPT,
target: { kind: "new", configId: "cfg-1", placement: "split-pane" },
...deps,
});

expect(outcome).toBe("failed");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { SendToTerminalAgentInput } from "renderer/hooks/host-service/useSendToTerminalAgent";
import type {
AgentSessionPlacement,
AgentTarget,
} from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/AgentCommentComposer";

/** `sent` → delivered; `failed` → caller keeps state for retry (toast already
* shown); `no-launcher` → a new session was needed but none was wired. */
export type DispatchOutcome = "sent" | "failed" | "no-launcher";

interface DispatchSelectionArgs {
workspaceId: string;
/** Already-formatted, already-bounded prompt body. */
text: string;
/** Resolved target, or null when the ladder produced nothing. */
target: AgentTarget | null;
sendToTerminalAgent: (input: SendToTerminalAgentInput) => Promise<void>;
onCreateNewAgentSession?: (input: {
configId: string;
placement: AgentSessionPlacement;
prompt: string;
}) => Promise<{ terminalId: string } | null>;
/** Called when a new session is needed but no launcher is wired. */
onMissingLauncher: () => void;
}

/** Send a formatted selection to the resolved target: existing terminal →
* writeInput; new/null target → onCreateNewAgentSession, or signal when no
* launcher is wired. Returns an outcome instead of throwing. */
export async function dispatchSelection({
workspaceId,
text,
target,
sendToTerminalAgent,
onCreateNewAgentSession,
onMissingLauncher,
}: DispatchSelectionArgs): Promise<DispatchOutcome> {
if (target === null || target.kind === "new") {
if (target === null || !onCreateNewAgentSession) {
onMissingLauncher();
return "no-launcher";
}
const result = await onCreateNewAgentSession({
configId: target.configId,
placement: target.placement,
prompt: text,
});
return result ? "sent" : "failed";
}

try {
await sendToTerminalAgent({
workspaceId,
terminalId: target.terminalId,
text,
});
return "sent";
} catch {
return "failed";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { type DispatchOutcome, dispatchSelection } from "./dispatchSelection";
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, expect, it } from "bun:test";
import { normalizeTerminalCommand } from "renderer/lib/terminal/launch-command";
import {
type AgentPromptFileContext,
formatAgentPromptWithFileContext,
} from "./useSendToTerminalAgent";

const fileContext = (
overrides?: Partial<AgentPromptFileContext>,
): AgentPromptFileContext => ({
path: "src/a.ts",
startLine: 40,
endLine: 60,
...overrides,
});

describe("formatAgentPromptWithFileContext (selection carrier)", () => {
it("renders a multi-line selection as 'In <path>:L<a>-L<b>: <instruction>'", () => {
const result = formatAgentPromptWithFileContext({
comment: "refactor this",
file: fileContext(),
});

expect(result).toBe("In src/a.ts:L40-L60: refactor this");
});

it("collapses a single-line selection range to 'L<a>' (not 'L<a>-L<a>')", () => {
const result = formatAgentPromptWithFileContext({
comment: "explain",
file: fileContext({ startLine: 12, endLine: 12 }),
});

expect(result).toBe("In src/a.ts:L12: explain");
});

it("omits the diff-side suffix when no `side` is supplied (file-viewer case)", () => {
const result = formatAgentPromptWithFileContext({
comment: "look",
file: fileContext({ startLine: 5, endLine: 9 }),
});

expect(result).not.toContain("(deleted lines)");
expect(result).not.toContain("(across deletions and additions)");
expect(result).toBe("In src/a.ts:L5-L9: look");
});

it("embeds a fenced snippet AND the file anchor when the comment carries the captured lines", () => {
const snippet = "const x = 1;\nconst y = 2;";
const comment = `Here is the selected code:\n\n\`\`\`\n${snippet}\n\`\`\``;

const result = formatAgentPromptWithFileContext({
comment,
file: fileContext({ startLine: 1, endLine: 2 }),
});

expect(result).toContain("In src/a.ts:L1-L2:");
expect(result).toContain("```");
expect(result).toContain(snippet);
});

it("emits just the anchor (no trailing ': ') when the comment is empty (⌘↵ send, no message)", () => {
expect(
formatAgentPromptWithFileContext({ comment: "", file: fileContext() }),
).toBe("In src/a.ts:L40-L60");
expect(
formatAgentPromptWithFileContext({
comment: " ",
file: fileContext({ startLine: 12, endLine: 12 }),
}),
).toBe("In src/a.ts:L12");
});
});

describe("adapter divergence parity (edge case #5)", () => {
// The terminal surface appends a trailing newline (normalizeTerminalCommand);
// the chat surface trims both ends (chat-adapter toLaunchConfig does
// initialPrompt?.trim()). After normalizing leading/trailing whitespace the
// two carry the same context. This pins the terminal half of the invariant
// against the documented chat-side trim (chat half lands with PR2).
it("the terminal-serialized prompt trim-normalizes back to the formatted text", () => {
const formatted = formatAgentPromptWithFileContext({
comment: "Here is the selected code:",
file: fileContext({ startLine: 3, endLine: 7 }),
});

const terminalSerialized = normalizeTerminalCommand(formatted);
const chatSerialized = formatted.trim();

expect(terminalSerialized).toBe(`${formatted}\n`);
expect(terminalSerialized.trim()).toBe(chatSerialized);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,8 @@ export interface AgentPromptFileContext {
path: string;
startLine: number;
endLine: number;
/** Which side of the diff the selection covers. When omitted, the prompt
* is rendered without a side annotation (single-file viewer case).
* `deletions` and `mixed` are tagged explicitly because their line
* numbers refer to the pre-diff file and would otherwise be ambiguous. */
/** Diff side the selection covers; omitted for single-file viewers.
* `deletions`/`mixed` are tagged since their lines refer to the pre-diff file. */
side?: AgentPromptFileSide;
}

Expand All @@ -21,12 +19,7 @@ interface FormatPromptInput {
file: AgentPromptFileContext;
}

/**
* Build the prompt body for sending a comment to a CLI agent that should
* be anchored to a specific file/line range (e.g. inline diff comments,
* file-viewer selections). Keep this stable so consumers can format the
* same way without sharing logic.
*/
/** Build a CLI-agent prompt body anchored to a file/line range. */
export function formatAgentPromptWithFileContext({
comment,
file,
Expand All @@ -41,7 +34,11 @@ export function formatAgentPromptWithFileContext({
: file.side === "mixed"
? " (across deletions and additions)"
: "";
return `In ${file.path}:${range}${sideSuffix}: ${comment}`;
// ponytail: empty comment → emit just the anchor (no dangling ": ").
const note = comment.trim();
return note
? `In ${file.path}:${range}${sideSuffix}: ${note}`
: `In ${file.path}:${range}${sideSuffix}`;
}

export interface SendToTerminalAgentInput {
Expand All @@ -56,12 +53,8 @@ interface UseSendToTerminalAgentResult {
isPending: boolean;
}

/**
* Shared writer for pushing a comment/prompt into an existing terminal
* agent's pty via the host-service `terminal.writeInput` mutation.
* Surfaces (DiffPane composer, file-viewer comments, etc.) should funnel
* through this so the payload normalization + error toast stay consistent.
*/
/** Shared writer for pushing a prompt into an existing terminal agent's pty,
* keeping payload normalization + the error toast consistent across callers. */
export function useSendToTerminalAgent(): UseSendToTerminalAgentResult {
const writeInput = workspaceTrpc.terminal.writeInput.useMutation();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { useDiffCodeViewItems } from "./hooks/useDiffCodeViewItems";
import { useDiffCodeViewScroll } from "./hooks/useDiffCodeViewScroll";
import { useDiffCodeViewTheme } from "./hooks/useDiffCodeViewTheme";
import { useDiffCommentComposer } from "./hooks/useDiffCommentComposer";
import { useDiffTextSelection } from "./hooks/useDiffTextSelection";

interface CreateNewAgentSessionInput {
configId: string;
Expand Down Expand Up @@ -89,6 +90,7 @@ export function DiffPane({
const {
composerAnnotationsByItemId,
onLineSelectionEnd,
openForItem,
onGutterUtilityClick,
clear: clearComposer,
submit: submitComposer,
Expand All @@ -99,6 +101,10 @@ export function DiffPane({
onCreateNewAgentSession,
});

// Highlighting code text opens the same composer the gutter line-selection
// does, by feeding the resolved text selection through the composer opener.
const { onPostRender } = useDiffTextSelection({ openForItem });

const { items, fileByItemId, pathToItemId, hasPendingDiff, hasDiffError } =
useDiffCodeViewItems({
workspaceId,
Expand Down Expand Up @@ -128,8 +134,9 @@ export function DiffPane({
enableGutterUtility: true,
onGutterUtilityClick,
onLineSelectionEnd,
onPostRender,
}),
[options, onGutterUtilityClick, onLineSelectionEnd],
[options, onGutterUtilityClick, onLineSelectionEnd, onPostRender],
);

const renderHeaderPrefix = useCallback(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ export function AgentCommentComposer({
startLine === endLine
? `Line ${startLine}`
: `Lines ${startLine}–${endLine}`;
const canSubmit =
comment.trim().length > 0 && !submitting && resolved != null;
// ponytail: empty comment is allowed — ⌘↵ sends just the line reference.
const canSubmit = !submitting && resolved != null;
const showPlacement = resolved?.kind === "new";

const handleSubmit = async () => {
Expand All @@ -76,9 +76,7 @@ export function AgentCommentComposer({
try {
await onSubmit({ comment: comment.trim(), target: resolved });
} catch (error) {
// User-facing errors are the caller's responsibility (we toast in
// DiffPane's submit path). Catch here so a rejection doesn't leak
// as an unhandled promise out of the form's synchronous handlers.
// Caller toasts; catch here so a rejection isn't unhandled.
console.error("[AgentCommentComposer] submit failed", error);
} finally {
setSubmitting(false);
Expand Down
Loading