Merge nucleic/hazy-north-lemur-uqt4 into dev
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user