Truncation Logic Refinement
Nucleic-Session: FE1E2C94-7945-4827-AF56-74525A9BD434 Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
@@ -8,6 +8,9 @@ import NucleicProtocol
|
||||
struct ApprovalCardView: View {
|
||||
@EnvironmentObject var store: RemoteStore
|
||||
let approval: ApprovalRequest
|
||||
/// The transcript's on-screen height, so the card can cap itself and scroll its detail rather
|
||||
/// than growing past the screen and pushing Allow/Deny out of reach. 0 until measured.
|
||||
var availableHeight: CGFloat = 0
|
||||
|
||||
@State private var showAlwaysMenu = false
|
||||
|
||||
@@ -27,22 +30,14 @@ struct ApprovalCardView: View {
|
||||
.background(approval.risk.color.opacity(0.2), in: Capsule())
|
||||
.foregroundStyle(approval.risk.color)
|
||||
}
|
||||
// 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 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)
|
||||
} else if !approval.input.approvalDetail.isEmpty {
|
||||
ApprovalDetailBox(text: approval.input.approvalDetail)
|
||||
|
||||
// The detail (commit card, host breakdown + command, or plain text) scrolls inside a
|
||||
// bounded region so a giant command can't grow the card off-screen; the buttons below
|
||||
// stay pinned and always tappable. Rendered only when there's detail to show.
|
||||
if hasDetail {
|
||||
BoundedCardScroll(maxHeight: middleMaxHeight) {
|
||||
VStack(alignment: .leading, spacing: 10) { detailContent }
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
@@ -83,6 +78,73 @@ struct ApprovalCardView: View {
|
||||
}
|
||||
.padding()
|
||||
.glassSurface(cornerRadius: 20)
|
||||
// A hard ceiling on the whole card: even if the chrome estimate is off, the card can never
|
||||
// exceed the screen. `nil` (before the height is measured) leaves it unconstrained.
|
||||
.frame(maxHeight: availableHeight > 0 ? availableHeight - 12 : nil, alignment: .bottom)
|
||||
}
|
||||
|
||||
// MARK: - Detail
|
||||
|
||||
/// The kinds of detail an approval carries, resolved once so the body and `hasDetail` agree.
|
||||
/// A git commit reads as a structured commit card (subject + Markdown body); a host_exec gate
|
||||
/// lays out its parsed breakdown + exact command; every other tool shows its untruncated detail.
|
||||
private enum Detail {
|
||||
case commit(GitCommitSummary.Commit, command: String)
|
||||
case host(HostCommandSummary.Summary, command: String)
|
||||
case text(String)
|
||||
case none
|
||||
}
|
||||
|
||||
private var detail: Detail {
|
||||
if let command = approval.input["command"]?.stringValue,
|
||||
let commit = GitCommitSummary.parse(command) {
|
||||
return .commit(commit, command: command)
|
||||
}
|
||||
if let command = approval.input["command"]?.stringValue,
|
||||
let parsed = HostCommandSummary.summary(for: command),
|
||||
approval.toolName == HostCommandSummary.hostExecToolName
|
||||
|| parsed.invocations.count > 1 || parsed.isDestructive {
|
||||
return .host(parsed, command: command)
|
||||
}
|
||||
let text = approval.input.approvalDetail
|
||||
return text.isEmpty ? .none : .text(text)
|
||||
}
|
||||
|
||||
private var hasDetail: Bool {
|
||||
if case .none = detail { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
@ViewBuilder private var detailContent: some View {
|
||||
switch detail {
|
||||
case .commit(let commit, let command):
|
||||
GitCommitCard(commit: commit, rawCommand: command)
|
||||
case .host(let parsed, let command):
|
||||
HostCommandBreakdown(summary: parsed)
|
||||
Text("Exact command").font(.caption2.weight(.semibold)).foregroundStyle(.secondary)
|
||||
ApprovalDetailBox(text: Self.truncatedForDisplay(command))
|
||||
case .text(let text):
|
||||
ApprovalDetailBox(text: Self.truncatedForDisplay(text))
|
||||
case .none:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
/// The height the scrolling detail may take before it starts scrolling — the screen minus a
|
||||
/// generous reserve for the header, risk tag, buttons, and "Allow always…" menu, so those
|
||||
/// always have room. Falls back to a roomy default before the height is measured.
|
||||
private var middleMaxHeight: CGFloat {
|
||||
let base = availableHeight > 0 ? availableHeight - 12 : 640
|
||||
return max(140, base - 220)
|
||||
}
|
||||
|
||||
/// A very long command/detail is capped for display so one giant blob can't dominate the card;
|
||||
/// the middle still scrolls for the rest. The full command is still what gets approved — the
|
||||
/// breakdown above spells out its structure regardless.
|
||||
private static func truncatedForDisplay(_ text: String, limit: Int = 4000) -> String {
|
||||
guard text.count > limit else { return text }
|
||||
let omitted = text.count - limit
|
||||
return String(text.prefix(limit)) + "\n… [\(omitted) more characters truncated]"
|
||||
}
|
||||
|
||||
private func alwaysLabel(_ scope: AlwaysScope) -> String {
|
||||
@@ -94,33 +156,47 @@ struct ApprovalCardView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// The full content of an approval, wrapped and selectable. The box sizes to its
|
||||
/// content and only begins scrolling once the content exceeds `maxHeight`, so a short
|
||||
/// command sits in a snug box while a large paste stays bounded. The text wraps; it
|
||||
/// never scrolls horizontally.
|
||||
/// The full content of an approval, wrapped and selectable. It no longer scrolls on its own — the
|
||||
/// enclosing ``BoundedCardScroll`` owns the scrolling for the whole card — so it simply sizes to
|
||||
/// its (already length-capped) text. The text wraps; it never scrolls horizontally.
|
||||
private struct ApprovalDetailBox: View {
|
||||
let text: String
|
||||
private let maxHeight: CGFloat = 200
|
||||
|
||||
var body: some View {
|
||||
Text(text)
|
||||
.font(.caption.monospaced())
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(8)
|
||||
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps the variable-height middle of an input card so it scrolls only once it would exceed
|
||||
/// `maxHeight`. Below that it sizes to its content (a snug card); above it the content scrolls
|
||||
/// while the card's header and action buttons stay pinned on-screen — so a giant command or a long
|
||||
/// list of options can never push Allow/Deny (or Submit) off the bottom of the phone. Shared by the
|
||||
/// approval and question cards.
|
||||
struct BoundedCardScroll<Content: View>: View {
|
||||
let maxHeight: CGFloat
|
||||
@ViewBuilder var content: Content
|
||||
@State private var contentHeight: CGFloat = 0
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.vertical) {
|
||||
Text(text)
|
||||
.font(.caption.monospaced())
|
||||
.textSelection(.enabled)
|
||||
content
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(8)
|
||||
.background(GeometryReader { geo in
|
||||
Color.clear.preference(key: ApprovalDetailHeightKey.self, value: geo.size.height)
|
||||
Color.clear.preference(key: CardContentHeightKey.self, value: geo.size.height)
|
||||
})
|
||||
}
|
||||
.frame(height: contentHeight == 0 ? nil : min(contentHeight, maxHeight))
|
||||
.onPreferenceChange(ApprovalDetailHeightKey.self) { contentHeight = $0 }
|
||||
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 8))
|
||||
.scrollBounceBehavior(.basedOnSize)
|
||||
.onPreferenceChange(CardContentHeightKey.self) { contentHeight = $0 }
|
||||
}
|
||||
}
|
||||
|
||||
private struct ApprovalDetailHeightKey: PreferenceKey {
|
||||
struct CardContentHeightKey: PreferenceKey {
|
||||
static var defaultValue: CGFloat { 0 }
|
||||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||
value = max(value, nextValue())
|
||||
|
||||
Reference in New Issue
Block a user