Merge claude/elastic-haibt-f29115 into dev (iPad remote port: adaptive sidebar+detail shell, Mac-style two-pane diff, structured commit/command transcript cards, keyboard/pointer affordances, camera-free manual pairing)

This commit is contained in:
2026-07-04 00:54:14 -07:00
14 changed files with 776 additions and 70 deletions
@@ -1,6 +1,9 @@
import Foundation import Foundation
import Security import Security
import NucleicProtocol import NucleicProtocol
#if canImport(UIKit)
import UIKit
#endif
/// What the phone pins about its Mac at pairing (SYNC §4.2): the host's static key (for IK /// What the phone pins about its Mac at pairing (SYNC §4.2): the host's static key (for IK
/// reconnect), a display name, and the transport + connection hint from the QR — LAN /// reconnect), a display name, and the transport + connection hint from the QR — LAN
@@ -42,11 +45,22 @@ enum IdentityStore {
static func deviceID() -> String { static func deviceID() -> String {
let defaults = UserDefaults.standard let defaults = UserDefaults.standard
if let existing = defaults.string(forKey: deviceIDKey) { return existing } if let existing = defaults.string(forKey: deviceIDKey) { return existing }
let id = "iphone-" + UUID().uuidString.prefix(8).lowercased() let id = deviceIDPrefix + UUID().uuidString.prefix(8).lowercased()
defaults.set(id, forKey: deviceIDKey) defaults.set(id, forKey: deviceIDKey)
return id return id
} }
/// Idiom-tagged prefix so the host lists a paired device with the right kind/icon
/// (`ipad-…` vs `iphone-…`). Only stamps *freshly generated* ids — an existing install
/// keeps whatever id it already persisted, so upgrading a phone never changes its identity.
private static var deviceIDPrefix: String {
#if canImport(UIKit)
return UIDevice.current.userInterfaceIdiom == .pad ? "ipad-" : "iphone-"
#else
return "iphone-"
#endif
}
static func loadPairedHost() -> PairedHost? { static func loadPairedHost() -> PairedHost? {
guard let data = UserDefaults.standard.data(forKey: pairedHostKey) else { return nil } guard let data = UserDefaults.standard.data(forKey: pairedHostKey) else { return nil }
return try? JSONDecoder().decode(PairedHost.self, from: data) return try? JSONDecoder().decode(PairedHost.self, from: data)
@@ -295,6 +295,17 @@ final class RemoteStore: ObservableObject {
+ if (!token) throw new AuthError("missing bearer token") + if (!token) throw new AuthError("missing bearer token")
return verify(token) return verify(token)
} }
diff --git a/auth/session.ts b/auth/session.ts
new file mode 100644
--- /dev/null
+++ b/auth/session.ts
@@ -0,0 +1,6 @@
+export interface Session {
+ userId: string
+ issuedAt: number
+}
+
+export const SESSION_TTL = 3600
""") """)
} }
@@ -618,6 +629,12 @@ 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"
// A multi-step, destructive shell pipeline so the transcript's step list (with the delete
// flagged in red) is exercisable offline: expand the Bash call to see the breakdown.
let demoCleanupCommand = "cd ~/code/nucleic && rm -rf .worktrees/auth-old && git worktree prune && git branch -D nucleic/auth-old"
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: []))),
@@ -633,8 +650,14 @@ 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, .toolCallStarted(ToolCall(toolCallID: "t5", name: "Bash", input: ["command": .string(demoCleanupCommand)]))),
event(18, .toolCallCompleted(ToolCall(toolCallID: "t5", name: "Bash", input: ["command": .string(demoCleanupCommand)]))),
event(19, .toolResult(ToolResult(toolCallID: "t5", content: "Removed 1 worktree; deleted branch nucleic/auth-old.", isError: false))),
event(20, .usage(Usage(inputTokens: 84_300, outputTokens: 2_140, costUSD: 0.0421, contextInputTokens: 84_300))),
event(21, .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).
@@ -653,11 +676,17 @@ final class RemoteStore: ObservableObject {
ApprovalID(rawValue: "demo-appr-\(sessionID.rawValue)") ApprovalID(rawValue: "demo-appr-\(sessionID.rawValue)")
} }
func closeOpen() { /// Close a session's live subscription. `id` names *which* session is closing — the detail
if let id = openSessionID { /// view passes its own. On iPad's split view, switching session A→B can mount B (which calls
send(.unsubscribe(id)) /// `open(B)`, setting `openSessionID = B`) *before* A's detail disappears; so we always
markOpened(id) // everything up to now has been seen /// unsubscribe the named session but only tear down the shared open-state when it still
} /// belongs to that session — otherwise we'd wipe B's freshly-loaded transcript. Called with
/// no argument it closes whatever is currently open (the iPhone push/pop path, unchanged).
func closeOpen(_ id: SessionID? = nil) {
guard let target = id ?? openSessionID else { return }
send(.unsubscribe(target))
markOpened(target) // everything up to now has been seen
guard openSessionID == target else { return }
openSessionID = nil openSessionID = nil
openEvents = [] openEvents = []
openApprovals = [] openApprovals = []
@@ -36,8 +36,6 @@ struct NucleicRemoteApp: App {
struct RootView: View { struct RootView: View {
@EnvironmentObject var store: RemoteStore @EnvironmentObject var store: RemoteStore
// Initial tab; overridable via NUCLEIC_TAB for offline UI previews.
@State private var tab = Int(ProcessInfo.processInfo.environment["NUCLEIC_TAB"] ?? "") ?? 0
// Appearance preferences — same storage keys and semantics as the Mac's Settings → // Appearance preferences — same storage keys and semantics as the Mac's Settings →
// Appearance, so both devices honor the same choices. // Appearance, so both devices honor the same choices.
@AppStorage(AppAppearance.storageKey) private var appearanceRaw = AppAppearance.system.rawValue @AppStorage(AppAppearance.storageKey) private var appearanceRaw = AppAppearance.system.rawValue
@@ -52,30 +50,15 @@ struct RootView: View {
DemoBanner() DemoBanner()
Group { Group {
if store.isPaired { if store.isPaired {
TabView(selection: $tab) { // Width-adaptive shell: the iPhone's TabView on compact width, the Mac's
HomeView() // sidebar+detail split on a regular-width iPad (see AdaptiveRootView).
.tabItem { Label("Home", systemImage: "house") }.tag(0) AdaptiveRootView()
SessionsView()
.tabItem { Label("Sessions", systemImage: "square.stack.3d.up") }
.badge(store.needsYouCount).tag(1)
ProjectsView()
.tabItem { Label("Projects", systemImage: "folder") }.tag(2)
TodosView()
.tabItem { Label("To-dos", systemImage: "checklist") }.tag(3)
SettingsView()
.tabItem { Label("Settings", systemImage: "gearshape") }.tag(4)
}
} else { } else {
PairingIntroView() PairingIntroView()
} }
} }
} }
.overlay(alignment: .bottom) { ErrorBubble() } .overlay(alignment: .bottom) { ErrorBubble() }
// A notification tap routes to its session: jump to the Sessions tab, where
// `SessionsView` consumes `pendingRoute` and pushes the detail.
.onChange(of: store.pendingRoute) { _, route in
if route != nil { tab = 1 }
}
.tint(Palette.accent) .tint(Palette.accent)
.preferredColorScheme((AppAppearance(rawValue: appearanceRaw) ?? .system).colorScheme) .preferredColorScheme((AppAppearance(rawValue: appearanceRaw) ?? .system).colorScheme)
.dynamicTypeSize((AppTextSize(rawValue: textSizeRaw) ?? .medium).dynamicTypeSize) .dynamicTypeSize((AppTextSize(rawValue: textSizeRaw) ?? .medium).dynamicTypeSize)
@@ -0,0 +1,213 @@
import SwiftUI
import NucleicProtocol
/// Chooses the navigation shell by width so one universal binary serves both idioms:
/// • **compact** (iPhone, and iPad in Slide Over / narrow split) → the five-tab `TabView`.
/// • **regular** (iPad full-screen / Stage Manager) → the Mac's sidebar + detail split.
///
/// Both render the SAME `RemoteStore` projection — the iPad is a third renderer of the one host
/// authority (UX_MACOS §8), not a fork. The store holds all real state, so a size-class flip
/// (e.g. resizing a Stage Manager window) only swaps chrome; sessions, connection, and the open
/// transcript survive it.
struct AdaptiveRootView: View {
@Environment(\.horizontalSizeClass) private var sizeClass
var body: some View {
if sizeClass == .regular {
SplitRootView()
} else {
CompactRootView()
}
}
}
// MARK: - Compact (iPhone / narrow multitasking)
/// The original iPhone shell, relocated verbatim from `RootView`: a five-tab `TabView`. A
/// notification tap routes to the Sessions tab, where `SessionsView` consumes `pendingRoute`
/// and pushes the detail.
struct CompactRootView: View {
@EnvironmentObject var store: RemoteStore
// Initial tab; overridable via NUCLEIC_TAB for offline UI previews.
@State private var tab = Int(ProcessInfo.processInfo.environment["NUCLEIC_TAB"] ?? "") ?? 0
var body: some View {
TabView(selection: $tab) {
HomeView()
.tabItem { Label("Home", systemImage: "house") }.tag(0)
SessionsView()
.tabItem { Label("Sessions", systemImage: "square.stack.3d.up") }
.badge(store.needsYouCount).tag(1)
ProjectsView()
.tabItem { Label("Projects", systemImage: "folder") }.tag(2)
TodosView()
.tabItem { Label("To-dos", systemImage: "checklist") }.tag(3)
SettingsView()
.tabItem { Label("Settings", systemImage: "gearshape") }.tag(4)
}
// A notification tap routes to its session by jumping to the Sessions tab.
.onChange(of: store.pendingRoute) { _, route in
if route != nil { tab = 1 }
}
}
}
// MARK: - Regular (iPad sidebar + detail)
/// What the sidebar can select. Mirrors the Mac sidebar: a few global destinations plus the
/// live sessions themselves (grouped by project). Projects are reached through the `projects`
/// destination (`ProjectsView`), which also hosts the scoped "start a chat here" composer.
enum SidebarItem: Hashable {
case home
case projects
case todos
case settings
case session(SessionID)
}
/// The Mac's information architecture on iPad: a two-column `NavigationSplitView` whose leading
/// column carries the destinations + the project→session tree, and whose detail is the selected
/// session (or a destination view). Selection lives here and binds the same `openSessionID`
/// lifecycle the iPhone push/pop path uses — the detail is keyed by session id so switching one
/// session for another fires `open`/`closeOpen` exactly as a push/pop would.
struct SplitRootView: View {
@EnvironmentObject var store: RemoteStore
@State private var selection: SidebarItem? = .home
@State private var columnVisibility: NavigationSplitViewVisibility = .all
var body: some View {
NavigationSplitView(columnVisibility: $columnVisibility) {
SplitSidebar(selection: $selection)
} detail: {
SplitDetail(selection: selection)
}
.navigationSplitViewStyle(.balanced)
// Notification tap → select its session directly (the regular-width analogue of the
// compact path's jump-to-Sessions-tab). Then clear the request so it isn't re-consumed.
.onChange(of: store.pendingRoute) { _, route in
guard let route else { return }
selection = .session(route)
store.pendingRoute = nil
}
.onAppear {
// Offline UI previews (NUCLEIC_DEMO): open straight into one session.
if let raw = ProcessInfo.processInfo.environment["NUCLEIC_DEMO_SESSION"], !raw.isEmpty {
selection = .session(SessionID(rawValue: raw))
}
}
}
}
/// The leading column: global destinations, then the live sessions grouped under their projects
/// (attention-first within each group), mirroring the Mac sidebar tree. The persistent
/// connection status sits in the footer — one home for it, versus the phone's per-tab inset.
private struct SplitSidebar: View {
@EnvironmentObject var store: RemoteStore
@Binding var selection: SidebarItem?
/// Live sessions grouped under their project (dashboard order), with anything whose project
/// isn't in the dashboard collected into a trailing "Other" group. Attention-first sort.
private var projectGroups: [(name: String, rows: [WireSessionSummary])] {
let live = store.liveSessions.sorted {
let a = StatusStyle.sortRank($0), b = StatusStyle.sortRank($1)
return a == b ? $0.updatedAt > $1.updatedAt : a < b
}
var groups: [(name: String, rows: [WireSessionSummary])] = []
var placed = Set<SessionID>()
for project in store.dashboard.projects {
let rows = live.filter { $0.projectID == project.id.rawValue }
guard !rows.isEmpty else { continue }
groups.append((project.name, rows))
rows.forEach { placed.insert($0.sessionID) }
}
let orphans = live.filter { !placed.contains($0.sessionID) }
if !orphans.isEmpty { groups.append(("Other", orphans)) }
return groups
}
var body: some View {
List(selection: $selection) {
Section {
Label("Home", systemImage: "house").tag(SidebarItem.home)
Label("Projects", systemImage: "folder").tag(SidebarItem.projects)
Label("To-dos", systemImage: "checklist").tag(SidebarItem.todos)
Label("Settings", systemImage: "gearshape").tag(SidebarItem.settings)
}
if projectGroups.isEmpty {
Section("Sessions") {
Text(store.connectivity.isLive
? "Start a session from Home or the Mac to see it here."
: "Waiting to connect to \(store.hostName)…")
.font(.callout).foregroundStyle(.secondary)
}
} else {
ForEach(projectGroups, id: \.name) { group in
Section(group.name) {
ForEach(group.rows, id: \.sessionID) { summary in
SessionRow(summary: summary, showProjectName: false)
.tag(SidebarItem.session(summary.sessionID))
.listRowBackground(SessionRowWash(summary: summary))
.swipeActions(edge: .leading) {
Button {
store.setFavorite(summary.sessionID, !summary.favorite)
} label: {
Label("Favorite", systemImage: summary.favorite ? "star.slash" : "star.fill")
}.tint(.yellow)
}
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
store.deleteSession(summary.sessionID)
} label: { Label("Delete", systemImage: "trash") }
Button {
store.setArchived(summary.sessionID, !summary.archived)
} label: {
Label(summary.archived ? "Unarchive" : "Archive",
systemImage: summary.archived ? "tray.and.arrow.up" : "archivebox")
}.tint(.gray)
}
}
}
}
}
}
.listStyle(.sidebar)
.navigationTitle(store.hostName.isEmpty ? "Nucleic" : store.hostName)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button { store.refreshSessions() } label: {
Image(systemName: "arrow.clockwise")
}
.help("Refresh")
}
}
.safeAreaInset(edge: .bottom) {
ConnectionChip().padding(.bottom, 10)
}
}
}
/// The detail column. A selected session reuses the existing `SessionDetailView` unchanged
/// (it owns its own subscribe/unsubscribe on appear/disappear); it's keyed `.id(sessionID)` so
/// switching sessions remounts and drives that lifecycle. The destination views already embed
/// their own `NavigationStack`, so they're shown directly.
private struct SplitDetail: View {
let selection: SidebarItem?
var body: some View {
switch selection {
case .home, .none:
HomeView()
case .projects:
ProjectsView()
case .todos:
TodosView()
case .settings:
SettingsView()
case .session(let id):
NavigationStack {
SessionDetailView(sessionID: id)
}
.id(id)
}
}
}
@@ -33,13 +33,17 @@ struct ApprovalCardView: View {
.background(approval.risk.color.opacity(0.2), in: Capsule()) .background(approval.risk.color.opacity(0.2), in: Capsule())
.foregroundStyle(approval.risk.color) .foregroundStyle(approval.risk.color)
} }
// A host_exec gate escapes the sandbox onto the macOS host, so lay out what it's // A git commit reads as a structured commit card (subject + Markdown body) so you can
// about to run — the inferred purpose and the program/actions/flags, parsed from the // see exactly what you're granting; the literal command stays under "Show command".
// command itself (never from the agent) — then the exact command beneath it. Every // Otherwise a host_exec gate lays out its parsed breakdown + exact command, and every
// other tool shows its untruncated detail in a single scrollable box. // other tool shows its untruncated detail in a single scrollable box.
if approval.toolName == HostCommandSummary.hostExecToolName, if let command = approval.input["command"]?.stringValue,
let command = approval.input["command"]?.stringValue, let commit = GitCommitSummary.parse(command) {
let parsed = HostCommandSummary.summary(for: command) { GitCommitCard(commit: commit, rawCommand: command)
} else if let command = approval.input["command"]?.stringValue,
let parsed = HostCommandSummary.summary(for: command),
approval.toolName == HostCommandSummary.hostExecToolName
|| parsed.invocations.count > 1 || parsed.isDestructive {
HostCommandBreakdown(summary: parsed) HostCommandBreakdown(summary: parsed)
Text("Exact command").font(.caption2.weight(.semibold)).foregroundStyle(.secondary) Text("Exact command").font(.caption2.weight(.semibold)).foregroundStyle(.secondary)
ApprovalDetailBox(text: command) ApprovalDetailBox(text: command)
@@ -66,6 +70,9 @@ struct ApprovalCardView: View {
Text("Deny").frame(maxWidth: .infinity) Text("Deny").frame(maxWidth: .infinity)
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
// Hardware-keyboard shortcuts for the defining interaction: Esc denies, Return
// allows (the approval bar replaces the composer, so Return is unclaimed here).
.keyboardShortcut(.cancelAction)
Button { Button {
store.respond(approval, .allow()) store.respond(approval, .allow())
@@ -74,6 +81,7 @@ struct ApprovalCardView: View {
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.disabled(!allowEnabled) .disabled(!allowEnabled)
.keyboardShortcut(.defaultAction)
} }
// A destructive action (rm, force-push, reset --hard, …) offers no remembered // A destructive action (rm, force-push, reset --hard, …) offers no remembered
@@ -92,6 +92,7 @@ struct StartChatComposer: View {
Image(systemName: "arrow.up.circle.fill").font(.title) Image(systemName: "arrow.up.circle.fill").font(.title)
} }
.disabled(selected == nil || draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.canControl) .disabled(selected == nil || draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.canControl)
.keyboardShortcut(.return, modifiers: .command)
} }
if !store.canControl { if !store.canControl {
Text("This device is view-only.").font(.caption2).foregroundStyle(.secondary) Text("This device is view-only.").font(.caption2).foregroundStyle(.secondary)
@@ -86,6 +86,9 @@ struct HomeView: View {
StartChatComposer() StartChatComposer()
} }
.padding() .padding()
// Cap + center the dashboard on a wide iPad so it reads like the Mac home rather
// than a stretched phone; a no-op at phone width.
.readableColumn()
} }
.navigationTitle("Home") .navigationTitle("Home")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
@@ -0,0 +1,12 @@
import SwiftUI
/// Width tuning for the universal layout. In a wide iPad detail column, content designed for a
/// phone runs edge-to-edge and loses the Mac's comfortable reading measure; `readableColumn`
/// caps its width and centers it. It's a no-op on the phone and on iPad compact multitasking,
/// where the available width is already below the cap — so the iPhone layout is unchanged.
extension View {
func readableColumn(_ maxWidth: CGFloat = 820) -> some View {
frame(maxWidth: maxWidth, alignment: .leading)
.frame(maxWidth: .infinity, alignment: .center)
}
}
@@ -71,7 +71,9 @@ struct SessionDetailView: View {
Button("Cancel", role: .cancel) {} Button("Cancel", role: .cancel) {}
} }
.onAppear { store.open(sessionID) } .onAppear { store.open(sessionID) }
.onDisappear { store.closeOpen() } // Pass our own id so an iPad split-view A→B switch (which may mount B before A
// disappears) unsubscribes A without tearing down B's just-opened state.
.onDisappear { store.closeOpen(sessionID) }
} }
/// Whether this session's project is under Nucleic Control (gates Orchestra + autoship). /// Whether this session's project is under Nucleic Control (gates Orchestra + autoship).
@@ -221,6 +223,9 @@ struct SessionDetailView: View {
.font(.title2) .font(.title2)
} }
.disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.connectivity.isLive) .disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.connectivity.isLive)
// Hardware-keyboard send (Magic Keyboard on iPad), mirroring the Mac —
// plain Return stays newline in the multiline field.
.keyboardShortcut(.return, modifiers: .command)
} }
} }
} }
@@ -297,6 +302,9 @@ struct TranscriptList: View {
} }
} }
.padding() .padding()
// Cap the transcript to a readable measure on a wide iPad so lines don't run
// edge-to-edge on a 13-inch screen; a no-op at phone width.
.readableColumn()
} }
// A drag on the transcript dismisses the keyboard, so a tall multiline composer can // A drag on the transcript dismisses the keyboard, so a tall multiline composer can
// be put away without leaving the session. // be put away without leaving the session.
@@ -310,25 +318,26 @@ struct TranscriptList: View {
} }
} }
/// The Diff tab with the real patch (the Mac Diff tab, phone form): a per-file summary list /// The Diff tab, read-only over the on-demand `WireSessionDiff`. On a phone it's a per-file
/// over the unified patch, colored +/− per line, fetched on demand via `fetchDiff`. /// summary over one scrolling unified patch; on a regular-width iPad it becomes the Mac's
/// two-pane diff — a selectable file list beside the selected file's patch — exploiting the
/// wide detail column. Same wire, no new protocol.
struct SessionDiffView: View { struct SessionDiffView: View {
let diff: WireSessionDiff? let diff: WireSessionDiff?
let loading: Bool let loading: Bool
/// Below this available width the two-pane diff would leave the patch too cramped, so we
/// keep the phone stack. Picks two-pane on a wide iPad (landscape, or a large iPad) and the
/// stack on a phone or a narrow portrait split — based on real width, not just size class.
private let twoPaneMinWidth: CGFloat = 700
var body: some View { var body: some View {
if let diff, diff.stat.filesChanged > 0 { if let diff, diff.stat.filesChanged > 0 {
ScrollView { GeometryReader { geo in
VStack(alignment: .leading, spacing: 14) { if geo.size.width >= twoPaneMinWidth {
fileList(diff) SplitDiffView(diff: diff)
PatchText(patch: diff.patch) } else {
if diff.truncated { stacked(diff)
Label("Patch truncated — open the Mac for the rest.",
systemImage: "scissors")
.font(.caption).foregroundStyle(.secondary)
}
} }
.padding()
} }
} else if loading { } else if loading {
ProgressView("Fetching diff…") ProgressView("Fetching diff…")
@@ -338,28 +347,123 @@ struct SessionDiffView: View {
} }
} }
private func fileList(_ diff: WireSessionDiff) -> some View { /// Phone form: the file summary over one scrolling unified patch.
VStack(alignment: .leading, spacing: 6) { private func stacked(_ diff: WireSessionDiff) -> some View {
Text("\(diff.stat.filesChanged) file\(diff.stat.filesChanged == 1 ? "" : "s") changed · +\(diff.stat.added) −\(diff.stat.removed)") ScrollView {
.font(.subheadline.weight(.semibold)) VStack(alignment: .leading, spacing: 14) {
ForEach(diff.files) { file in VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) { DiffStatHeader(stat: diff.stat)
Image(systemName: statusIcon(file.status)) ForEach(diff.files) { DiffFileRow(file: $0) }
.font(.caption)
.foregroundStyle(statusColor(file.status))
Text(file.path)
.font(.caption.monospaced())
.lineLimit(1).truncationMode(.middle)
Spacer(minLength: 8)
Text("+\(file.added)").font(.caption2.monospacedDigit()).foregroundStyle(Palette.success)
Text("−\(file.removed)").font(.caption2.monospacedDigit()).foregroundStyle(Palette.danger)
} }
.card()
PatchText(patch: diff.patch)
if diff.truncated { TruncatedPatchNote() }
}
.padding()
.readableColumn()
}
}
}
/// iPad form: the Mac's two-pane diff. A tappable file list on the left drives the selected
/// file's patch on the right. Row taps use a plain `Button` (not `List(selection:)`, whose
/// single-select tap handling is unreliable outside an edit-mode / split-view context).
private struct SplitDiffView: View {
let diff: WireSessionDiff
@State private var selected: String?
/// The combined patch split into per-file sections, keyed by file path.
private var sections: [String: String] { UnifiedPatch.sections(diff.patch, files: diff.files) }
private var current: String? { selected ?? diff.files.first?.path }
var body: some View {
HStack(spacing: 0) {
fileList
.frame(width: 280)
.background(Color(.secondarySystemBackground))
Divider()
patchPane
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.onAppear { if selected == nil { selected = diff.files.first?.path } }
// Keep the selection valid as the diff refetches (files can appear/vanish between turns).
.onChange(of: diff.files) { _, files in
if selected == nil || !files.contains(where: { $0.path == selected }) {
selected = files.first?.path
} }
} }
.card()
} }
private func statusIcon(_ status: String) -> String { private var fileList: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
DiffStatHeader(stat: diff.stat)
.padding(.horizontal, 12).padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
Divider()
ForEach(diff.files) { file in
Button { selected = file.path } label: {
DiffFileRow(file: file)
.padding(.horizontal, 12).padding(.vertical, 9)
.frame(maxWidth: .infinity, alignment: .leading)
.background(current == file.path
? Palette.accent.opacity(0.15) : Color.clear)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.hoverEffect(.highlight)
}
if diff.truncated { TruncatedPatchNote().padding(12) }
}
}
}
@ViewBuilder
private var patchPane: some View {
if let path = current, let patch = sections[path], !patch.isEmpty {
ScrollView { PatchText(patch: patch).padding() }
} else if current != nil {
// A file with no textual hunk (binary, or a section dropped by patch truncation).
ContentUnavailableView(
"No preview", systemImage: "doc",
description: Text("This file has no textual diff\(diff.truncated ? " in the fetched patch" : "").")
)
} else {
ContentUnavailableView("Select a file", systemImage: "sidebar.left")
}
}
}
/// The "N files changed · +A −R" header shared by both diff forms.
private struct DiffStatHeader: View {
let stat: DiffStat
var body: some View {
Text("\(stat.filesChanged) file\(stat.filesChanged == 1 ? "" : "s") changed · +\(stat.added) −\(stat.removed)")
.font(.subheadline.weight(.semibold))
}
}
/// One file's row: status glyph, path, and its +/− counts. Shared by the phone summary and the
/// iPad file list.
struct DiffFileRow: View {
let file: WireFileDiff
var body: some View {
HStack(spacing: 8) {
Image(systemName: DiffStatus.icon(file.status))
.font(.caption)
.foregroundStyle(DiffStatus.color(file.status))
Text(file.path)
.font(.caption.monospaced())
.lineLimit(1).truncationMode(.middle)
Spacer(minLength: 8)
Text("+\(file.added)").font(.caption2.monospacedDigit()).foregroundStyle(Palette.success)
Text("−\(file.removed)").font(.caption2.monospacedDigit()).foregroundStyle(Palette.danger)
}
}
}
enum DiffStatus {
static func icon(_ status: String) -> String {
switch status { switch status {
case "added", "untracked": "plus.circle" case "added", "untracked": "plus.circle"
case "deleted": "minus.circle" case "deleted": "minus.circle"
@@ -367,8 +471,7 @@ struct SessionDiffView: View {
default: "pencil.circle" default: "pencil.circle"
} }
} }
static func color(_ status: String) -> Color {
private func statusColor(_ status: String) -> Color {
switch status { switch status {
case "added", "untracked": Palette.success case "added", "untracked": Palette.success
case "deleted": Palette.danger case "deleted": Palette.danger
@@ -377,6 +480,57 @@ struct SessionDiffView: View {
} }
} }
private struct TruncatedPatchNote: View {
var body: some View {
Label("Patch truncated — open the Mac for the rest.", systemImage: "scissors")
.font(.caption).foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
/// Splits a combined `git diff` patch into per-file sections for the iPad's two-pane view.
enum UnifiedPatch {
/// Map from file path to that file's slice of the unified patch. Sections begin at a
/// `diff --git a/… b/…` line. When the section count matches `files` we pair positionally
/// (the host emits patch and files together, in order); otherwise we key each section by the
/// new path parsed from its header. Callers fall back to a "no preview" state on a miss.
static func sections(_ patch: String, files: [WireFileDiff]) -> [String: String] {
guard patch.contains("diff --git ") else {
// A single-file patch with no git header: attribute the whole thing to the sole file.
return files.count == 1 ? [files[0].path: patch] : [:]
}
var chunks: [String] = []
var current: [Substring] = []
for line in patch.split(separator: "\n", omittingEmptySubsequences: false) {
if line.hasPrefix("diff --git ") {
if !current.isEmpty { chunks.append(current.joined(separator: "\n")) }
current = [line]
} else if !current.isEmpty {
current.append(line)
}
}
if !current.isEmpty { chunks.append(current.joined(separator: "\n")) }
var result: [String: String] = [:]
if chunks.count == files.count {
for (file, chunk) in zip(files, chunks) { result[file.path] = chunk }
} else {
for chunk in chunks where newPath(chunk) != nil { result[newPath(chunk)!] = chunk }
}
return result
}
/// The new-side path from a section's `diff --git a/OLD b/NEW` header. Nil when the header is
/// absent or the path is quoted/spaced in a way we don't split cleanly.
private static func newPath(_ chunk: String) -> String? {
guard let header = chunk.split(separator: "\n", maxSplits: 1).first,
header.hasPrefix("diff --git "),
let bRange = header.range(of: " b/") else { return nil }
let path = header[bRange.upperBound...]
return path.isEmpty ? nil : String(path)
}
}
/// The unified patch, one `Text` per line with the classic +/− coloring. Lines scroll /// The unified patch, one `Text` per line with the classic +/− coloring. Lines scroll
/// horizontally as a block so long lines don't wrap into noise. /// horizontally as a block so long lines don't wrap into noise.
private struct PatchText: View { private struct PatchText: View {
@@ -1,10 +1,12 @@
import SwiftUI import SwiftUI
import NucleicProtocol import NucleicProtocol
import NucleicTailnet import NucleicTailnet
import UIKit
struct SettingsView: View { struct SettingsView: View {
@EnvironmentObject var store: RemoteStore @EnvironmentObject var store: RemoteStore
@State private var showScanner = false @State private var showScanner = false
@State private var showManualPair = false
@State private var tailscaleAuthKey: String = TailnetAuthStore.loadAuthKey() ?? "" @State private var tailscaleAuthKey: String = TailnetAuthStore.loadAuthKey() ?? ""
@FocusState private var tailscaleKeyFocused: Bool @FocusState private var tailscaleKeyFocused: Bool
@AppStorage("nucleic.showRawEvents") private var showRaw = false @AppStorage("nucleic.showRawEvents") private var showRaw = false
@@ -133,6 +135,11 @@ struct SettingsView: View {
} label: { } label: {
Label("Pair with a Mac", systemImage: "qrcode.viewfinder") Label("Pair with a Mac", systemImage: "qrcode.viewfinder")
} }
Button {
showManualPair = true
} label: {
Label("Enter code manually", systemImage: "keyboard")
}
if store.isPaired { if store.isPaired {
Button(role: .destructive) { store.unpair() } label: { Button(role: .destructive) { store.unpair() } label: {
Label("Unpair this device", systemImage: "minus.circle") Label("Unpair this device", systemImage: "minus.circle")
@@ -149,6 +156,12 @@ struct SettingsView: View {
store.pair(with: payload) store.pair(with: payload)
} }
} }
.sheet(isPresented: $showManualPair) {
ManualPairingView { payload in
showManualPair = false
store.pair(with: payload)
}
}
} }
} }
} }
@@ -157,12 +170,13 @@ struct SettingsView: View {
struct PairingIntroView: View { struct PairingIntroView: View {
@EnvironmentObject var store: RemoteStore @EnvironmentObject var store: RemoteStore
@State private var showScanner = false @State private var showScanner = false
@State private var showManualPair = false
var body: some View { var body: some View {
VStack(spacing: 24) { VStack(spacing: 24) {
Image(systemName: "qrcode.viewfinder").font(.system(size: 72)).foregroundStyle(.tint) Image(systemName: "qrcode.viewfinder").font(.system(size: 72)).foregroundStyle(.tint)
Text("Pair with your Mac").font(.title2.weight(.semibold)) Text("Pair with your Mac").font(.title2.weight(.semibold))
Text("On your Mac, open Nucleic ▸ Settings ▸ Add iPhone to show a QR code, then scan it here.") Text("On your Mac, open Nucleic ▸ Settings ▸ Add device to show a QR code, then scan it here — or enter the pairing code beneath it manually.")
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.padding(.horizontal, 32) .padding(.horizontal, 32)
@@ -173,6 +187,15 @@ struct PairingIntroView: View {
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
// A camera-free path — the iPad's scanner can't run while the app is mirrored to an
// external display or in some Stage Manager states; pasting the code always works.
Button {
showManualPair = true
} label: {
Label("Enter code manually", systemImage: "keyboard")
}
.buttonStyle(.bordered)
// Feedback after a scan: the whole UI is still the intro until pairing completes, so // Feedback after a scan: the whole UI is still the intro until pairing completes, so
// without this a failed or in-flight pair looks like "nothing happened." // without this a failed or in-flight pair looks like "nothing happened."
pairingStatus pairingStatus
@@ -201,6 +224,12 @@ struct PairingIntroView: View {
store.pair(with: payload) store.pair(with: payload)
} }
} }
.sheet(isPresented: $showManualPair) {
ManualPairingView { payload in
showManualPair = false
store.pair(with: payload)
}
}
} }
/// A status line reflecting an in-flight or failed pairing attempt. Silent in the resting /// A status line reflecting an in-flight or failed pairing attempt. Silent in the resting
@@ -239,3 +268,72 @@ struct PairingIntroView: View {
} }
} }
} }
/// A camera-free pairing path (UX_IOS §7): paste the Mac's `nucleic://pair?d=…` code instead of
/// scanning its QR. The iPad's camera can be blocked while mirrored to an external display or in
/// some Stage Manager states, where the scanner's `couldNotStart` path leaves you stuck — this
/// always works. Parses with the same `PairingPayload(qrString:)` the scanner uses.
struct ManualPairingView: View {
let onEntered: (PairingPayload) -> Void
@Environment(\.dismiss) private var dismiss
@State private var code = ""
@State private var error: String?
@FocusState private var fieldFocused: Bool
var body: some View {
NavigationStack {
Form {
Section {
TextField("nucleic://pair?d=…", text: $code, axis: .vertical)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
.font(.callout.monospaced())
.lineLimit(2...6)
.focused($fieldFocused)
.onChange(of: code) { error = nil }
} header: {
Text("Pairing code")
} footer: {
Text("On your Mac, open Nucleic ▸ Settings ▸ Add device and copy the pairing link shown beneath the QR code, then paste it here.")
}
if let error {
Label(error, systemImage: "exclamationmark.triangle.fill")
.font(.callout).foregroundStyle(.red)
}
Button {
submit()
} label: {
Label("Pair", systemImage: "link").frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
.keyboardShortcut(.defaultAction)
}
.navigationTitle("Enter pairing code")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
ToolbarItem(placement: .primaryAction) {
Button {
if let pasted = UIPasteboard.general.string { code = pasted }
} label: {
Label("Paste", systemImage: "doc.on.clipboard")
}
.disabled(!UIPasteboard.general.hasStrings)
}
}
.onAppear { fieldFocused = true }
}
}
private func submit() {
let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines)
guard let payload = try? PairingPayload(qrString: trimmed) else {
error = "That isn't a valid Nucleic pairing code. Copy the link beneath the QR on your Mac."
return
}
onEntered(payload)
dismiss()
}
}
@@ -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 {
@@ -29,7 +29,10 @@ struct HostExecToolCard: View {
!$0.flags.isEmpty || !$0.arguments.isEmpty || !$0.env.isEmpty !$0.flags.isEmpty || !$0.arguments.isEmpty || !$0.env.isEmpty
} }
} }
private var canExpand: Bool { hasOutput || hasBreakdown } /// A host-run `git commit` — surfaced as a structured commit card (subject + Markdown body),
/// like the Mac, instead of only the generic "Commit changes" purpose.
private var commit: GitCommitSummary.Commit? { GitCommitSummary.parse(command) }
private var canExpand: Bool { hasOutput || hasBreakdown || commit != nil }
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
@@ -37,7 +40,11 @@ struct HostExecToolCard: View {
label: { header } label: { header }
.buttonStyle(.plain) .buttonStyle(.plain)
if expanded { if expanded {
if hasBreakdown, let parsed { if let commit {
// The `$ command` is already shown in the header, so omit the disclosure here.
Divider().overlay(Color.primary.opacity(0.06))
GitCommitCard(commit: commit)
} else if hasBreakdown, let parsed {
Divider().overlay(Color.primary.opacity(0.06)) Divider().overlay(Color.primary.opacity(0.06))
HostCommandBreakdown(summary: parsed, showPurpose: false) HostCommandBreakdown(summary: parsed, showPurpose: false)
} }
@@ -43,9 +43,23 @@ 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); a multi-step or
// destructive shell pipeline reads as a compact step list (deletes flagged in red) — both
// like the Mac, with the literal command one tap away under "Show command". Everything else
// keeps the plain input block.
if isShellTool, let commit = GitCommitSummary.parse(input) {
GitCommitCard(commit: commit, rawCommand: input)
} else if isShellTool, let summary = HostCommandSummary.summary(for: input),
summary.invocations.count > 1 || summary.isDestructive {
HostCommandBreakdown(summary: summary, showPurpose: false)
CommandDisclosure(command: input, accent: Palette.accent)
} 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 {