diff --git a/packages/adapters/cursor-local/src/cli/format-event.ts b/packages/adapters/cursor-local/src/cli/format-event.ts index 556a3ebb..04f2ae7e 100644 --- a/packages/adapters/cursor-local/src/cli/format-event.ts +++ b/packages/adapters/cursor-local/src/cli/format-event.ts @@ -24,6 +24,30 @@ function stringifyUnknown(value: unknown): string { } } +function printUserMessage(messageRaw: unknown): void { + if (typeof messageRaw === "string") { + const text = messageRaw.trim(); + if (text) console.log(pc.gray(`user: ${text}`)); + return; + } + + const message = asRecord(messageRaw); + if (!message) return; + + const directText = asString(message.text).trim(); + if (directText) console.log(pc.gray(`user: ${directText}`)); + + const content = Array.isArray(message.content) ? message.content : []; + for (const partRaw of content) { + const part = asRecord(partRaw); + if (!part) continue; + const type = asString(part.type).trim(); + if (type !== "output_text" && type !== "text") continue; + const text = asString(part.text).trim(); + if (text) console.log(pc.gray(`user: ${text}`)); + } +} + function printAssistantMessage(messageRaw: unknown): void { if (typeof messageRaw === "string") { const text = messageRaw.trim(); @@ -82,6 +106,56 @@ function printAssistantMessage(messageRaw: unknown): void { } } +function printToolCallEventTopLevel(parsed: Record): void { + const subtype = asString(parsed.subtype).trim().toLowerCase(); + const callId = asString(parsed.call_id, asString(parsed.callId, asString(parsed.id, ""))); + const toolCall = asRecord(parsed.tool_call ?? parsed.toolCall); + if (!toolCall) { + console.log(pc.yellow(`tool_call${subtype ? `: ${subtype}` : ""}`)); + return; + } + + const [toolName] = Object.keys(toolCall); + if (!toolName) { + console.log(pc.yellow(`tool_call${subtype ? `: ${subtype}` : ""}`)); + return; + } + const payload = asRecord(toolCall[toolName]) ?? {}; + const args = payload.args ?? asRecord(payload.function)?.arguments; + const result = + payload.result ?? + payload.output ?? + payload.error ?? + asRecord(payload.function)?.result ?? + asRecord(payload.function)?.output; + const isError = + parsed.is_error === true || + payload.is_error === true || + subtype === "failed" || + subtype === "error" || + subtype === "cancelled" || + payload.error !== undefined; + + if (subtype === "started" || subtype === "start") { + console.log(pc.yellow(`tool_call: ${toolName}${callId ? ` (${callId})` : ""}`)); + if (args !== undefined) { + console.log(pc.gray(stringifyUnknown(args))); + } + return; + } + + if (subtype === "completed" || subtype === "complete" || subtype === "finished") { + const header = `tool_result${isError ? " (error)" : ""}${callId ? ` (${callId})` : ""}`; + console.log((isError ? pc.red : pc.cyan)(header)); + if (result !== undefined) { + console.log((isError ? pc.red : pc.gray)(stringifyUnknown(result))); + } + return; + } + + console.log(pc.yellow(`tool_call: ${toolName}${subtype ? ` (${subtype})` : ""}`)); +} + function printLegacyToolEvent(part: Record): void { const tool = asString(part.tool, "tool"); const callId = asString(part.callID, asString(part.id, "")); @@ -158,6 +232,22 @@ export function printCursorStreamEvent(raw: string, _debug: boolean): void { return; } + if (type === "user") { + printUserMessage(parsed.message); + return; + } + + if (type === "thinking") { + const text = asString(parsed.text).trim() || asString(asRecord(parsed.delta)?.text).trim(); + if (text) console.log(pc.gray(`thinking: ${text}`)); + return; + } + + if (type === "tool_call") { + printToolCallEventTopLevel(parsed); + return; + } + if (type === "result") { const usage = asRecord(parsed.usage); const input = asNumber(usage?.input_tokens, asNumber(usage?.inputTokens)); diff --git a/packages/adapters/cursor-local/src/ui/parse-stdout.ts b/packages/adapters/cursor-local/src/ui/parse-stdout.ts index 1028a765..6202ec10 100644 --- a/packages/adapters/cursor-local/src/ui/parse-stdout.ts +++ b/packages/adapters/cursor-local/src/ui/parse-stdout.ts @@ -32,6 +32,32 @@ function stringifyUnknown(value: unknown): string { } } +function parseUserMessage(messageRaw: unknown, ts: string): TranscriptEntry[] { + if (typeof messageRaw === "string") { + const text = messageRaw.trim(); + return text ? [{ kind: "user", ts, text }] : []; + } + + const message = asRecord(messageRaw); + if (!message) return []; + + const entries: TranscriptEntry[] = []; + const directText = asString(message.text).trim(); + if (directText) entries.push({ kind: "user", ts, text: directText }); + + const content = Array.isArray(message.content) ? message.content : []; + for (const partRaw of content) { + const part = asRecord(partRaw); + if (!part) continue; + const type = asString(part.type).trim(); + if (type !== "output_text" && type !== "text") continue; + const text = asString(part.text).trim(); + if (text) entries.push({ kind: "user", ts, text }); + } + + return entries; +} + function parseAssistantMessage(messageRaw: unknown, ts: string): TranscriptEntry[] { if (typeof messageRaw === "string") { const text = messageRaw.trim(); @@ -101,6 +127,64 @@ function parseAssistantMessage(messageRaw: unknown, ts: string): TranscriptEntry return entries; } +function parseCursorToolCallEvent(event: Record, ts: string): TranscriptEntry[] { + const subtype = asString(event.subtype).trim().toLowerCase(); + const callId = + asString(event.call_id) || + asString(event.callId) || + asString(event.id) || + "tool_call"; + const toolCall = asRecord(event.tool_call ?? event.toolCall); + if (!toolCall) { + return [{ kind: "system", ts, text: `tool_call${subtype ? ` (${subtype})` : ""}` }]; + } + + const [toolName] = Object.keys(toolCall); + if (!toolName) { + return [{ kind: "system", ts, text: `tool_call${subtype ? ` (${subtype})` : ""}` }]; + } + const payload = asRecord(toolCall[toolName]) ?? {}; + const input = payload.args ?? asRecord(payload.function)?.arguments ?? {}; + + if (subtype === "started" || subtype === "start") { + return [{ + kind: "tool_call", + ts, + name: toolName, + input, + }]; + } + + if (subtype === "completed" || subtype === "complete" || subtype === "finished") { + const result = + payload.result ?? + payload.output ?? + payload.error ?? + asRecord(payload.function)?.result ?? + asRecord(payload.function)?.output; + const isError = + event.is_error === true || + payload.is_error === true || + asString(payload.status).toLowerCase() === "error" || + asString(payload.status).toLowerCase() === "failed" || + asString(payload.status).toLowerCase() === "cancelled" || + payload.error !== undefined; + return [{ + kind: "tool_result", + ts, + toolUseId: callId, + content: result !== undefined ? stringifyUnknown(result) : `${toolName} completed`, + isError, + }]; + } + + return [{ + kind: "system", + ts, + text: `tool_call${subtype ? ` (${subtype})` : ""}: ${toolName}`, + }]; +} + export function parseCursorStdoutLine(line: string, ts: string): TranscriptEntry[] { const normalized = normalizeCursorStreamLine(line); if (!normalized.line) return []; @@ -129,6 +213,20 @@ export function parseCursorStdoutLine(line: string, ts: string): TranscriptEntry return entries.length > 0 ? entries : [{ kind: "assistant", ts, text: asString(parsed.result) }]; } + if (type === "user") { + return parseUserMessage(parsed.message, ts); + } + + if (type === "thinking") { + const text = asString(parsed.text).trim() || asString(asRecord(parsed.delta)?.text).trim(); + if (!text) return []; + return [{ kind: "thinking", ts, text }]; + } + + if (type === "tool_call") { + return parseCursorToolCallEvent(parsed, ts); + } + if (type === "result") { const usage = asRecord(parsed.usage); const inputTokens = asNumber(usage?.input_tokens, asNumber(usage?.inputTokens)); diff --git a/server/src/__tests__/cursor-local-adapter.test.ts b/server/src/__tests__/cursor-local-adapter.test.ts index 462177f1..70547200 100644 --- a/server/src/__tests__/cursor-local-adapter.test.ts +++ b/server/src/__tests__/cursor-local-adapter.test.ts @@ -136,6 +136,74 @@ describe("cursor ui stdout parser", () => { ), ).toEqual([{ kind: "thinking", ts, text: "streamed" }]); }); + + it("parses user, top-level thinking, and top-level tool_call events", () => { + const ts = "2026-03-05T00:00:00.000Z"; + + expect( + parseCursorStdoutLine( + JSON.stringify({ + type: "user", + message: { + role: "user", + content: [{ type: "text", text: "Please inspect README.md" }], + }, + }), + ts, + ), + ).toEqual([{ kind: "user", ts, text: "Please inspect README.md" }]); + + expect( + parseCursorStdoutLine( + JSON.stringify({ + type: "thinking", + subtype: "delta", + text: "planning next command", + }), + ts, + ), + ).toEqual([{ kind: "thinking", ts, text: "planning next command" }]); + + expect( + parseCursorStdoutLine( + JSON.stringify({ + type: "tool_call", + subtype: "started", + call_id: "call_1", + tool_call: { + readToolCall: { + args: { path: "README.md" }, + }, + }, + }), + ts, + ), + ).toEqual([{ kind: "tool_call", ts, name: "readToolCall", input: { path: "README.md" } }]); + + expect( + parseCursorStdoutLine( + JSON.stringify({ + type: "tool_call", + subtype: "completed", + call_id: "call_1", + tool_call: { + readToolCall: { + result: { success: { content: "README contents" } }, + }, + }, + }), + ts, + ), + ).toEqual([ + { + kind: "tool_result", + ts, + toolUseId: "call_1", + content: '{\n "success": {\n "content": "README contents"\n }\n}', + isError: false, + }, + ]); + }); }); function stripAnsi(value: string): string { @@ -143,7 +211,7 @@ function stripAnsi(value: string): string { } describe("cursor cli formatter", () => { - it("prints init, assistant, tool, and result events", () => { + it("prints init, user, assistant, tool, and result events", () => { const spy = vi.spyOn(console, "log").mockImplementation(() => {}); try { @@ -151,6 +219,15 @@ describe("cursor cli formatter", () => { JSON.stringify({ type: "system", subtype: "init", session_id: "chat_abc", model: "gpt-5" }), false, ); + printCursorStreamEvent( + JSON.stringify({ + type: "user", + message: { + content: [{ type: "text", text: "run tests" }], + }, + }), + false, + ); printCursorStreamEvent( JSON.stringify({ type: "assistant", @@ -160,6 +237,14 @@ describe("cursor cli formatter", () => { }), false, ); + printCursorStreamEvent( + JSON.stringify({ + type: "thinking", + subtype: "delta", + text: "looking at package.json", + }), + false, + ); printCursorStreamEvent( JSON.stringify({ type: "assistant", @@ -178,6 +263,32 @@ describe("cursor cli formatter", () => { }), false, ); + printCursorStreamEvent( + JSON.stringify({ + type: "tool_call", + subtype: "started", + call_id: "call_1", + tool_call: { + readToolCall: { + args: { path: "README.md" }, + }, + }, + }), + false, + ); + printCursorStreamEvent( + JSON.stringify({ + type: "tool_call", + subtype: "completed", + call_id: "call_1", + tool_call: { + readToolCall: { + result: { success: { content: "README contents" } }, + }, + }, + }), + false, + ); printCursorStreamEvent( JSON.stringify({ type: "result", @@ -196,8 +307,13 @@ describe("cursor cli formatter", () => { expect(lines).toEqual( expect.arrayContaining([ "Cursor init (session: chat_abc, model: gpt-5)", + "user: run tests", "assistant: hello", + "thinking: looking at package.json", "tool_call: bash", + "tool_call: readToolCall (call_1)", + "tool_result (call_1)", + '{\n "success": {\n "content": "README contents"\n }\n}', "tool_result", "AGENTS.md", "result: subtype=success",