feat(spa): redesign tool footer — show latest tool, stats line, expandable history (#2031)

Replace the continuously-appending tool list with a cleaner 3-part footer:
1. Latest tool call (swapped, not appended) — shows current tool + hint
2. Compact stats line — "1× Bash, 4× Read, 5× Grep, 8× Glob"
3. Expandable attachment — full ordered tool history (Slack auto-collapses)

Also adds toolHint field to SlackSegment, extracts formatToolStats and
formatToolHistory as tested helpers, and adds 19 new unit tests.

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: L <6723574+louisgv@users.noreply.github.com>
This commit is contained in:
A 2026-02-28 12:43:29 -08:00 committed by GitHub
parent a8b7bb7fb9
commit 6f6b2a7895
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 270 additions and 28 deletions

View file

@ -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, unknown>): 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, unknown>): string {
const input = toRecord(block.input);
if (!input) {
return "";
@ -94,8 +102,34 @@ function formatToolHint(block: Record<string, unknown>): 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, unknown>): 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, number>): 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<string, unknown>): 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<string, unknown>): 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<string, unknown>): SlackSegment | nul
kind: "tool_use",
text: toolParts.join("\n"),
toolName: firstToolName,
toolHint: firstToolHint,
};
}
if (textParts.length > 0) {

View file

@ -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<typeof App>["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<string | undefined> {
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<string, number>;
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<string, number>();
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<void> {
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;
}
}

View file

@ -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<string, unknown> = {
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<string, unknown> = {
input: { pattern: "**/*.ts" },
};
expect(extractToolHint(block)).toBe("**/*.ts");
});
it("extracts file_path from input", () => {
const block: Record<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, number>([
["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();