diff --git a/NucleicRemote/NucleicRemote/Models/IdentityStore.swift b/NucleicRemote/NucleicRemote/Models/IdentityStore.swift index 4277b5f..4203cd1 100644 --- a/NucleicRemote/NucleicRemote/Models/IdentityStore.swift +++ b/NucleicRemote/NucleicRemote/Models/IdentityStore.swift @@ -1,6 +1,9 @@ import Foundation import Security 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 /// reconnect), a display name, and the transport + connection hint from the QR — LAN @@ -42,11 +45,22 @@ enum IdentityStore { static func deviceID() -> String { let defaults = UserDefaults.standard 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) 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? { guard let data = UserDefaults.standard.data(forKey: pairedHostKey) else { return nil } return try? JSONDecoder().decode(PairedHost.self, from: data) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 095c6eb..142895a 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -295,6 +295,17 @@ final class RemoteStore: ObservableObject { + if (!token) throw new AuthError("missing bearer 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, 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 = [ event(1, .sessionStarted(SessionStarted( 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(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, .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 // 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)") } - func closeOpen() { - if let id = openSessionID { - send(.unsubscribe(id)) - markOpened(id) // everything up to now has been seen - } + /// Close a session's live subscription. `id` names *which* session is closing — the detail + /// view passes its own. On iPad's split view, switching session A→B can mount B (which calls + /// `open(B)`, setting `openSessionID = B`) *before* A's detail disappears; so we always + /// 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 openEvents = [] openApprovals = [] diff --git a/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift b/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift index 7a5fc6f..267c7c1 100644 --- a/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift +++ b/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift @@ -36,8 +36,6 @@ struct NucleicRemoteApp: App { struct RootView: 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 // Appearance preferences — same storage keys and semantics as the Mac's Settings → // Appearance, so both devices honor the same choices. @AppStorage(AppAppearance.storageKey) private var appearanceRaw = AppAppearance.system.rawValue @@ -52,30 +50,15 @@ struct RootView: View { DemoBanner() Group { if store.isPaired { - 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) - } + // Width-adaptive shell: the iPhone's TabView on compact width, the Mac's + // sidebar+detail split on a regular-width iPad (see AdaptiveRootView). + AdaptiveRootView() } else { PairingIntroView() } } } .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) .preferredColorScheme((AppAppearance(rawValue: appearanceRaw) ?? .system).colorScheme) .dynamicTypeSize((AppTextSize(rawValue: textSizeRaw) ?? .medium).dynamicTypeSize) diff --git a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift new file mode 100644 index 0000000..653c521 --- /dev/null +++ b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift @@ -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() + 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) + } + } +} diff --git a/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift b/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift index 76edfbf..0926b72 100644 --- a/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift +++ b/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift @@ -33,13 +33,17 @@ struct ApprovalCardView: View { .background(approval.risk.color.opacity(0.2), in: Capsule()) .foregroundStyle(approval.risk.color) } - // A host_exec gate escapes the sandbox onto the macOS host, so lay out what it's - // about to run — the inferred purpose and the program/actions/flags, parsed from the - // command itself (never from the agent) — then the exact command beneath it. Every + // A git commit reads as a structured commit card (subject + Markdown body) so you can + // see exactly what you're granting; the literal command stays under "Show command". + // 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. - if approval.toolName == HostCommandSummary.hostExecToolName, - let command = approval.input["command"]?.stringValue, - let parsed = HostCommandSummary.summary(for: command) { + if let command = approval.input["command"]?.stringValue, + let commit = GitCommitSummary.parse(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) Text("Exact command").font(.caption2.weight(.semibold)).foregroundStyle(.secondary) ApprovalDetailBox(text: command) @@ -66,6 +70,9 @@ struct ApprovalCardView: View { Text("Deny").frame(maxWidth: .infinity) } .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 { store.respond(approval, .allow()) @@ -74,6 +81,7 @@ struct ApprovalCardView: View { } .buttonStyle(.borderedProminent) .disabled(!allowEnabled) + .keyboardShortcut(.defaultAction) } // A destructive action (rm, force-push, reset --hard, …) offers no remembered diff --git a/NucleicRemote/NucleicRemote/Views/Composer.swift b/NucleicRemote/NucleicRemote/Views/Composer.swift index 8d8d5f0..89b2157 100644 --- a/NucleicRemote/NucleicRemote/Views/Composer.swift +++ b/NucleicRemote/NucleicRemote/Views/Composer.swift @@ -92,6 +92,7 @@ struct StartChatComposer: View { Image(systemName: "arrow.up.circle.fill").font(.title) } .disabled(selected == nil || draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.canControl) + .keyboardShortcut(.return, modifiers: .command) } if !store.canControl { Text("This device is view-only.").font(.caption2).foregroundStyle(.secondary) diff --git a/NucleicRemote/NucleicRemote/Views/HomeView.swift b/NucleicRemote/NucleicRemote/Views/HomeView.swift index 28f6941..f1bd9c7 100644 --- a/NucleicRemote/NucleicRemote/Views/HomeView.swift +++ b/NucleicRemote/NucleicRemote/Views/HomeView.swift @@ -86,6 +86,9 @@ struct HomeView: View { StartChatComposer() } .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") .navigationBarTitleDisplayMode(.inline) diff --git a/NucleicRemote/NucleicRemote/Views/Layout.swift b/NucleicRemote/NucleicRemote/Views/Layout.swift new file mode 100644 index 0000000..efdd553 --- /dev/null +++ b/NucleicRemote/NucleicRemote/Views/Layout.swift @@ -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) + } +} diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 64473d3..9ccf742 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -71,7 +71,9 @@ struct SessionDetailView: View { Button("Cancel", role: .cancel) {} } .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). @@ -221,6 +223,9 @@ struct SessionDetailView: View { .font(.title2) } .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() + // 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 // 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 -/// over the unified patch, colored +/− per line, fetched on demand via `fetchDiff`. +/// The Diff tab, read-only over the on-demand `WireSessionDiff`. On a phone it's a per-file +/// 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 { let diff: WireSessionDiff? 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 { if let diff, diff.stat.filesChanged > 0 { - ScrollView { - VStack(alignment: .leading, spacing: 14) { - fileList(diff) - PatchText(patch: diff.patch) - if diff.truncated { - Label("Patch truncated — open the Mac for the rest.", - systemImage: "scissors") - .font(.caption).foregroundStyle(.secondary) - } + GeometryReader { geo in + if geo.size.width >= twoPaneMinWidth { + SplitDiffView(diff: diff) + } else { + stacked(diff) } - .padding() } } else if loading { ProgressView("Fetching diff…") @@ -338,28 +347,123 @@ struct SessionDiffView: View { } } - private func fileList(_ diff: WireSessionDiff) -> some View { - VStack(alignment: .leading, spacing: 6) { - Text("\(diff.stat.filesChanged) file\(diff.stat.filesChanged == 1 ? "" : "s") changed · +\(diff.stat.added) −\(diff.stat.removed)") - .font(.subheadline.weight(.semibold)) - ForEach(diff.files) { file in - HStack(spacing: 8) { - Image(systemName: statusIcon(file.status)) - .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) + /// Phone form: the file summary over one scrolling unified patch. + private func stacked(_ diff: WireSessionDiff) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 6) { + DiffStatHeader(stat: diff.stat) + ForEach(diff.files) { DiffFileRow(file: $0) } } + .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 { case "added", "untracked": "plus.circle" case "deleted": "minus.circle" @@ -367,8 +471,7 @@ struct SessionDiffView: View { default: "pencil.circle" } } - - private func statusColor(_ status: String) -> Color { + static func color(_ status: String) -> Color { switch status { case "added", "untracked": Palette.success 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 /// horizontally as a block so long lines don't wrap into noise. private struct PatchText: View { diff --git a/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/NucleicRemote/NucleicRemote/Views/SettingsView.swift index 8c563ad..cfb2fbd 100644 --- a/NucleicRemote/NucleicRemote/Views/SettingsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SettingsView.swift @@ -1,10 +1,12 @@ import SwiftUI import NucleicProtocol import NucleicTailnet +import UIKit struct SettingsView: View { @EnvironmentObject var store: RemoteStore @State private var showScanner = false + @State private var showManualPair = false @State private var tailscaleAuthKey: String = TailnetAuthStore.loadAuthKey() ?? "" @FocusState private var tailscaleKeyFocused: Bool @AppStorage("nucleic.showRawEvents") private var showRaw = false @@ -133,6 +135,11 @@ struct SettingsView: View { } label: { Label("Pair with a Mac", systemImage: "qrcode.viewfinder") } + Button { + showManualPair = true + } label: { + Label("Enter code manually", systemImage: "keyboard") + } if store.isPaired { Button(role: .destructive) { store.unpair() } label: { Label("Unpair this device", systemImage: "minus.circle") @@ -149,6 +156,12 @@ struct SettingsView: View { 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 { @EnvironmentObject var store: RemoteStore @State private var showScanner = false + @State private var showManualPair = false var body: some View { VStack(spacing: 24) { Image(systemName: "qrcode.viewfinder").font(.system(size: 72)).foregroundStyle(.tint) 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) .foregroundStyle(.secondary) .padding(.horizontal, 32) @@ -173,6 +187,15 @@ struct PairingIntroView: View { } .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 // without this a failed or in-flight pair looks like "nothing happened." pairingStatus @@ -201,6 +224,12 @@ struct PairingIntroView: View { 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 @@ -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() + } +} 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/HostExecCard.swift b/NucleicRemote/NucleicRemote/Views/Transcript/HostExecCard.swift index f502a28..b59726f 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/HostExecCard.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/HostExecCard.swift @@ -29,7 +29,10 @@ struct HostExecToolCard: View { !$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 { VStack(alignment: .leading, spacing: 8) { @@ -37,7 +40,11 @@ struct HostExecToolCard: View { label: { header } .buttonStyle(.plain) 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)) HostCommandBreakdown(summary: parsed, showPurpose: false) } diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift b/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift index 6e5da10..3eda77d 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift @@ -43,9 +43,23 @@ 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); 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) } if let result = group.result {