diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/HostCommandSummary.swift b/NucleicRemote/NucleicRemote/Views/Transcript/HostCommandSummary.swift index 49596b9..9d3837c 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/HostCommandSummary.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/HostCommandSummary.swift @@ -33,10 +33,23 @@ enum HostCommandSummary { var display: String { value.map { "\(name) \($0)" } ?? name } } + /// A recognized shell control-flow header — a loop (`for`/`while`/`until`/`select`) or a + /// conditional (`if`/`elif`/`case`). Surfaced so a scripted pipeline reads as its structure — + /// one "for i in $(seq 1 50)" step under a loop glyph — instead of shredding into bogus + /// `for`/`do`/`done`/`fi` program steps. The mobile mirror of the desktop's `ControlFlow`. + struct ControlFlow: Equatable { + enum Kind: Equatable { case loop, conditional } + let kind: Kind + /// The header rendered readably — verbatim from the command, so `$(…)` substitutions and + /// tests survive: "for i in $(seq 1 50)", "if [ -f /tmp/x ]". + let text: String + } + /// One program invocation within a command pipeline. struct Invocation: Equatable, Identifiable { let id: Int - /// The program name, reduced to its basename (`/usr/bin/swift` → `swift`). + /// The program name, reduced to its basename (`/usr/bin/swift` → `swift`). Empty for a + /// ``control`` step, which has no program. let program: String /// The subcommands / targets that follow it, in order (`build`, `run`, `test`). let actions: [String] @@ -50,9 +63,16 @@ enum HostCommandSummary { let elevated: Bool /// True for an inherently destructive program (`rm`/`rmdir`). let destructive: Bool + /// Set when this "invocation" is actually a shell loop/conditional *header* rather than a + /// program — rendered as one control-flow step. Nil for an ordinary program invocation. + let control: ControlFlow? - /// "swift build", for a compact one-liner. - var headline: String { ([program] + actions).joined(separator: " ") } + /// "swift build", for a compact one-liner — or the loop/conditional header for a + /// ``control`` step ("for i in $(seq 1 50)"). + var headline: String { + if let control { return control.text } + return ([program] + actions).joined(separator: " ") + } } /// A whole `host_exec` command, parsed. @@ -77,9 +97,20 @@ enum HostCommandSummary { var workingDirectory: String? var nextID = 0 for segment in Lexer.splitSegments(flattened) { - let tokens = Lexer.tokenize(segment) - let (env, elevated, rest) = stripPrefixes(tokens) + // Peel any leading `do`/`then`/`else`/`{` so the command a loop or conditional body + // introduces (`then rm -rf x`) parses as that command, then strip env/`sudo` off it. + let peeled = Lexer.stripBodyIntroducers(Lexer.tokenize(segment)) + let (env, elevated, rest) = stripPrefixes(peeled) guard let head = rest.first else { continue } + // A pure structural keyword (`done`/`fi`/`esac`/`}`/`break`/…) carries no operation — + // skip it like `cd`, so it never renders as a bogus program step. + if Lexer.structuralKeywords.contains(head) { continue } + // A loop or conditional *header* reads as one control-flow step rather than shredding. + if let control = controlFlow(head: head, segment: segment) { + invocations.append(makeControlInvocation(id: nextID, control: control)) + nextID += 1 + continue + } if basename(head).lowercased() == "cd" { if workingDirectory == nil, rest.count >= 2 { workingDirectory = normalizeDirectory(rest[1]) @@ -118,6 +149,23 @@ enum HostCommandSummary { return (env, elevated, Array(rest)) } + /// Classifies a segment whose (post-peel) head word opens a shell control-flow construct — a + /// loop (`for`/`while`/`until`/`select`) or a conditional (`if`/`elif`/`case`) — into a + /// ``ControlFlow`` carrying the verbatim header for display. Nil for an ordinary program. + private static func controlFlow(head: String, segment: String) -> ControlFlow? { + let text = segment.trimmingCharacters(in: .whitespacesAndNewlines) + if Lexer.loopKeywords.contains(head) { return ControlFlow(kind: .loop, text: text) } + if Lexer.conditionalKeywords.contains(head) { return ControlFlow(kind: .conditional, text: text) } + return nil + } + + /// A control-flow step — a loop/conditional header — as an ``Invocation`` with only its + /// ``Invocation/control`` set, so it slots into the same step list. + private static func makeControlInvocation(id: Int, control: ControlFlow) -> Invocation { + Invocation(id: id, program: "", actions: [], flags: [], arguments: [], + env: [], elevated: false, destructive: false, control: control) + } + private static func makeInvocation( id: Int, program rawProgram: String, args: [String], env: [EnvAssignment], elevated: Bool @@ -129,7 +177,7 @@ enum HostCommandSummary { let destructive = program == "rm" || program == "rmdir" return Invocation( id: id, program: program, actions: actions, flags: flags, arguments: arguments, - env: env, elevated: elevated, destructive: destructive) + env: env, elevated: elevated, destructive: destructive, control: nil) } private static func splitActions(_ args: [String], spec: ProgramSpec) -> (actions: [String], rest: [String]) { @@ -265,6 +313,11 @@ enum HostCommandSummary { } private static func interpret(_ inv: Invocation) -> (purpose: String, salience: Int) { + // A control-flow header describes the shape of the script, not what it does — rank it low + // so a real body command (a build/test/delete) leads the headline instead. + if let control = inv.control { + return (control.kind == .loop ? "Run a loop" : "Run a conditional", 18) + } if inv.destructive { let target = inv.arguments.first.map { " \($0)" } ?? " files" return ("Delete\(target) on the host", 100) @@ -641,6 +694,32 @@ private enum Lexer { guard let eq = token.firstIndex(of: "="), eq != token.startIndex else { return false } return token[.. = ["do", "then", "else", "{"] + + /// Pure structural keywords with no operation of their own — a loop/conditional terminator + /// (`done`/`fi`/`esac`/`}`), the `for … in` connective, and `break`/`continue`; skipped like `cd`. + static let structuralKeywords: Set = ["done", "fi", "esac", "}", "in", "break", "continue"] + + /// The keywords that open a *loop* header — `for`/`while`/`until`/`select`. + static let loopKeywords: Set = ["for", "while", "until", "select"] + + /// The keywords that open a *conditional* header — `if`/`elif`/`case`. + static let conditionalKeywords: Set = ["if", "elif", "case"] + + /// Drops any leading body-introducer keywords (`do`/`then`/`else`/`{`) from a tokenized + /// segment, so the command they precede lands at the front (`then rm -rf x` → `rm -rf x`). + static func stripBodyIntroducers(_ tokens: [String]) -> [String] { + var rest = tokens[...] + while let first = rest.first, bodyIntroducerKeywords.contains(first) { + rest = rest.dropFirst() + } + return Array(rest) + } } // MARK: - Git commit summary diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/HostExecCard.swift b/NucleicRemote/NucleicRemote/Views/Transcript/HostExecCard.swift index 6a75a95..8313dd2 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/HostExecCard.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/HostExecCard.swift @@ -40,7 +40,7 @@ struct HostExecToolCard: View { guard let parsed else { return false } if parsed.workingDirectory != nil || parsed.invocations.count > 1 { return true } return parsed.invocations.contains { - !$0.flags.isEmpty || !$0.arguments.isEmpty || !$0.env.isEmpty + $0.control != nil || !$0.flags.isEmpty || !$0.arguments.isEmpty || !$0.env.isEmpty } } /// A host-run `git commit` — surfaced as a structured commit card (subject + Markdown body), @@ -152,8 +152,10 @@ struct HostCommandBreakdown: View { /// (program + actions + every env/flag/operand spelled out); a *chain* collapses to one /// compact line per command so a long pipeline stays short, not a tall stack of blocks. @ViewBuilder private var steps: some View { - if summary.invocations.count <= 1 { - if let only = summary.invocations.first { invocationView(only) } + // A lone *program* gets the full block; a lone control-flow header (or any chain) uses the + // compact step list, which renders the loop/conditional header as one glyphed line. + if summary.invocations.count == 1, let only = summary.invocations.first, only.control == nil { + invocationView(only) } else { VStack(alignment: .leading, spacing: 6) { ForEach(Array(summary.invocations.prefix(Self.maxVisibleSteps))) { inv in @@ -197,9 +199,11 @@ struct HostCommandBreakdown: View { (inv.env.map(\.display) + inv.flags.map(\.display) + inv.arguments).joined(separator: " ") } - /// The leading glyph for a compacted step: a delete/elevation marker takes priority so the - /// risky step is unmistakable; otherwise a plain terminal glyph. + /// The leading glyph for a compacted step: a loop/conditional header gets a control-flow glyph; + /// a delete/elevation marker takes priority so the risky step is unmistakable; otherwise a + /// plain terminal glyph. private func stepGlyph(_ inv: HostCommandSummary.Invocation) -> String { + if let control = inv.control { return control.kind == .loop ? "repeat" : "arrow.triangle.branch" } if inv.destructive { return "trash" } if inv.elevated { return "lock.shield" } return "terminal" diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift b/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift index 78a776d..07753d5 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift @@ -67,7 +67,8 @@ struct ToolCallCard: View { 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 { + summary.invocations.count > 1 || summary.isDestructive + || summary.invocations.contains(where: { $0.control != nil }) { HostCommandBreakdown(summary: summary, showPurpose: false) CommandDisclosure(command: input, accent: Palette.accent) } else if !input.isEmpty {