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 <[email protected]>
This commit is contained in:
2026-07-04 00:21:32 -07:00
co-authored by Claude Opus 4.8
parent 34aa01a724
commit 850ee0cd96
4 changed files with 187 additions and 3 deletions
@@ -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).
@@ -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))
}
}
}
}
@@ -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 <dir>` / `-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 {
@@ -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 {