Merge nucleic/fuzzy-glass-quail-uswb into dev
This commit is contained in:
@@ -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,40 +273,26 @@ 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.
|
||||
if let transcript = enclosingScrollView {
|
||||
transcript.scrollWheel(with: event)
|
||||
} else {
|
||||
nextResponder?.scrollWheel(with: event)
|
||||
}
|
||||
return
|
||||
// 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.
|
||||
if let transcript = enclosingScrollView {
|
||||
transcript.scrollWheel(with: event)
|
||||
} else {
|
||||
nextResponder?.scrollWheel(with: event)
|
||||
}
|
||||
super.scrollWheel(with: event)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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: commandExpanded || contentHeight == 0
|
||||
? nil : min(contentHeight, collapsedHeight),
|
||||
alignment: .top)
|
||||
.clipped()
|
||||
if commandOverflows {
|
||||
overflowToggle
|
||||
}
|
||||
}
|
||||
// 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))
|
||||
.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
|
||||
|
||||
Reference in New Issue
Block a user