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,
|
AgentEvent(sessionID: sessionID, seq: seq, at: Date(), backend: .claudeCode,
|
||||||
nativeType: nil, kind: kind)
|
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 = [
|
openEvents = [
|
||||||
event(1, .sessionStarted(SessionStarted(
|
event(1, .sessionStarted(SessionStarted(
|
||||||
backendSessionID: "demo", model: "claude-opus-4-8[1m]", cwd: "~/code/nucleic", toolNames: []))),
|
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(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(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(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(14, .toolCallStarted(ToolCall(toolCallID: "t4", name: "Bash", input: ["command": .string(demoCommitCommand)]))),
|
||||||
event(15, .runFinished(RunFinished(outcome: .completed, finalText: "Done."))),
|
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
|
// 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).
|
// 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
|
// MARK: - ToolGroup convenience
|
||||||
|
|
||||||
extension ToolGroup {
|
extension ToolGroup {
|
||||||
|
|||||||
@@ -43,9 +43,17 @@ struct ToolCallCard: View {
|
|||||||
.contentShape(Rectangle())
|
.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 {
|
@ViewBuilder private var details: some View {
|
||||||
let input = group.input.approvalDetail
|
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)
|
ToolBlock(label: "Input", text: input, mono: true)
|
||||||
}
|
}
|
||||||
if let result = group.result {
|
if let result = group.result {
|
||||||
|
|||||||
Reference in New Issue
Block a user