canopy-ui 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,52 @@
1
+ import type { Participant } from "./protocol";
2
+
3
+ interface Props {
4
+ participants: Participant[];
5
+ presenceUserIds: number[];
6
+ draftHolderId: number | null;
7
+ draftHolderIdle: boolean;
8
+ }
9
+
10
+ export function PresenceChips({
11
+ participants,
12
+ presenceUserIds,
13
+ draftHolderId,
14
+ draftHolderIdle,
15
+ }: Props) {
16
+ const present = participants.filter((p) =>
17
+ presenceUserIds.includes(p.user_id),
18
+ );
19
+ if (present.length === 0) {
20
+ return <div className="text-sm text-muted-foreground">nobody else here</div>;
21
+ }
22
+ return (
23
+ <div className="flex gap-2">
24
+ {present.map((p) => {
25
+ const isHolder = p.user_id === draftHolderId && !draftHolderIdle;
26
+ return (
27
+ <div
28
+ key={p.user_id}
29
+ title={p.display_name + (isHolder ? " — editing…" : "")}
30
+ className={`flex h-7 w-7 items-center justify-center rounded-full text-xs font-medium ${
31
+ isHolder
32
+ ? "bg-primary text-primary-foreground ring-2 ring-primary/30"
33
+ : "bg-muted text-muted-foreground"
34
+ }`}
35
+ >
36
+ {initials(p.display_name)}
37
+ </div>
38
+ );
39
+ })}
40
+ </div>
41
+ );
42
+ }
43
+
44
+ function initials(name: string): string {
45
+ return name
46
+ .split(" ")
47
+ .map((w) => w[0])
48
+ .filter(Boolean)
49
+ .join("")
50
+ .slice(0, 2)
51
+ .toUpperCase();
52
+ }
@@ -0,0 +1,144 @@
1
+ import {
2
+ useEffect,
3
+ useRef,
4
+ useState,
5
+ type KeyboardEvent,
6
+ type ReactNode,
7
+ } from "react";
8
+
9
+ import type { Draft } from "./protocol";
10
+ import { isDraftIdle, msUntilDraftIdle } from "./drafts";
11
+ import { Button } from "../ui/button";
12
+
13
+ interface Props {
14
+ draft: Draft | null;
15
+ currentUserId: number;
16
+ holderIsPresent: boolean;
17
+ isStreaming: boolean;
18
+ streamingMessageId: string | null;
19
+ onUpdate: (body: string) => void;
20
+ onSend: () => void;
21
+ onStop: (messageId: string) => void;
22
+ onTakeOver: () => void;
23
+ /** Optional app-supplied banner rendered above the composer (e.g. an
24
+ * imported-session note). The kit itself has no CLI-auth banners. */
25
+ banner?: ReactNode;
26
+ /** When set, sending is disabled and this reason is shown as a hint. */
27
+ disabledReason?: string;
28
+ }
29
+
30
+ export function SendBox({
31
+ draft,
32
+ currentUserId,
33
+ holderIsPresent,
34
+ isStreaming,
35
+ streamingMessageId,
36
+ onUpdate,
37
+ onSend,
38
+ onStop,
39
+ onTakeOver,
40
+ banner,
41
+ disabledReason,
42
+ }: Props) {
43
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
44
+ // Force a re-render when the lock transitions from live to idle.
45
+ // Without this, nothing would trigger a re-render exactly at T+2s
46
+ // after the last edit, and another user's UI would stay locked
47
+ // indefinitely until some unrelated event happens to arrive.
48
+ const [, forceTick] = useState(0);
49
+
50
+ useEffect(() => {
51
+ if (!draft) return;
52
+ const remaining = msUntilDraftIdle(draft);
53
+ if (remaining === 0) return;
54
+ const t = window.setTimeout(() => forceTick((n) => n + 1), remaining + 10);
55
+ return () => window.clearTimeout(t);
56
+ }, [draft?.last_edit_at, draft]);
57
+
58
+ const holderId = draft?.last_editor ?? null;
59
+ const isHolder = holderId != null && holderId === currentUserId;
60
+ const holderIsIdle = isDraftIdle(draft);
61
+
62
+ // Gate on draft existence: during the pre-session.state window the
63
+ // textarea would otherwise accept keystrokes that silently drop
64
+ // because the hook's updateDraft no-ops when active_draft is null.
65
+ const canEdit =
66
+ draft != null && (isHolder || holderIsIdle || !holderIsPresent);
67
+
68
+ useEffect(() => {
69
+ if (canEdit && !isHolder && textareaRef.current) {
70
+ textareaRef.current.focus();
71
+ }
72
+ }, [canEdit, isHolder]);
73
+
74
+ const body = draft?.body ?? "";
75
+ const blocked = Boolean(disabledReason);
76
+ const canSend =
77
+ canEdit && body.trim().length > 0 && !isStreaming && !blocked;
78
+
79
+ const handleKey = (e: KeyboardEvent<HTMLTextAreaElement>) => {
80
+ // `isComposing` is true during IME input (CJK, etc.). Pressing
81
+ // Enter to commit a composition must not send the message.
82
+ const isComposing = (e.nativeEvent as unknown as { isComposing?: boolean })
83
+ .isComposing;
84
+ if (e.key === "Enter" && !e.shiftKey && !isComposing) {
85
+ e.preventDefault();
86
+ if (canSend) onSend();
87
+ }
88
+ };
89
+
90
+ const handleStopClick = () => {
91
+ if (streamingMessageId != null) onStop(streamingMessageId);
92
+ };
93
+
94
+ const placeholder = !draft
95
+ ? "Connecting…"
96
+ : blocked
97
+ ? disabledReason
98
+ : canEdit
99
+ ? "Type a message… (Enter to send, Shift+Enter for newline)"
100
+ : "Another teammate is editing…";
101
+
102
+ return (
103
+ <div className="border-t border-border bg-background">
104
+ {banner}
105
+ <div className="p-2">
106
+ <textarea
107
+ ref={textareaRef}
108
+ value={body}
109
+ disabled={!canEdit || blocked}
110
+ onChange={(e) => onUpdate(e.target.value)}
111
+ onKeyDown={handleKey}
112
+ placeholder={placeholder}
113
+ rows={3}
114
+ className="w-full resize-none rounded-md border border-input bg-transparent p-2 text-sm text-foreground shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground"
115
+ />
116
+ <div className="mt-1 flex items-center justify-end gap-2">
117
+ {blocked && (
118
+ <span className="mr-auto text-xs text-muted-foreground">
119
+ {disabledReason}
120
+ </span>
121
+ )}
122
+ {isStreaming ? (
123
+ <Button
124
+ type="button"
125
+ variant="destructive"
126
+ size="sm"
127
+ onClick={handleStopClick}
128
+ >
129
+ stop
130
+ </Button>
131
+ ) : null}
132
+ {!canEdit && holderIsPresent && !holderIsIdle ? (
133
+ <Button type="button" variant="outline" size="sm" onClick={onTakeOver}>
134
+ take over
135
+ </Button>
136
+ ) : null}
137
+ <Button type="button" size="sm" disabled={!canSend} onClick={onSend}>
138
+ send
139
+ </Button>
140
+ </div>
141
+ </div>
142
+ </div>
143
+ );
144
+ }
@@ -0,0 +1,86 @@
1
+ import { Check, ChevronRight, Loader2, X } from "lucide-react";
2
+
3
+ import type { Message } from "./protocol";
4
+ import { deriveToolStatus, toolDisplayName, toolPreview } from "./pairToolMessages";
5
+
6
+ interface Props {
7
+ use: Message;
8
+ result: Message | null;
9
+ /** Controlled-open state: true to force open, false to force closed,
10
+ * undefined to let the user control via the native <details> toggle. */
11
+ forceOpen?: boolean;
12
+ }
13
+
14
+ /**
15
+ * Renders a tool_use + tool_result as a single collapsible row.
16
+ *
17
+ * Header line: ``{status icon} {tool name} · {preview}`` — readable when
18
+ * collapsed so the user can scan a long stream of tool calls without
19
+ * expanding any. Expanded view stacks the input JSON on top of the
20
+ * result body, both monospace.
21
+ */
22
+ export function ToolCallPair({ use, result, forceOpen }: Props) {
23
+ const status = deriveToolStatus(use, result);
24
+ const name = toolDisplayName(use);
25
+ const preview = toolPreview(use, result);
26
+
27
+ const StatusIcon =
28
+ status.kind === "success" ? Check : status.kind === "error" ? X : Loader2;
29
+ const iconColor =
30
+ status.kind === "success"
31
+ ? "text-success"
32
+ : status.kind === "error"
33
+ ? "text-destructive"
34
+ : "text-muted-foreground animate-spin";
35
+
36
+ const input = (use.content as { input?: unknown } | undefined)?.input;
37
+
38
+ return (
39
+ <details
40
+ // ``open`` controls the row when forceOpen is set; otherwise
41
+ // ``open={undefined}`` lets the native toggle take over so single
42
+ // rows still expand/collapse on click.
43
+ open={forceOpen}
44
+ className="group my-1 rounded border border-border bg-muted/40 text-sm"
45
+ >
46
+ <summary className="flex cursor-pointer items-center gap-2 px-2 py-1.5 text-muted-foreground hover:bg-muted/60 select-none [&::-webkit-details-marker]:hidden">
47
+ <ChevronRight className="h-3 w-3 shrink-0 transition-transform group-open:rotate-90" />
48
+ <StatusIcon className={`h-3.5 w-3.5 shrink-0 ${iconColor}`} />
49
+ <span className="font-mono text-xs font-medium text-foreground">
50
+ {name}
51
+ </span>
52
+ {preview && (
53
+ <span className="truncate text-xs italic text-muted-foreground">
54
+ · {preview}
55
+ </span>
56
+ )}
57
+ </summary>
58
+ <div className="space-y-2 border-t border-border/60 p-2">
59
+ {input !== undefined && (
60
+ <div>
61
+ <div className="mb-1 text-[10px] uppercase tracking-wider text-muted-foreground">
62
+ input
63
+ </div>
64
+ <pre className="overflow-x-auto whitespace-pre-wrap break-all rounded bg-background p-2 text-xs">
65
+ {JSON.stringify(input, null, 2)}
66
+ </pre>
67
+ </div>
68
+ )}
69
+ <div>
70
+ <div className="mb-1 text-[10px] uppercase tracking-wider text-muted-foreground">
71
+ result
72
+ </div>
73
+ {result === null ? (
74
+ <div className="text-xs italic text-muted-foreground">
75
+ (running…)
76
+ </div>
77
+ ) : (
78
+ <pre className="overflow-x-auto whitespace-pre-wrap break-all rounded bg-background p-2 text-xs">
79
+ {result.plaintext}
80
+ </pre>
81
+ )}
82
+ </div>
83
+ </div>
84
+ </details>
85
+ );
86
+ }
@@ -0,0 +1,66 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest"
2
+
3
+ import type { Draft } from "./protocol"
4
+ import { IDLE_THRESHOLD_MS, isDraftIdle, msUntilDraftIdle } from "./drafts"
5
+
6
+ const NOW = 1_700_000_000_000
7
+
8
+ function draftEditedAt(msAgo: number): Draft {
9
+ return {
10
+ id: "d1",
11
+ slot: "next",
12
+ status: "open",
13
+ body: "",
14
+ version: 0,
15
+ last_editor: 1,
16
+ last_edit_at: new Date(NOW - msAgo).toISOString(),
17
+ }
18
+ }
19
+
20
+ afterEach(() => {
21
+ vi.useRealTimers()
22
+ })
23
+
24
+ describe("isDraftIdle", () => {
25
+ it("treats a null/undefined draft as idle", () => {
26
+ expect(isDraftIdle(null)).toBe(true)
27
+ expect(isDraftIdle(undefined)).toBe(true)
28
+ })
29
+
30
+ it("treats a draft with no last_edit_at as idle", () => {
31
+ const d = { ...draftEditedAt(0), last_edit_at: "" }
32
+ expect(isDraftIdle(d)).toBe(true)
33
+ })
34
+
35
+ it("is NOT idle immediately after an edit", () => {
36
+ vi.useFakeTimers()
37
+ vi.setSystemTime(NOW)
38
+ // edited 500ms ago — well within the 2s threshold
39
+ expect(isDraftIdle(draftEditedAt(500))).toBe(false)
40
+ })
41
+
42
+ it("IS idle once more than the threshold has elapsed", () => {
43
+ vi.useFakeTimers()
44
+ vi.setSystemTime(NOW)
45
+ expect(isDraftIdle(draftEditedAt(IDLE_THRESHOLD_MS + 1))).toBe(true)
46
+ })
47
+ })
48
+
49
+ describe("msUntilDraftIdle", () => {
50
+ it("returns 0 for a null draft", () => {
51
+ expect(msUntilDraftIdle(null)).toBe(0)
52
+ })
53
+
54
+ it("returns the remaining time before the idle transition", () => {
55
+ vi.useFakeTimers()
56
+ vi.setSystemTime(NOW)
57
+ // edited 500ms ago → 1500ms remain
58
+ expect(msUntilDraftIdle(draftEditedAt(500))).toBe(1500)
59
+ })
60
+
61
+ it("clamps to 0 once past the threshold", () => {
62
+ vi.useFakeTimers()
63
+ vi.setSystemTime(NOW)
64
+ expect(msUntilDraftIdle(draftEditedAt(IDLE_THRESHOLD_MS + 5000))).toBe(0)
65
+ })
66
+ })
@@ -0,0 +1,19 @@
1
+ import type { Draft } from "./protocol";
2
+
3
+ export const IDLE_THRESHOLD_MS = 2_000;
4
+
5
+ export function isDraftIdle(draft: Draft | null | undefined): boolean {
6
+ if (!draft?.last_edit_at) return true;
7
+ return Date.now() - new Date(draft.last_edit_at).getTime() > IDLE_THRESHOLD_MS;
8
+ }
9
+
10
+ /**
11
+ * Milliseconds until the draft lock becomes idle. Returns 0 if already
12
+ * idle. Use this to schedule a timer that forces a re-render at the
13
+ * idle transition point.
14
+ */
15
+ export function msUntilDraftIdle(draft: Draft | null | undefined): number {
16
+ if (!draft?.last_edit_at) return 0;
17
+ const elapsed = Date.now() - new Date(draft.last_edit_at).getTime();
18
+ return Math.max(0, IDLE_THRESHOLD_MS - elapsed);
19
+ }
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import type { Message } from "./protocol"
3
+ import { prependHistory } from "./history"
4
+
5
+ function msg(turn_index: number, plaintext = `m${turn_index}`): Message {
6
+ return {
7
+ id: `t${turn_index}`, turn_index, role: "user", content: {}, plaintext,
8
+ status: "complete", error_detail: null, started_at: null, completed_at: null,
9
+ created_at: "",
10
+ }
11
+ }
12
+
13
+ describe("prependHistory", () => {
14
+ it("prepends older messages ahead of current, chronological", () => {
15
+ const current = [msg(30), msg(31)]
16
+ const older = [msg(28), msg(29)]
17
+ expect(prependHistory(current, older).map((m) => m.turn_index)).toEqual([28, 29, 30, 31])
18
+ })
19
+
20
+ it("dedupes on turn_index (an overlapping page is not double-inserted)", () => {
21
+ const current = [msg(30), msg(31)]
22
+ const older = [msg(29), msg(30)] // 30 overlaps
23
+ expect(prependHistory(current, older).map((m) => m.turn_index)).toEqual([29, 30, 31])
24
+ })
25
+
26
+ it("returns the same reference when older is empty", () => {
27
+ const current = [msg(30)]
28
+ expect(prependHistory(current, [])).toBe(current)
29
+ })
30
+
31
+ it("returns the same reference when every older row already exists", () => {
32
+ const current = [msg(30), msg(31)]
33
+ expect(prependHistory(current, [msg(30)])).toBe(current)
34
+ })
35
+ })
@@ -0,0 +1,16 @@
1
+ import type { Message } from "./protocol";
2
+
3
+ /**
4
+ * Merge an older page (from the REST scroll-back endpoint) into the current
5
+ * transcript: prepend, dedupe by turn_index, keep chronological. Pure — no React,
6
+ * no WebSocket — so the container can apply "Load earlier" to the socket's
7
+ * SessionState without a new WS frame. Returns `current` unchanged (same
8
+ * reference) when nothing new prepends, so callers can skip a re-render.
9
+ */
10
+ export function prependHistory(current: Message[], older: Message[]): Message[] {
11
+ if (older.length === 0) return current;
12
+ const seen = new Set(current.map((m) => m.turn_index));
13
+ const fresh = older.filter((m) => !seen.has(m.turn_index));
14
+ if (fresh.length === 0) return current;
15
+ return [...fresh, ...current].sort((a, b) => a.turn_index - b.turn_index);
16
+ }
@@ -0,0 +1,56 @@
1
+ // canopy-ui/chat — a reusable, app-agnostic multiplayer chat kit.
2
+ //
3
+ // Speaks the canonical chat WebSocket protocol (ace-web's contract; string
4
+ // ids). App specifics — the ws URL builder, the markdown renderer, session
5
+ // meta — are injected. The presentational tree is props-in / callbacks-out.
6
+
7
+ // Protocol types
8
+ export type {
9
+ Message,
10
+ MessageRole,
11
+ MessageStatus,
12
+ Draft,
13
+ Participant,
14
+ SessionState,
15
+ WsAction,
16
+ WsEvent,
17
+ } from "./protocol";
18
+
19
+ // Reducer (pure)
20
+ export { sessionReducer } from "./sessionReducer";
21
+ export { prependHistory } from "./history";
22
+
23
+ // Hooks
24
+ export {
25
+ useSessionSocket,
26
+ type UseSessionSocketOptions,
27
+ type UseSessionSocketResult,
28
+ } from "./useSessionSocket";
29
+ export { useStickyBottom } from "./useStickyBottom";
30
+
31
+ // Draft idle helpers
32
+ export { IDLE_THRESHOLD_MS, isDraftIdle, msUntilDraftIdle } from "./drafts";
33
+
34
+ // Tool-message pairing helpers
35
+ export {
36
+ pairToolMessages,
37
+ deriveToolStatus,
38
+ toolPreview,
39
+ toolDisplayName,
40
+ type ChatRow,
41
+ type ToolCallStatus,
42
+ } from "./pairToolMessages";
43
+
44
+ // Presentational components
45
+ export { ChatPanel, type ChatPanelProps } from "./ChatPanel";
46
+ export { MessageList } from "./MessageList";
47
+ export { MessageItem, type RenderMarkdown } from "./MessageItem";
48
+ export { ToolCallPair } from "./ToolCallPair";
49
+ export { SendBox } from "./SendBox";
50
+ export { PresenceChips } from "./PresenceChips";
51
+ export { ConnectionStatus } from "./ConnectionStatus";
52
+ export {
53
+ PlacementBanner,
54
+ type PlacementBannerProps,
55
+ type PlacementRunner,
56
+ } from "./PlacementBanner";
@@ -0,0 +1,208 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import type { Message } from "./protocol";
4
+ import {
5
+ deriveToolStatus,
6
+ pairToolMessages,
7
+ toolDisplayName,
8
+ toolPreview,
9
+ } from "./pairToolMessages";
10
+
11
+ let seq = 0;
12
+ function msg(
13
+ partial: Partial<Message> & { id: string; role: Message["role"] },
14
+ ): Message {
15
+ return {
16
+ turn_index: seq++,
17
+ content: {},
18
+ plaintext: "",
19
+ status: "complete",
20
+ error_detail: null,
21
+ started_at: null,
22
+ completed_at: null,
23
+ created_at: "2026-05-13T00:00:00Z",
24
+ ...partial,
25
+ };
26
+ }
27
+
28
+ describe("pairToolMessages", () => {
29
+ it("pairs consecutive tool_use + tool_result by id/tool_use_id", () => {
30
+ const rows = pairToolMessages([
31
+ msg({ id: "1", role: "user", plaintext: "hi" }),
32
+ msg({
33
+ id: "2",
34
+ role: "tool_use",
35
+ content: { id: "t1", name: "Bash", input: { command: "ls" } },
36
+ }),
37
+ msg({
38
+ id: "3",
39
+ role: "tool_result",
40
+ content: { tool_use_id: "t1" },
41
+ plaintext: "out",
42
+ }),
43
+ msg({ id: "4", role: "assistant", plaintext: "done" }),
44
+ ]);
45
+ expect(rows).toHaveLength(3);
46
+ expect(rows[0].kind).toBe("message");
47
+ expect(rows[1].kind).toBe("tool_pair");
48
+ if (rows[1].kind === "tool_pair") {
49
+ expect(rows[1].use.id).toBe("2");
50
+ expect(rows[1].result?.id).toBe("3");
51
+ }
52
+ expect(rows[2].kind).toBe("message");
53
+ });
54
+
55
+ it("pairs tool_use with a later non-adjacent tool_result", () => {
56
+ const rows = pairToolMessages([
57
+ msg({ id: "1", role: "tool_use", content: { id: "a", name: "X" } }),
58
+ msg({ id: "2", role: "tool_use", content: { id: "b", name: "Y" } }),
59
+ msg({ id: "3", role: "tool_result", content: { tool_use_id: "a" } }),
60
+ msg({ id: "4", role: "tool_result", content: { tool_use_id: "b" } }),
61
+ ]);
62
+ expect(rows).toHaveLength(2);
63
+ expect(rows[0].kind).toBe("tool_pair");
64
+ expect(rows[1].kind).toBe("tool_pair");
65
+ if (rows[0].kind === "tool_pair" && rows[1].kind === "tool_pair") {
66
+ expect(rows[0].use.id).toBe("1");
67
+ expect(rows[0].result?.id).toBe("3");
68
+ expect(rows[1].use.id).toBe("2");
69
+ expect(rows[1].result?.id).toBe("4");
70
+ }
71
+ });
72
+
73
+ it("leaves an in-flight tool_use with null result so UI can show pending", () => {
74
+ const rows = pairToolMessages([
75
+ msg({ id: "1", role: "tool_use", content: { id: "t1", name: "Bash" } }),
76
+ ]);
77
+ expect(rows).toHaveLength(1);
78
+ expect(rows[0].kind).toBe("tool_pair");
79
+ if (rows[0].kind === "tool_pair") {
80
+ expect(rows[0].result).toBeNull();
81
+ }
82
+ });
83
+
84
+ it("falls back to standalone when a tool_result has no matching use", () => {
85
+ const rows = pairToolMessages([
86
+ msg({ id: "1", role: "tool_result", content: { tool_use_id: "ghost" } }),
87
+ ]);
88
+ expect(rows).toHaveLength(1);
89
+ expect(rows[0].kind).toBe("message");
90
+ });
91
+
92
+ it("doesn't lose tool calls in long sessions", () => {
93
+ const msgs: Message[] = [];
94
+ for (let i = 0; i < 44; i++) {
95
+ msgs.push(
96
+ msg({
97
+ id: `${i * 2 + 1}`,
98
+ role: "tool_use",
99
+ content: { id: `t${i}`, name: "Bash" },
100
+ }),
101
+ );
102
+ if (i < 42) {
103
+ msgs.push(
104
+ msg({
105
+ id: `${i * 2 + 2}`,
106
+ role: "tool_result",
107
+ content: { tool_use_id: `t${i}` },
108
+ }),
109
+ );
110
+ }
111
+ }
112
+ const rows = pairToolMessages(msgs);
113
+ expect(rows).toHaveLength(44);
114
+ expect(rows.filter((r) => r.kind === "tool_pair")).toHaveLength(44);
115
+ // First 42 paired, last 2 pending.
116
+ const pending = rows.filter(
117
+ (r) => r.kind === "tool_pair" && r.result === null,
118
+ );
119
+ expect(pending).toHaveLength(2);
120
+ });
121
+ });
122
+
123
+ describe("deriveToolStatus", () => {
124
+ it("returns pending when result is missing", () => {
125
+ const use = msg({ id: "1", role: "tool_use" });
126
+ expect(deriveToolStatus(use, null).kind).toBe("pending");
127
+ });
128
+
129
+ it("returns error when result is in error status", () => {
130
+ const use = msg({ id: "1", role: "tool_use" });
131
+ const result = msg({ id: "2", role: "tool_result", status: "error" });
132
+ expect(deriveToolStatus(use, result).kind).toBe("error");
133
+ });
134
+
135
+ it("sniffs error-like result content", () => {
136
+ const use = msg({ id: "1", role: "tool_use" });
137
+ const result = msg({
138
+ id: "2",
139
+ role: "tool_result",
140
+ plaintext: "Error: file not found",
141
+ });
142
+ expect(deriveToolStatus(use, result).kind).toBe("error");
143
+ });
144
+
145
+ it("returns success on a clean result", () => {
146
+ const use = msg({ id: "1", role: "tool_use" });
147
+ const result = msg({ id: "2", role: "tool_result", plaintext: "ok" });
148
+ expect(deriveToolStatus(use, result).kind).toBe("success");
149
+ });
150
+ });
151
+
152
+ describe("toolPreview / toolDisplayName", () => {
153
+ it("shows the first line of a Bash command", () => {
154
+ const use = msg({
155
+ id: "1",
156
+ role: "tool_use",
157
+ content: {
158
+ name: "Bash",
159
+ input: { command: "ls -la /tmp\n# more after newline" },
160
+ },
161
+ });
162
+ expect(toolPreview(use, null)).toBe("ls -la /tmp");
163
+ });
164
+
165
+ it("shows file_path for Read/Write/Edit", () => {
166
+ for (const name of ["Read", "Write", "Edit"]) {
167
+ const use = msg({
168
+ id: "1",
169
+ role: "tool_use",
170
+ content: { name, input: { file_path: "/a/b.txt" } },
171
+ });
172
+ expect(toolPreview(use, null)).toBe("/a/b.txt");
173
+ }
174
+ });
175
+
176
+ it("shows the todo count for TodoWrite", () => {
177
+ const use = msg({
178
+ id: "1",
179
+ role: "tool_use",
180
+ content: {
181
+ name: "TodoWrite",
182
+ input: { todos: [{}, {}, {}] },
183
+ },
184
+ });
185
+ expect(toolPreview(use, null)).toBe("3 todos");
186
+ });
187
+
188
+ it("shows the skill name for Skill", () => {
189
+ const use = msg({
190
+ id: "1",
191
+ role: "tool_use",
192
+ content: { name: "Skill", input: { skill: "ace:run" } },
193
+ });
194
+ expect(toolPreview(use, null)).toBe("ace:run");
195
+ });
196
+
197
+ it("strips the mcp prefix for MCP tools", () => {
198
+ const use = msg({
199
+ id: "1",
200
+ role: "tool_use",
201
+ content: {
202
+ name: "mcp__plugin_ace_ace-gdrive__drive_create_file",
203
+ input: {},
204
+ },
205
+ });
206
+ expect(toolDisplayName(use)).toBe("drive_create_file");
207
+ });
208
+ });