Merge nucleic/hazy-north-lemur-uqt4 into dev

This commit is contained in:
2026-07-18 13:23:22 -07:00
parent 677551a82b
commit 9941d4435e
3 changed files with 96 additions and 12 deletions
@@ -33,10 +33,23 @@ enum HostCommandSummary {
var display: String { value.map { "\(name) \($0)" } ?? name } 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. /// One program invocation within a command pipeline.
struct Invocation: Equatable, Identifiable { struct Invocation: Equatable, Identifiable {
let id: Int 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 let program: String
/// The subcommands / targets that follow it, in order (`build`, `run`, `test`). /// The subcommands / targets that follow it, in order (`build`, `run`, `test`).
let actions: [String] let actions: [String]
@@ -50,9 +63,16 @@ enum HostCommandSummary {
let elevated: Bool let elevated: Bool
/// True for an inherently destructive program (`rm`/`rmdir`). /// True for an inherently destructive program (`rm`/`rmdir`).
let destructive: Bool 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. /// "swift build", for a compact one-liner — or the loop/conditional header for a
var headline: String { ([program] + actions).joined(separator: " ") } /// ``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. /// A whole `host_exec` command, parsed.
@@ -77,9 +97,20 @@ enum HostCommandSummary {
var workingDirectory: String? var workingDirectory: String?
var nextID = 0 var nextID = 0
for segment in Lexer.splitSegments(flattened) { for segment in Lexer.splitSegments(flattened) {
let tokens = Lexer.tokenize(segment) // Peel any leading `do`/`then`/`else`/`{` so the command a loop or conditional body
let (env, elevated, rest) = stripPrefixes(tokens) // 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 } 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 basename(head).lowercased() == "cd" {
if workingDirectory == nil, rest.count >= 2 { if workingDirectory == nil, rest.count >= 2 {
workingDirectory = normalizeDirectory(rest[1]) workingDirectory = normalizeDirectory(rest[1])
@@ -118,6 +149,23 @@ enum HostCommandSummary {
return (env, elevated, Array(rest)) 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( private static func makeInvocation(
id: Int, program rawProgram: String, args: [String], id: Int, program rawProgram: String, args: [String],
env: [EnvAssignment], elevated: Bool env: [EnvAssignment], elevated: Bool
@@ -129,7 +177,7 @@ enum HostCommandSummary {
let destructive = program == "rm" || program == "rmdir" let destructive = program == "rm" || program == "rmdir"
return Invocation( return Invocation(
id: id, program: program, actions: actions, flags: flags, arguments: arguments, 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]) { 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) { 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 { if inv.destructive {
let target = inv.arguments.first.map { " \($0)" } ?? " files" let target = inv.arguments.first.map { " \($0)" } ?? " files"
return ("Delete\(target) on the host", 100) 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 } guard let eq = token.firstIndex(of: "="), eq != token.startIndex else { return false }
return token[..<eq].allSatisfy { $0.isLetter || $0.isNumber || $0 == "_" } return token[..<eq].allSatisfy { $0.isLetter || $0.isNumber || $0 == "_" }
} }
// MARK: - Shell control flow
/// Keywords that merely *introduce* a command body — `do`/`then`/`else` and the `{` group
/// opener; the command they precede is what matters, so they're peeled off.
static let bodyIntroducerKeywords: Set<String> = ["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<String> = ["done", "fi", "esac", "}", "in", "break", "continue"]
/// The keywords that open a *loop* header — `for`/`while`/`until`/`select`.
static let loopKeywords: Set<String> = ["for", "while", "until", "select"]
/// The keywords that open a *conditional* header — `if`/`elif`/`case`.
static let conditionalKeywords: Set<String> = ["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 // MARK: - Git commit summary
@@ -40,7 +40,7 @@ struct HostExecToolCard: View {
guard let parsed else { return false } guard let parsed else { return false }
if parsed.workingDirectory != nil || parsed.invocations.count > 1 { return true } if parsed.workingDirectory != nil || parsed.invocations.count > 1 { return true }
return parsed.invocations.contains { 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), /// 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 /// (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. /// compact line per command so a long pipeline stays short, not a tall stack of blocks.
@ViewBuilder private var steps: some View { @ViewBuilder private var steps: some View {
if summary.invocations.count <= 1 { // A lone *program* gets the full block; a lone control-flow header (or any chain) uses the
if let only = summary.invocations.first { invocationView(only) } // 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 { } else {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
ForEach(Array(summary.invocations.prefix(Self.maxVisibleSteps))) { inv in 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: " ") (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 /// The leading glyph for a compacted step: a loop/conditional header gets a control-flow glyph;
/// risky step is unmistakable; otherwise a plain terminal 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 { 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.destructive { return "trash" }
if inv.elevated { return "lock.shield" } if inv.elevated { return "lock.shield" }
return "terminal" return "terminal"
@@ -67,7 +67,8 @@ struct ToolCallCard: View {
if isShellTool, let commit = GitCommitSummary.parse(input) { if isShellTool, let commit = GitCommitSummary.parse(input) {
GitCommitCard(commit: commit, rawCommand: input) GitCommitCard(commit: commit, rawCommand: input)
} else if isShellTool, let summary = HostCommandSummary.summary(for: 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) HostCommandBreakdown(summary: summary, showPurpose: false)
CommandDisclosure(command: input, accent: Palette.accent) CommandDisclosure(command: input, accent: Palette.accent)
} else if !input.isEmpty { } else if !input.isEmpty {