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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user