diff --git a/.claude/skills/setup-spa/helpers.ts b/.claude/skills/setup-spa/helpers.ts index 4696a13b..be6f1dc2 100644 --- a/.claude/skills/setup-spa/helpers.ts +++ b/.claude/skills/setup-spa/helpers.ts @@ -78,11 +78,19 @@ export interface SlackSegment { kind: "text" | "tool_use" | "tool_result"; text: string; toolName?: string; // set for tool_use + toolHint?: string; // set for tool_use — truncated command/pattern/path isError?: boolean; // set for tool_result } -/** Format a tool_use input block into a truncated backtick hint string. */ -function formatToolHint(block: Record): string { +/** Tracked tool call for history and stats. */ +export interface ToolCall { + name: string; + hint: string; + errored?: boolean; +} + +/** Extract a truncated hint from a tool_use input block. */ +export function extractToolHint(block: Record): string { const input = toRecord(block.input); if (!input) { return ""; @@ -94,8 +102,34 @@ function formatToolHint(block: Record): string { if (!hint) { return ""; } - const short = hint.length > 80 ? `${hint.slice(0, 80)}...` : hint; - return ` \`${short}\``; + return hint.length > 80 ? `${hint.slice(0, 80)}...` : hint; +} + +/** Format a tool_use input block into a truncated backtick hint string. */ +function formatToolHint(block: Record): string { + const hint = extractToolHint(block); + if (!hint) { + return ""; + } + return ` \`${hint}\``; +} + +/** Format tool counts into a compact stats string: "1× Bash, 4× Read, 5× Grep". */ +export function formatToolStats(counts: ReadonlyMap): string { + return Array.from(counts.entries()) + .map(([name, count]) => `${count}× ${name}`) + .join(", "); +} + +/** Format the full ordered tool history into a numbered list for the expandable attachment. */ +export function formatToolHistory(history: readonly ToolCall[]): string { + return history + .map((t, i) => { + const icon = t.errored ? "✗" : "✓"; + const hint = t.hint ? ` — ${t.hint}` : ""; + return `${i + 1}. ${icon} ${t.name}${hint}`; + }) + .join("\n"); } /** Parse an assistant-type event into a SlackSegment. */ @@ -109,6 +143,7 @@ function parseAssistantEvent(event: Record): SlackSegment | nul const textParts: string[] = []; const toolParts: string[] = []; let firstToolName: string | undefined; + let firstToolHint: string | undefined; for (const rawBlock of content) { const block = toRecord(rawBlock); @@ -123,6 +158,7 @@ function parseAssistantEvent(event: Record): SlackSegment | nul if (block.type === "tool_use" && isString(block.name)) { if (!firstToolName) { firstToolName = block.name; + firstToolHint = extractToolHint(block); } toolParts.push(`:hammer_and_wrench: *${block.name}*${formatToolHint(block)}`); } @@ -134,6 +170,7 @@ function parseAssistantEvent(event: Record): SlackSegment | nul kind: "tool_use", text: toolParts.join("\n"), toolName: firstToolName, + toolHint: firstToolHint, }; } if (textParts.length > 0) { diff --git a/.claude/skills/setup-spa/main.ts b/.claude/skills/setup-spa/main.ts index a2911803..db3b961f 100644 --- a/.claude/skills/setup-spa/main.ts +++ b/.claude/skills/setup-spa/main.ts @@ -5,7 +5,7 @@ import type { SectionBlock, ContextBlock, KnownBlock } from "@slack/bolt"; import { App } from "@slack/bolt"; import * as v from "valibot"; import { toRecord, isString } from "@openrouter/spawn-shared"; -import type { State } from "./helpers"; +import type { State, ToolCall } from "./helpers"; import { ResultSchema, loadState, @@ -16,6 +16,8 @@ import { stripMention, downloadSlackFile, runCleanupIfDue, + formatToolStats, + formatToolHistory, } from "./helpers"; type SlackClient = InstanceType["client"]; @@ -99,8 +101,16 @@ When creating issues, include a footer: "_Filed from Slack by SPA_" Below is the full Slack thread. The most recent message is the one you should respond to. Prior messages are context.`; +/** Slack attachment shape (secondary content below blocks). */ +interface SlackAttachment { + color?: string; + text: string; + mrkdwn_in?: string[]; +} + /** * Post a new message or update an existing one. Returns the message timestamp. + * Optional `attachments` adds expandable secondary content below blocks. */ async function postOrUpdate( client: SlackClient, @@ -109,6 +119,7 @@ async function postOrUpdate( existingTs: string | undefined, fallback: string, blocks: KnownBlock[], + attachments?: SlackAttachment[], ): Promise { if (!existingTs) { const msg = await client.chat @@ -117,6 +128,7 @@ async function postOrUpdate( thread_ts: threadTs, text: fallback, blocks, + attachments, }) .catch(() => null); return msg?.ts; @@ -127,6 +139,7 @@ async function postOrUpdate( ts: existingTs, text: fallback, blocks, + attachments: attachments ?? [], }) .catch(() => {}); return existingTs; @@ -209,17 +222,34 @@ async function buildThreadPrompt(client: SlackClient, channel: string, threadTs: // ─── Block Kit message builder ───────────────────────────────────────────── -interface ToolEntry { - name: string; - errored?: boolean; -} - const MAX_SECTION_LEN = 2900; // Slack section block text limit is 3000 -/** Build Block Kit blocks: section(text) + context(tools + loading). */ -function buildBlocks(mainText: string, tools: ToolEntry[], loading: boolean): KnownBlock[] { - const blocks: KnownBlock[] = []; +interface BuildBlocksInput { + mainText: string; + currentTool: ToolCall | null; + toolCounts: ReadonlyMap; + toolHistory: readonly ToolCall[]; + loading: boolean; +} +interface BuildBlocksResult { + blocks: KnownBlock[]; + attachments: SlackAttachment[]; +} + +/** + * Build Block Kit blocks with redesigned tool footer: + * 1. Section: main response text + * 2. Context: latest tool call (swapped, not appended) + * 3. Context: compact stats line (1× Bash, 4× Read, ...) + * 4. Attachment: full ordered tool history (expandable in Slack) + */ +function buildBlocks(input: BuildBlocksInput): BuildBlocksResult { + const { mainText, currentTool, toolCounts, toolHistory, loading } = input; + const blocks: KnownBlock[] = []; + const attachments: SlackAttachment[] = []; + + // 1. Main text section if (mainText) { const display = mainText.length > MAX_SECTION_LEN ? `...${mainText.slice(-MAX_SECTION_LEN)}` : mainText; const section: SectionBlock = { @@ -232,9 +262,13 @@ function buildBlocks(mainText: string, tools: ToolEntry[], loading: boolean): Kn blocks.push(section); } - if (tools.length > 0) { - const parts = tools.map((t) => (t.errored ? `${t.name} :x:` : t.name)); - let toolLine = `:hammer_and_wrench: ${parts.join(" · ")}`; + // 2. Current tool detail — shows only the LATEST tool (swapped each update) + if (currentTool) { + const icon = currentTool.errored ? ":x:" : ":hammer_and_wrench:"; + let toolLine = `${icon} *${currentTool.name}*`; + if (currentTool.hint) { + toolLine += ` \`${currentTool.hint}\``; + } if (loading) { toolLine += " :openrouter-loading:"; } @@ -261,7 +295,32 @@ function buildBlocks(mainText: string, tools: ToolEntry[], loading: boolean): Kn blocks.push(ctx); } - return blocks; + // 3. Stats line — compact tool usage counts + if (toolCounts.size > 0) { + const stats = formatToolStats(toolCounts); + const ctx: ContextBlock = { + type: "context", + elements: [ + { + type: "mrkdwn", + text: stats, + }, + ], + }; + blocks.push(ctx); + } + + // 4. Expandable tool history — Slack auto-collapses long attachment text + if (!loading && toolHistory.length > 1) { + const historyText = formatToolHistory(toolHistory); + attachments.push({ + color: "#808080", + text: historyText, + mrkdwn_in: ["text"], + }); + } + + return { blocks, attachments }; } /** @@ -312,7 +371,9 @@ async function runClaudeAndStream( // ─── Streaming state ───────────────────────────────────────────────── let mainText = ""; - const tools: ToolEntry[] = []; + const toolHistory: ToolCall[] = []; + const toolCounts = new Map(); + let currentTool: ToolCall | null = null; let msgTs: string | undefined; let returnedSessionId: string | null = null; let hasOutput = false; @@ -322,13 +383,28 @@ async function runClaudeAndStream( /** Post or update the Slack message with current blocks. */ async function updateMessage(loading: boolean): Promise { - const blocks = buildBlocks(mainText, tools, loading); + const { blocks, attachments } = buildBlocks({ + mainText, + currentTool, + toolCounts, + toolHistory, + loading, + }); if (blocks.length === 0) { return; } - const fallback = mainText || `Working... (${tools.length} tool${tools.length === 1 ? "" : "s"})`; + const totalTools = toolHistory.length; + const fallback = mainText || `Working... (${totalTools} tool${totalTools === 1 ? "" : "s"})`; hasOutput = true; - msgTs = await postOrUpdate(client, channel, threadTs, msgTs, fallback, blocks); + msgTs = await postOrUpdate( + client, + channel, + threadTs, + msgTs, + fallback, + blocks, + attachments.length > 0 ? attachments : undefined, + ); dirty = false; } @@ -383,12 +459,16 @@ async function runClaudeAndStream( mainText += segment.text; dirty = true; } else if (segment.kind === "tool_use" && segment.toolName) { - tools.push({ + const tool: ToolCall = { name: segment.toolName, - }); + hint: segment.toolHint ?? "", + }; + toolHistory.push(tool); + currentTool = tool; + toolCounts.set(tool.name, (toolCounts.get(tool.name) ?? 0) + 1); dirty = true; - } else if (segment.kind === "tool_result" && segment.isError && tools.length > 0) { - tools[tools.length - 1].errored = true; + } else if (segment.kind === "tool_result" && segment.isError && currentTool) { + currentTool.errored = true; dirty = true; } } diff --git a/.claude/skills/setup-spa/spa.test.ts b/.claude/skills/setup-spa/spa.test.ts index 37341912..805d04a2 100644 --- a/.claude/skills/setup-spa/spa.test.ts +++ b/.claude/skills/setup-spa/spa.test.ts @@ -1,5 +1,16 @@ import { describe, it, expect, mock, afterEach } from "bun:test"; -import { parseStreamEvent, stripMention, markdownToSlack, loadState, saveState, downloadSlackFile } from "./helpers"; +import { + parseStreamEvent, + stripMention, + markdownToSlack, + loadState, + saveState, + downloadSlackFile, + extractToolHint, + formatToolStats, + formatToolHistory, +} from "./helpers"; +import type { ToolCall } from "./helpers"; import { toRecord } from "@openrouter/spawn-shared"; import streamEvents from "../../../fixtures/claude-code/stream-events.json"; @@ -20,11 +31,12 @@ describe("parseStreamEvent", () => { expect(result?.text).toContain("I'll look at the issue and check the repository structure."); }); - it("parses assistant tool_use (Bash) from fixture with toolName", () => { + it("parses assistant tool_use (Bash) from fixture with toolName and toolHint", () => { // fixture[1]: assistant with tool_use Bash const result = parseStreamEvent(fixture(1)); expect(result?.kind).toBe("tool_use"); expect(result?.toolName).toBe("Bash"); + expect(result?.toolHint).toContain("gh issue list"); expect(result?.text).toContain(":hammer_and_wrench: *Bash*"); expect(result?.text).toContain("gh issue list"); }); @@ -38,11 +50,12 @@ describe("parseStreamEvent", () => { expect(result?.text).toContain("Fly.io deploy fails on arm64"); }); - it("parses assistant tool_use (Glob) from fixture with toolName", () => { + it("parses assistant tool_use (Glob) from fixture with toolName and toolHint", () => { // fixture[3]: assistant with tool_use Glob const result = parseStreamEvent(fixture(3)); expect(result?.kind).toBe("tool_use"); expect(result?.toolName).toBe("Glob"); + expect(result?.toolHint).toBe("**/*.ts"); expect(result?.text).toBe(":hammer_and_wrench: *Glob* `**/*.ts`"); }); @@ -99,6 +112,7 @@ describe("parseStreamEvent", () => { }; const result = parseStreamEvent(event); expect(result?.text).toContain("..."); + expect(result?.toolHint).toContain("..."); expect(result?.kind).toBe("tool_use"); }); @@ -158,6 +172,7 @@ describe("parseStreamEvent", () => { const result = parseStreamEvent(event); expect(result?.kind).toBe("tool_use"); expect(result?.toolName).toBe("Bash"); + expect(result?.toolHint).toBe(""); expect(result?.text).toBe(":hammer_and_wrench: *Bash*"); }); @@ -313,6 +328,116 @@ describe("saveState", () => { }); }); +describe("extractToolHint", () => { + it("extracts command from input", () => { + const block: Record = { + input: { command: "gh issue list --repo OpenRouterTeam/spawn" }, + }; + expect(extractToolHint(block)).toBe("gh issue list --repo OpenRouterTeam/spawn"); + }); + + it("extracts pattern from input", () => { + const block: Record = { + input: { pattern: "**/*.ts" }, + }; + expect(extractToolHint(block)).toBe("**/*.ts"); + }); + + it("extracts file_path from input", () => { + const block: Record = { + input: { file_path: "/home/user/spawn/index.ts" }, + }; + expect(extractToolHint(block)).toBe("/home/user/spawn/index.ts"); + }); + + it("prefers command over pattern and file_path", () => { + const block: Record = { + input: { command: "echo hi", pattern: "*.ts", file_path: "/foo" }, + }; + expect(extractToolHint(block)).toBe("echo hi"); + }); + + it("truncates hints longer than 80 chars", () => { + const longCmd = "x".repeat(100); + const block: Record = { + input: { command: longCmd }, + }; + const result = extractToolHint(block); + expect(result).toHaveLength(83); // 80 + "..." + expect(result).toEndWith("..."); + }); + + it("returns empty string for missing input", () => { + expect(extractToolHint({})).toBe(""); + }); + + it("returns empty string for input without recognized keys", () => { + const block: Record = { + input: { query: "search term" }, + }; + expect(extractToolHint(block)).toBe(""); + }); +}); + +describe("formatToolStats", () => { + it("formats a single tool count", () => { + const counts = new Map([["Bash", 3]]); + expect(formatToolStats(counts)).toBe("3× Bash"); + }); + + it("formats multiple tool counts", () => { + const counts = new Map([ + ["Bash", 1], + ["Read", 4], + ["Grep", 5], + ["Glob", 8], + ]); + expect(formatToolStats(counts)).toBe("1× Bash, 4× Read, 5× Grep, 8× Glob"); + }); + + it("returns empty string for empty map", () => { + expect(formatToolStats(new Map())).toBe(""); + }); +}); + +describe("formatToolHistory", () => { + it("formats a single tool call", () => { + const history: ToolCall[] = [{ name: "Bash", hint: "echo hi" }]; + expect(formatToolHistory(history)).toBe("1. ✓ Bash — echo hi"); + }); + + it("formats multiple tool calls with numbering", () => { + const history: ToolCall[] = [ + { name: "Bash", hint: "gh issue list" }, + { name: "Glob", hint: "**/*.ts" }, + { name: "Read", hint: "/home/user/index.ts" }, + ]; + const result = formatToolHistory(history); + expect(result).toBe( + "1. ✓ Bash — gh issue list\n2. ✓ Glob — **/*.ts\n3. ✓ Read — /home/user/index.ts", + ); + }); + + it("marks errored tools with ✗", () => { + const history: ToolCall[] = [ + { name: "Bash", hint: "rm -rf /", errored: true }, + { name: "Read", hint: "file.ts" }, + ]; + const result = formatToolHistory(history); + expect(result).toContain("1. ✗ Bash — rm -rf /"); + expect(result).toContain("2. ✓ Read — file.ts"); + }); + + it("handles tools without hints", () => { + const history: ToolCall[] = [{ name: "Bash", hint: "" }]; + expect(formatToolHistory(history)).toBe("1. ✓ Bash"); + }); + + it("returns empty string for empty history", () => { + expect(formatToolHistory([])).toBe(""); + }); +}); + describe("downloadSlackFile", () => { afterEach(() => { mock.restore();