Merge nucleic/fuzzy-glass-quail-uswb into dev

This commit is contained in:
2026-08-02 05:32:23 -07:00
parent a34c439771
commit 221407681d
2 changed files with 64 additions and 52 deletions
+13 -35
View File
@@ -62,8 +62,7 @@ struct EditableCodeBlock: View {
CodeBlockEditor(
text: $draft,
fontSize: bodySize,
fittedHeight: $fittedTextHeight,
maximumHeight: maximumEditorHeight)
fittedHeight: $fittedTextHeight)
.frame(height: editorHeight)
// Together with the editor's own text-container inset this matches the
// read-only padding, so committing an edit doesn't shift the code.
@@ -107,24 +106,19 @@ struct EditableCodeBlock: View {
// MARK: - editor sizing
/// Past this the editor stops growing and starts scrolling, so one long snippet can't
/// push the rest of the transcript off-screen.
private static let maximumEditorLines = 24
private var maximumEditorHeight: CGFloat {
CodeBlockEditor.height(forLines: Self.maximumEditorLines, fontSize: bodySize)
}
/// Sized to the text so short snippets don't open a tall empty editor and long ones
/// don't push the rest of the transcript off-screen past that, the editor scrolls.
/// Sized to the text, with no ceiling: the editor grows with its content and never
/// scrolls vertically. It renders inside the transcript's scroll surface, where an
/// inner vertical scroller swallowed the wheel the moment the pointer crossed a long
/// snippet, freezing transcript scrolling a tall row is the transcript's problem to
/// lay out, and the virtualized table sizes rows to content.
private var editorHeight: CGFloat {
guard fittedTextHeight > 0 else {
// First frame, before the editor has laid out: the same font metrics it will
// use, so an unwrapped snippet opens at exactly its final height.
let lines = draft.components(separatedBy: "\n").count
return min(CodeBlockEditor.height(forLines: lines, fontSize: bodySize), maximumEditorHeight)
return CodeBlockEditor.height(forLines: lines, fontSize: bodySize)
}
return min(fittedTextHeight, maximumEditorHeight)
return fittedTextHeight
}
private func toggleEditing() {
@@ -162,8 +156,6 @@ private struct CodeBlockEditor: NSViewRepresentable {
let fontSize: CGFloat
/// Height the text needs, insets included, rounded up to a whole point.
@Binding var fittedHeight: CGFloat
/// The block's ceiling. Only text taller than this may scroll.
let maximumHeight: CGFloat
/// Paired with the caller's 4pt padding this reproduces the read-only block's 8pt
/// inset, so the code sits in the same place in both states.
@@ -215,7 +207,8 @@ private struct CodeBlockEditor: NSViewRepresentable {
scroll.hasHorizontalScroller = false
scroll.autohidesScrollers = true
scroll.scrollerStyle = .overlay
// Starts fitted; `scrollsVertically` turns these on if the text outgrows the block.
// The editor never scrolls: it is sized to its text (see `editorHeight`), and the
// scroll view exists only as the text view's conventional container.
scroll.hasVerticalScroller = false
scroll.verticalScrollElasticity = .none
scroll.horizontalScrollElasticity = .none
@@ -269,7 +262,6 @@ private struct CodeBlockEditor: NSViewRepresentable {
// Whole points only: this height flows into the transcript's layout, and a
// fractional one puts this row and everything under it off the pixel grid.
let fitted = ceil(used + textView.textContainerInset.height * 2)
scrollView?.scrollsVertically = fitted > parent.maximumHeight + 0.5
guard abs(parent.fittedHeight - fitted) > 0.5 else { return }
// Defer the binding write: this can fire from inside a SwiftUI update or an
// AppKit layout pass.
@@ -281,29 +273,18 @@ private struct CodeBlockEditor: NSViewRepresentable {
}
/// Scroll view for the code editor. It reports relayout (a window resize rewraps the text,
/// changing the fitted height) and, crucially, only takes the scroll wheel when the code
/// really overflows otherwise the wheel goes back to the transcript, so hovering a code
/// block on the way past doesn't swallow the scroll.
/// changing the fitted height) and never takes the scroll wheel: the editor is sized to its
/// text, so there is never anywhere to scroll to, and the wheel always belongs to the
/// transcript hovering a code block on the way past must not swallow the scroll.
final class CodeBlockScrollView: NSScrollView {
var onLayout: (() -> Void)?
/// Set from the height pass: true only when the text is taller than the block's ceiling
/// and there is somewhere to scroll to.
var scrollsVertically = false {
didSet {
guard scrollsVertically != oldValue else { return }
hasVerticalScroller = scrollsVertically
verticalScrollElasticity = scrollsVertically ? .automatic : .none
}
}
override func layout() {
super.layout()
onLayout?()
}
override func scrollWheel(with event: NSEvent) {
guard scrollsVertically else {
// Hand the event to the transcript's scroll view directly. Walking the responder
// chain instead can dead-end on a SwiftUI host view that neither scrolls nor
// forwards, which reads to the user as scrolling being dead over a code block.
@@ -312,9 +293,6 @@ final class CodeBlockScrollView: NSScrollView {
} else {
nextResponder?.scrollWheel(with: event)
}
return
}
super.scrollWheel(with: event)
}
}
+43 -9
View File
@@ -50,10 +50,15 @@ struct HostExecCard: View {
/// Host execution always carries the attention accent: it's never the calm default path.
private var accent: Color { tint ?? palette.attention }
/// Past this height the command block scrolls instead of growing, so a long command can't
/// push the Allow/Deny buttons off-screen. Mirrors `ApprovalDetailBox`.
private let maxHeight: CGFloat = 220
/// Past this height the command block collapses behind a "Show more" control instead of
/// growing unbounded. Deliberately NOT a nested vertical scroller: this card renders
/// inside the transcript's scroll surface, and an inner vertical scroll view swallows
/// the wheel the moment the pointer crosses it, freezing transcript scrolling.
private let collapsedHeight: CGFloat = 220
@State private var contentHeight: CGFloat = 0
@State private var commandExpanded = false
private var commandOverflows: Bool { contentHeight > collapsedHeight + 0.5 }
/// The deterministic, command-derived breakdown (program, actions, flags, inferred purpose),
/// parsed from the command string alone never from anything the agent claimed it would do.
@@ -109,11 +114,13 @@ struct HostExecCard: View {
}
}
/// The command itself, pretty-printed in a bounded monospaced block. The text wraps and the
/// box sizes to its content a short command sits in a snug block and only begins
/// scrolling once the content exceeds `maxHeight`.
/// The command itself, pretty-printed in a bounded monospaced block. The text wraps and
/// the box sizes to its content a short command sits in a snug block. A long command
/// collapses to `collapsedHeight` behind "Show more", expanding in place rather than
/// scrolling: the transcript row simply grows, and wheel events always belong to the
/// transcript.
private var commandBlock: some View {
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text("$")
.font(.system(.callout, design: .monospaced))
@@ -129,15 +136,42 @@ struct HostExecCard: View {
.background(GeometryReader { geo in
Color.clear.preference(key: HostExecHeightKey.self, value: geo.size.height)
})
}
// Until the first measurement lands, show the content at full height so it never
// flashes as a zero-height sliver.
.frame(height: contentHeight == 0 ? nil : min(contentHeight, maxHeight))
.frame(
height: commandExpanded || contentHeight == 0
? nil : min(contentHeight, collapsedHeight),
alignment: .top)
.clipped()
if commandOverflows {
overflowToggle
}
}
.onPreferenceChange(HostExecHeightKey.self) { contentHeight = $0 }
.background(accent.opacity(0.05), in: .rect(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(accent.opacity(0.12), lineWidth: 1))
}
private var overflowToggle: some View {
Button {
commandExpanded.toggle()
} label: {
HStack(spacing: 5) {
Image(systemName: commandExpanded ? "chevron.up" : "chevron.down")
.font(.caption2.weight(.semibold))
Text(commandExpanded ? "Show less" : "Show more")
.font(.caption.weight(.medium))
}
.foregroundStyle(accent)
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.background(accent.opacity(0.07))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(commandExpanded ? "Show less of the command" : "Show the full command")
}
/// The agent's stated reason for needing the host, with an on-device trust badge that
/// cross-checks it against the command. The badge is the user's guard against a deceptive
/// reason: it's computed by a separate local model from the raw command + reason, so the