From 850ee0cd961c9b0e6e02c2df65055fd7e3367917 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sat, 4 Jul 2026 00:21:32 -0700 Subject: [PATCH] iPad: render git commits as a structured card in the transcript Port the desktop GitBlockCard's commit rendering (+ CommandDisclosure) to the remote transcript: a Bash `git commit` now shows the commit subject as a headline and the message body as Markdown, with the raw command one tap away under "Show command" -- instead of a raw `git commit -F - <<'EOF' ...` blob. - GitCommitCard.swift: the card + the CommandDisclosure helper (portable SwiftUI, mirroring Sources/NucleicApp/{GitBlockCard,CommandDisclosure}). - GitCommitSummary.parse (in HostCommandSummary.swift) extracts the message from -m/--message args or a `-F -` heredoc body, reusing the file-private shell Lexer already used for host-exec summaries; skips env prefixes and git global flags, and only fires for real `git commit` segments. - Hooked into ToolCallCard.details, gated to shell tools so non-shell input is never misread as a commit. - Demo transcript gains a git-commit call so the card is exercisable offline. SummaryCard is intentionally not ported -- it's the host-only Apple-Intelligence recap, which a remote has no way to generate. Verified in the iPad simulator (demo mode); parser covered by a standalone test. Co-Authored-By: Claude Opus 4.8 --- .../NucleicRemote/Models/RemoteStore.swift | 10 ++- .../Views/Transcript/GitCommitCard.swift | 89 +++++++++++++++++++ .../Views/Transcript/HostCommandSummary.swift | 81 +++++++++++++++++ .../Views/Transcript/ToolGroupRow.swift | 10 ++- 4 files changed, 187 insertions(+), 3 deletions(-) create mode 100644 NucleicRemote/NucleicRemote/Views/Transcript/GitCommitCard.swift diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index f7e2215..6ceabc8 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -624,6 +624,9 @@ final class RemoteStore: ObservableObject { AgentEvent(sessionID: sessionID, seq: seq, at: Date(), backend: .claudeCode, nativeType: nil, kind: kind) } + // A realistic `git commit` (heredoc message) so the transcript's structured commit card + // is exercisable offline: expand the Bash call to see the subject + Markdown body. + let demoCommitCommand = "git commit -F - <<'EOF'\nfix: harden auth middleware\n\nRequire a Bearer token and reject a missing or blank one.\n\n- extract `requireSession`\n- add a `Bearer` prefix check\nEOF" openEvents = [ event(1, .sessionStarted(SessionStarted( backendSessionID: "demo", model: "claude-opus-4-8[1m]", cwd: "~/code/nucleic", toolNames: []))), @@ -639,8 +642,11 @@ final class RemoteStore: ObservableObject { event(11, .toolCallStarted(ToolCall(toolCallID: "t3", name: "Task", input: ["description": "Audit other call sites", "prompt": "Find every caller of the old auth API."]))), event(12, .toolCallCompleted(ToolCall(toolCallID: "t3", name: "Task", input: ["description": "Audit other call sites"]))), event(13, .toolResult(ToolResult(toolCallID: "t3", content: "Checked 7 files; 1 stale caller updated.", isError: false))), - event(14, .usage(Usage(inputTokens: 84_300, outputTokens: 2_140, costUSD: 0.0421, contextInputTokens: 84_300))), - event(15, .runFinished(RunFinished(outcome: .completed, finalText: "Done."))), + event(14, .toolCallStarted(ToolCall(toolCallID: "t4", name: "Bash", input: ["command": .string(demoCommitCommand)]))), + event(15, .toolCallCompleted(ToolCall(toolCallID: "t4", name: "Bash", input: ["command": .string(demoCommitCommand)]))), + event(16, .toolResult(ToolResult(toolCallID: "t4", content: "[nucleic/auth-refactor 1a2b3c4] fix: harden auth middleware\n 2 files changed, 312 insertions(+), 40 deletions(-)", isError: false))), + event(17, .usage(Usage(inputTokens: 84_300, outputTokens: 2_140, costUSD: 0.0421, contextInputTokens: 84_300))), + event(18, .runFinished(RunFinished(outcome: .completed, finalText: "Done."))), ] // If this session is blocked on a human, surface a real approval card so the // Allow/Deny loop is exercisable in the demo (the seeded `a1` session). diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/GitCommitCard.swift b/NucleicRemote/NucleicRemote/Views/Transcript/GitCommitCard.swift new file mode 100644 index 0000000..d01754b --- /dev/null +++ b/NucleicRemote/NucleicRemote/Views/Transcript/GitCommitCard.swift @@ -0,0 +1,89 @@ +import SwiftUI + +/// A `git commit` rendered as a structured card — the remote's echo of the desktop `GitBlockCard`: +/// the commit subject reads as a headline and the message body as Markdown, so a commit in the +/// transcript is legible at a glance instead of a raw `git commit -F - <<'EOF' …` blob. The literal +/// command stays one tap away under "Show command", so the card never hides what actually ran. +struct GitCommitCard: View { + let commit: GitCommitSummary.Commit + /// The literal command, revealed under "Show command". Omit to hide the disclosure. + var rawCommand: String? = nil + + private var accent: Color { Palette.accent } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .firstTextBaseline, spacing: 9) { + Image(systemName: commit.amend ? "pencil.circle" : "checkmark.seal") + .font(.callout).foregroundStyle(accent).frame(width: 16) + VStack(alignment: .leading, spacing: 2) { + Text(commit.amend ? "Amend commit" : "Commit") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + if !commit.subject.isEmpty { + Text(commit.subject) + .font(.callout.weight(.semibold)) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + } + } + } + if !commit.body.isEmpty { + // The body is real commit-message Markdown (paragraphs, bullet lists) — render it + // formatted, indented under the subject. + MarkdownText(markdown: commit.body) + .padding(.leading, 25) + .textSelection(.enabled) + } + if let rawCommand, !rawCommand.isEmpty { + CommandDisclosure(command: rawCommand, accent: accent) + } + } + .padding(.horizontal, 12).padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(accent.opacity(0.06)) + .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(accent.opacity(0.16), lineWidth: 1)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } +} + +/// A disclosure that reveals the literal shell command beneath a structured command card — so a +/// command rendered as a GUI element never hides what's actually being run. The remote's port of +/// the desktop `CommandDisclosure`. +struct CommandDisclosure: View { + /// The literal command to reveal. + let command: String + /// The card's accent, applied to the toggle and the revealed block. + let accent: Color + + @State private var showCommand = false + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Button { + withAnimation(.easeInOut(duration: 0.15)) { showCommand.toggle() } + } label: { + HStack(spacing: 5) { + Image(systemName: "chevron.right") + .font(.caption2.weight(.semibold)) + .rotationEffect(.degrees(showCommand ? 90 : 0)) + Text(showCommand ? "Hide command" : "Show command") + .font(.caption.weight(.medium)) + } + .foregroundStyle(accent) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + if showCommand { + Text(command) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + .background(accent.opacity(0.05), in: RoundedRectangle(cornerRadius: 6)) + } + } + } +} diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/HostCommandSummary.swift b/NucleicRemote/NucleicRemote/Views/Transcript/HostCommandSummary.swift index 500038d..a4f51a1 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/HostCommandSummary.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/HostCommandSummary.swift @@ -577,6 +577,87 @@ private enum Lexer { } } +// MARK: - Git commit summary + +/// Pulls the commit message out of a command that runs `git commit`, so a commit reads as a +/// structured card (subject headline + Markdown body) instead of a raw `git commit -F - <<'EOF' …` +/// blob — the remote's echo of the desktop `GitCommandSummary`/`GitBlockCard` (which live in +/// NucleicCore, unavailable here). Handles the two shapes agents actually use: `-m`/`--message` +/// arguments and a `-F -`/`--file=-` heredoc body. Parsed from the command string alone, reusing +/// the same shell-aware ``Lexer`` the host-exec summary uses. +enum GitCommitSummary { + struct Commit: Equatable { + var subject: String + var body: String + var amend: Bool + } + + static func parse(_ command: String) -> Commit? { + let (flattened, bodies) = Lexer.stripHeredocs(command) + guard let args = commitArguments(in: flattened) else { return nil } + + let amend = args.contains("--amend") + var messages: [String] = [] + var usesStdinFile = false + var i = 0 + while i < args.count { + let a = args[i] + if a == "-m" || a == "--message" { + if i + 1 < args.count { messages.append(args[i + 1]); i += 2; continue } + } else if a.hasPrefix("--message=") { + messages.append(String(a.dropFirst("--message=".count))) + } else if a.hasPrefix("-m"), a.count > 2 { + messages.append(String(a.dropFirst(2))) + } else if (a == "-F" || a == "--file"), i + 1 < args.count, args[i + 1] == "-" { + usesStdinFile = true + } else if a == "--file=-" || a == "-F-" { + usesStdinFile = true + } + i += 1 + } + + // Prefer explicit `-m` messages; otherwise a `-F -` heredoc body is the message. + let message: String + if !messages.isEmpty { + message = messages.joined(separator: "\n\n") + } else if usesStdinFile, + let body = bodies.first(where: { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) { + message = body + } else { + return nil + } + + // Subject = first non-blank line; body = everything after it. + let lines = message.components(separatedBy: "\n") + guard let subjectIndex = lines.firstIndex(where: { !$0.trimmingCharacters(in: .whitespaces).isEmpty }) else { + return nil + } + let subject = lines[subjectIndex].trimmingCharacters(in: .whitespaces) + let body = lines[(subjectIndex + 1)...].joined(separator: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + return Commit(subject: subject, body: body, amend: amend) + } + + /// The arguments after `git commit` in whichever segment runs it (skipping env assignments and + /// git's global flags like `-C ` / `-c k=v`), or nil when no segment is a `git commit`. + private static func commitArguments(in flattened: String) -> [String]? { + for segment in Lexer.splitSegments(flattened) { + var tokens = Lexer.tokenize(segment) + while let first = tokens.first, Lexer.isEnvAssignment(first) { tokens.removeFirst() } + guard tokens.first == "git" else { continue } + var rest = Array(tokens.dropFirst()) + while let flag = rest.first, flag.hasPrefix("-") { + let takesValue = (flag == "-C" || flag == "-c") + rest.removeFirst() + if takesValue, !rest.isEmpty { rest.removeFirst() } + } + guard rest.first == "commit" else { continue } + return Array(rest.dropFirst()) + } + return nil + } +} + // MARK: - ToolGroup convenience extension ToolGroup { diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift b/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift index 6e5da10..f097150 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift @@ -43,9 +43,17 @@ struct ToolCallCard: View { .contentShape(Rectangle()) } + /// Only shell tools carry a literal command worth structuring (a git commit); everything else + /// keeps the plain input block, so a non-shell input can't be misread as a commit. + private var isShellTool: Bool { group.name == "Bash" || group.name == "Shell" } + @ViewBuilder private var details: some View { let input = group.input.approvalDetail - if !input.isEmpty { + // A git commit reads as a structured commit card (subject + Markdown body), like the Mac — + // the raw command stays one tap away under "Show command". + if isShellTool, let commit = GitCommitSummary.parse(input) { + GitCommitCard(commit: commit, rawCommand: input) + } else if !input.isEmpty { ToolBlock(label: "Input", text: input, mono: true) } if let result = group.result {