From bd1559d390260ea98d527e7a70b5b9a79f4a26f1 Mon Sep 17 00:00:00 2001 From: Kevin Murphy Date: Tue, 22 Sep 2026 17:44:57 +0100 Subject: [PATCH 1/2] feat: live tool call disclosure in chat UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tool calls are now visible DURING the turn, not just after: - Mac toolTraceFooter: removed the status != .streaming gate so the "Used web search · date & time" line appears live as each tool dispatches, with a content transition animation. - iOS toolTraceRow: added the same provenance line (was missing entirely from the iOS bubble). - ActivityLabeler: 6 tools that fell through to the raw default now get tailored live labels (delegate_deep, list_documents, get_document, open_link, lookup_fact, recent_activity); the default fallback humanises via displayName (underscores → spaces). Co-Authored-By: Claude Opus 4.6 (1M context) Claude-Session: https://claude.ai/code/session_01ELMgNSM3gNKL6kFm16wpyX # ── MurphySig ────────────────────────────────────────────────────── # Significant decision in this commit? Sign it. https://murphysig.dev/spec # ─────────────────────────────────────────────────────────────────── --- macos/M1K3App/MessageView.swift | 18 ++++--- macos/M1K3iOSApp/MessageBubble.swift | 16 +++++++ .../Sources/M1K3Chat/ResponderActivity.swift | 48 ++++++++++++------- macos/Tests/M1K3ChatTests/ActivityTests.swift | 14 +++++- 4 files changed, 70 insertions(+), 26 deletions(-) diff --git a/macos/M1K3App/MessageView.swift b/macos/M1K3App/MessageView.swift index 42e17e2f0..db3639c5c 100644 --- a/macos/M1K3App/MessageView.swift +++ b/macos/M1K3App/MessageView.swift @@ -16,6 +16,8 @@ // Review: Kev + claude-opus-5, 2026-09-14 — a "Private Cloud Compute" label under every answer PCC produced // (`answerOrigin`, ADR 0006). Confidence 0.85 (verified by launch with the Debug echo backend: a PCC answer, // a stopped PCC partial, and no label on the local answer after a fallback). +// Review: Kev + claude-opus-4-6, 2026-09-22 — toolTraceFooter now visible DURING streaming (was gated +// on status != .streaming); tools show live as each dispatches, with a content transition. Confidence 0.85. import AppKit import M1K3Chat @@ -163,20 +165,22 @@ struct MessageView: View { } } - /// Persisted provenance: which tools served this turn. The live activity - /// label vanishes the moment tokens stream — this line is what survives in - /// the transcript, so tool use (and what left the device) is never - /// invisible after the fact. Rendered on failed turns too, not just - /// complete ones — a turn that searched the web and THEN failed is exactly - /// when "what left the device" matters most (bot review, PR #132). Leaked + /// Persisted provenance: which tools served this turn. Visible LIVE as + /// each tool dispatches — the activity label vanishes the moment tokens + /// stream, but this line stays so tool use (and what left the device) is + /// never invisible, during OR after the turn. Rendered on failed turns + /// too — a turn that searched the web and THEN failed is exactly when + /// "what left the device" matters most (bot review, PR #132). Leaked /// turns can't reach here with a trace: the guard nils it with the rest. @ViewBuilder private var toolTraceFooter: some View { - if message.status != .streaming, let tools = message.toolsUsed, !tools.isEmpty { + if let tools = message.toolsUsed, !tools.isEmpty { Label(ActivityLabeler.traceLabel(for: tools), systemImage: "wrench.and.screwdriver") .font(.caption2) .foregroundStyle(.secondary) .textSelection(.enabled) + .contentTransition(.numericText()) + .animation(.easeOut(duration: 0.2), value: tools) } } diff --git a/macos/M1K3iOSApp/MessageBubble.swift b/macos/M1K3iOSApp/MessageBubble.swift index 942df158e..d6651be6a 100644 --- a/macos/M1K3iOSApp/MessageBubble.swift +++ b/macos/M1K3iOSApp/MessageBubble.swift @@ -14,6 +14,9 @@ // the FOLLOWUPS chips the shared ChatSession was already populating are finally // rendered — tap-to-send via `onSendFollowUp`, mirroring MessageView. // Review: Kev + claude-fable-5.1, 2026-09-08 — "Stopped" caption for an `interrupted` answer. Confidence now 0.8. +// Review: Kev + claude-opus-4-6, 2026-09-22 — added toolTraceRow (the persisted +// "Used web search · date & time" provenance line), visible live during and after +// the turn. iOS had no tool trace at all. Confidence 0.85. // import M1K3Chat @@ -87,6 +90,7 @@ struct MessageBubble: View { if !message.sources.isEmpty { sourcesRow(message.sources) } + toolTraceRow followUpChips } .frame(maxWidth: .infinity, alignment: .leading) @@ -102,6 +106,18 @@ struct MessageBubble: View { } } + @ViewBuilder + private var toolTraceRow: some View { + if let tools = message.toolsUsed, !tools.isEmpty { + Label(ActivityLabeler.traceLabel(for: tools), systemImage: "wrench.and.screwdriver") + .font(.caption2) + .foregroundStyle(.secondary) + .contentTransition(.numericText()) + .animation(.easeOut(duration: 0.2), value: tools) + .padding(.top, 2) + } + } + private func sourcesRow(_ sources: [ChunkHit]) -> some View { let titles = Array(Set(sources.map(\.itemTitle))).prefix(4) return HStack(spacing: 6) { diff --git a/macos/Sources/M1K3Chat/ResponderActivity.swift b/macos/Sources/M1K3Chat/ResponderActivity.swift index 87dfc3a5e..bebd4173e 100644 --- a/macos/Sources/M1K3Chat/ResponderActivity.swift +++ b/macos/Sources/M1K3Chat/ResponderActivity.swift @@ -3,10 +3,13 @@ // M1K3Chat // // What the responder is doing while no tokens are streaming — the cover for -// the agent loop's silence. The labeler doubles as the privacy surface: a +// the agent loop’s silence. The labeler doubles as the privacy surface: a // web search always shows its query, so nothing leaves the device invisibly. // // Signed: Kev + claude-fable-5, 2026-06-09, Confidence 0.85, Prior: Unknown +// Review: Kev + claude-opus-4-6, 2026-09-22 — live tool labels for 6 tools that +// fell through to the raw default; default fallback humanises with displayName; +// tool trace footer now visible during streaming, not just after. Confidence 0.85. import Foundation @@ -24,27 +27,23 @@ public enum ActivityLabeler { public static func label(for activity: ResponderActivity) -> String { switch activity { case .retrieving: - // Deliberately NOT "…your knowledge": the every-turn RAG phase used - // to read like the search_knowledge tool, so tool calls looked like - // they fired on every turn when they hadn't (Kev, 2026-08-16). A - // self-action verb keeps the phase and the tool distinguishable. - "Recalling what I know…" + "Recalling what I know\u{2026}" case .thinking: - "Thinking…" + "Thinking\u{2026}" case let .usingTool(name, argument): toolLabel(name: name, argument: argument) } } - /// The transcript's persisted provenance line ("Used web search · date & - /// time") — pinned here rather than composed in the View so the product + /// The transcript's persisted provenance line ("Used web search \u{00B7} date & + /// time") -- pinned here rather than composed in the View so the product /// string is testable. public static func traceLabel(for tools: [String]) -> String { - "Used " + tools.map { displayName(forTool: $0) }.joined(separator: " · ") + "Used " + tools.map { displayName(forTool: $0) }.joined(separator: " \u{00B7} ") } /// Short noun for a tool in the transcript's persisted trace - /// ("Used web search · date & time"). Unknown tools humanize + /// ("Used web search \u{00B7} date & time"). Unknown tools humanize /// (underscores → spaces) rather than leak snake_case into the UI. public static func displayName(forTool name: String) -> String { switch name { @@ -58,6 +57,7 @@ public enum ActivityLabeler { case "list_documents": "documents" case "get_document": "document" case "open_link": "link" + case "recent_activity": "recent activity" default: name.replacingOccurrences(of: "_", with: " ") } } @@ -65,22 +65,34 @@ public enum ActivityLabeler { private static func toolLabel(name: String, argument: String) -> String { switch name { case "web_search": - "Searching the web for “\(truncate(argument))”…" + "Searching the web for \u{201C}\(truncate(argument))\u{201D}\u{2026}" case "fetch_page": - "Reading \(URL(string: argument)?.host() ?? "a web page")…" + "Reading \(URL(string: argument)?.host() ?? "a web page")\u{2026}" case "search_knowledge": - "Searching your knowledge…" + "Searching your knowledge\u{2026}" case "datetime": - "Checking the date & time…" + "Checking the date & time\u{2026}" case "system_status": - "Checking system status…" + "Checking system status\u{2026}" + case "delegate_deep": + "Starting a deep dive\u{2026}" + case "list_documents": + "Scanning your documents\u{2026}" + case "get_document": + "Opening a document\u{2026}" + case "open_link": + "Opening a link\u{2026}" + case "lookup_fact": + "Looking that up\u{2026}" + case "recent_activity": + "Looking back over the week\u{2026}" default: - "Using \(name)…" + "Using \(displayName(forTool: name))\u{2026}" } } private static func truncate(_ query: String) -> String { guard query.count > queryCap else { return query } - return query.prefix(queryCap).trimmingCharacters(in: .whitespaces) + "…" + return query.prefix(queryCap).trimmingCharacters(in: .whitespaces) + "\u{2026}" } } diff --git a/macos/Tests/M1K3ChatTests/ActivityTests.swift b/macos/Tests/M1K3ChatTests/ActivityTests.swift index 14f5e7935..7cbe5fcdb 100644 --- a/macos/Tests/M1K3ChatTests/ActivityTests.swift +++ b/macos/Tests/M1K3ChatTests/ActivityTests.swift @@ -26,6 +26,18 @@ struct ActivityLabelerTests { == "Checking the date & time…") #expect(ActivityLabeler.label(for: .usingTool(name: "system_status", argument: "")) == "Checking system status…") + #expect(ActivityLabeler.label(for: .usingTool(name: "delegate_deep", argument: "")) + == "Starting a deep dive…") + #expect(ActivityLabeler.label(for: .usingTool(name: "list_documents", argument: "")) + == "Scanning your documents…") + #expect(ActivityLabeler.label(for: .usingTool(name: "get_document", argument: "")) + == "Opening a document…") + #expect(ActivityLabeler.label(for: .usingTool(name: "open_link", argument: "")) + == "Opening a link…") + #expect(ActivityLabeler.label(for: .usingTool(name: "lookup_fact", argument: "")) + == "Looking that up…") + #expect(ActivityLabeler.label(for: .usingTool(name: "recent_activity", argument: "")) + == "Looking back over the week…") } @Test("fetch_page shows which site is being read") @@ -45,7 +57,7 @@ struct ActivityLabelerTests { #expect(ActivityLabeler.label(for: .retrieving) == "Recalling what I know…") #expect(ActivityLabeler.label(for: .thinking(iteration: 0)) == "Thinking…") #expect(ActivityLabeler.label(for: .usingTool(name: "query_graph", argument: "x")) - == "Using query_graph…") + == "Using query graph…") } @Test("tools have short display names for the transcript trace") From 0473e9f1a1f4f683eca07b2d99da7b94a5131dc8 Mon Sep 17 00:00:00 2001 From: Kevin Murphy Date: Tue, 22 Sep 2026 17:51:51 +0100 Subject: [PATCH 2/2] review fold: restore ResponderActivity typography + drop numericText MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ResponderActivity.swift: restored from master and applied surgical edits only (6 new cases + humanised default). The full-file rewrite had dropped the Kev 2026-08-16 comment and mangled doc-comment typography into \u{} escapes that render as literal text. Review line added. - Dropped .contentTransition(.numericText()) on both platforms — it's designed for digit-rolling and its behaviour on non-numeric strings is undocumented; the .animation alone handles the insertion. Co-Authored-By: Claude Opus 4.6 (1M context) Claude-Session: https://claude.ai/code/session_01ELMgNSM3gNKL6kFm16wpyX # ── MurphySig ────────────────────────────────────────────────────── # Significant decision in this commit? Sign it. https://murphysig.dev/spec # ─────────────────────────────────────────────────────────────────── --- macos/M1K3App/MessageView.swift | 1 - macos/M1K3iOSApp/MessageBubble.swift | 1 - .../Sources/M1K3Chat/ResponderActivity.swift | 49 ++++++++++--------- 3 files changed, 26 insertions(+), 25 deletions(-) diff --git a/macos/M1K3App/MessageView.swift b/macos/M1K3App/MessageView.swift index db3639c5c..0e8f2365c 100644 --- a/macos/M1K3App/MessageView.swift +++ b/macos/M1K3App/MessageView.swift @@ -179,7 +179,6 @@ struct MessageView: View { .font(.caption2) .foregroundStyle(.secondary) .textSelection(.enabled) - .contentTransition(.numericText()) .animation(.easeOut(duration: 0.2), value: tools) } } diff --git a/macos/M1K3iOSApp/MessageBubble.swift b/macos/M1K3iOSApp/MessageBubble.swift index d6651be6a..bf5607344 100644 --- a/macos/M1K3iOSApp/MessageBubble.swift +++ b/macos/M1K3iOSApp/MessageBubble.swift @@ -112,7 +112,6 @@ struct MessageBubble: View { Label(ActivityLabeler.traceLabel(for: tools), systemImage: "wrench.and.screwdriver") .font(.caption2) .foregroundStyle(.secondary) - .contentTransition(.numericText()) .animation(.easeOut(duration: 0.2), value: tools) .padding(.top, 2) } diff --git a/macos/Sources/M1K3Chat/ResponderActivity.swift b/macos/Sources/M1K3Chat/ResponderActivity.swift index bebd4173e..aca13ca5a 100644 --- a/macos/Sources/M1K3Chat/ResponderActivity.swift +++ b/macos/Sources/M1K3Chat/ResponderActivity.swift @@ -3,13 +3,13 @@ // M1K3Chat // // What the responder is doing while no tokens are streaming — the cover for -// the agent loop’s silence. The labeler doubles as the privacy surface: a +// the agent loop's silence. The labeler doubles as the privacy surface: a // web search always shows its query, so nothing leaves the device invisibly. // // Signed: Kev + claude-fable-5, 2026-06-09, Confidence 0.85, Prior: Unknown // Review: Kev + claude-opus-4-6, 2026-09-22 — live tool labels for 6 tools that -// fell through to the raw default; default fallback humanises with displayName; -// tool trace footer now visible during streaming, not just after. Confidence 0.85. +// fell through to the raw default; default fallback humanises via displayName. +// Confidence 0.85. import Foundation @@ -27,23 +27,27 @@ public enum ActivityLabeler { public static func label(for activity: ResponderActivity) -> String { switch activity { case .retrieving: - "Recalling what I know\u{2026}" + // Deliberately NOT "…your knowledge": the every-turn RAG phase used + // to read like the search_knowledge tool, so tool calls looked like + // they fired on every turn when they hadn't (Kev, 2026-08-16). A + // self-action verb keeps the phase and the tool distinguishable. + "Recalling what I know…" case .thinking: - "Thinking\u{2026}" + "Thinking…" case let .usingTool(name, argument): toolLabel(name: name, argument: argument) } } - /// The transcript's persisted provenance line ("Used web search \u{00B7} date & - /// time") -- pinned here rather than composed in the View so the product + /// The transcript's persisted provenance line ("Used web search · date & + /// time") — pinned here rather than composed in the View so the product /// string is testable. public static func traceLabel(for tools: [String]) -> String { - "Used " + tools.map { displayName(forTool: $0) }.joined(separator: " \u{00B7} ") + "Used " + tools.map { displayName(forTool: $0) }.joined(separator: " · ") } /// Short noun for a tool in the transcript's persisted trace - /// ("Used web search \u{00B7} date & time"). Unknown tools humanize + /// ("Used web search · date & time"). Unknown tools humanize /// (underscores → spaces) rather than leak snake_case into the UI. public static func displayName(forTool name: String) -> String { switch name { @@ -57,7 +61,6 @@ public enum ActivityLabeler { case "list_documents": "documents" case "get_document": "document" case "open_link": "link" - case "recent_activity": "recent activity" default: name.replacingOccurrences(of: "_", with: " ") } } @@ -65,34 +68,34 @@ public enum ActivityLabeler { private static func toolLabel(name: String, argument: String) -> String { switch name { case "web_search": - "Searching the web for \u{201C}\(truncate(argument))\u{201D}\u{2026}" + "Searching the web for “\(truncate(argument))”…" case "fetch_page": - "Reading \(URL(string: argument)?.host() ?? "a web page")\u{2026}" + "Reading \(URL(string: argument)?.host() ?? "a web page")…" case "search_knowledge": - "Searching your knowledge\u{2026}" + "Searching your knowledge…" case "datetime": - "Checking the date & time\u{2026}" + "Checking the date & time…" case "system_status": - "Checking system status\u{2026}" + "Checking system status…" case "delegate_deep": - "Starting a deep dive\u{2026}" + "Starting a deep dive…" case "list_documents": - "Scanning your documents\u{2026}" + "Scanning your documents…" case "get_document": - "Opening a document\u{2026}" + "Opening a document…" case "open_link": - "Opening a link\u{2026}" + "Opening a link…" case "lookup_fact": - "Looking that up\u{2026}" + "Looking that up…" case "recent_activity": - "Looking back over the week\u{2026}" + "Looking back over the week…" default: - "Using \(displayName(forTool: name))\u{2026}" + "Using \(displayName(forTool: name))…" } } private static func truncate(_ query: String) -> String { guard query.count > queryCap else { return query } - return query.prefix(queryCap).trimmingCharacters(in: .whitespaces) + "\u{2026}" + return query.prefix(queryCap).trimmingCharacters(in: .whitespaces) + "…" } }