Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .ai/memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -561,3 +561,12 @@
- **Over-scroll degrades to appending rather than clamping.** A move above the window is ignored — the pre-window behaviour, which looks wrong but never writes to the *wrong* row. Deliberate escape hatch for sequences this model doesn't cover (`ESC[H`, `ESC[J`, scroll regions, alt screen — brew's download queue uses none of them).
- **Trailing-newline-ness is a property of how a row ended, not of `isComplete`.** Conflating them regressed `BrewCommandServicePseudoTerminalTests`: a child that ends with `printf 'tty'` (no newline) must not gain one in `standardOutput`. `TerminalTranscript` tracks `endsWithNewline` separately so the returned transcript holds settled rows without inventing terminators.
- **Rejected: adopting SwiftTerm.** Its `Terminal` models a fixed grid + scrollback; the console models a variable-length transcript with stable row identities for SwiftUI diffing. Using the engine headless means diffing a grid to infer which rows changed — plausibly more code than this, and it fights `CONVENTIONS.md`'s "add packages sparingly". Revisit only if the app ever needs to run arbitrary interactive commands (a shell, a pager, anything using the alt screen), where hand-rolling stops being viable.

## 2026-08-31 — The console body is a text view, not a list of rows

- **What was wrong.** A `List` with one `Text` per line gives every line its own selection scope. Dragging across lines selected nothing, ⌘A had no document to select, the gaps between rows (row insets, and the empty area below the last line) weren't text at all so clicks there did nothing, and the pointer alternated between an arrow and an I-beam depending on which of those it was over. All four are the same defect: there was no document.
- **`NSTextView` is the fix, and it stays inside View + ViewModel.** `ConsoleTextView` (an `NSViewRepresentable` in `BrewFeatureConsole/Views/`) hosts a TextKit 1 text view — selectable, not editable, `isRichText = false` so ⌘C yields plain text. `ANSIConsoleText` now renders `NSAttributedString`; colours are still named on the SwiftUI token palette and bridged with `NSColor(_:)`, so the design system remains the source and light/dark still resolve. A colour-scheme flip re-renders the whole document (tracked with `@Environment(\.colorScheme)` as a *stored* dependency — reading `context.environment` inside `updateNSView` isn't a reliable invalidation trigger).
- **Streaming is a diff, not a rebuild.** `ConsoleTranscript` (ViewModels) holds the rendered lines plus their cached UTF-16 lengths and returns the minimal edit — every change `brew` makes lands in a suffix, so it is "replace from the first line that differs to the end". Rebuilding per line would be quadratic over a run **and** would drop the user's selection each time. Lines compare on stream + text only; identity is deliberately ignored because a redrawn progress row keeps its id (see 2026-08-27).
- **The renderer and the transcript must agree character-for-character.** Offsets address the rendered text, so `ConsoleTranscript.text(of:)` and `ANSIConsoleText.attributed(for:)` both emit spans-joined + `"\n"`. A unit test pins the invariant (`rendered length == transcript.length`) because nothing else would catch drift.
- **Follow-the-tail is owned by a scroll observer, not by the update.** `ConsoleOutputTextView` gives up following when the user scrolls away and takes it back when they return to the end — growing the document doesn't move the clip view, so the bounds-change notification only ever fires for a real scroll. The pin also runs from `layout()`: SwiftUI hands over the first batch of output *before* the scroll view has any size, and a scroll issued then goes nowhere (symptom: opening the console on a finished job showed the top of the log).
- **Verified by driving the real app** (DYLD-injected driver, see the 2026-08-25 note): drag across lines selects four lines; `selectAll:` validates on the first responder and selects the whole document; hit-testing the bottom edge of the output area lands on the text view (no arrow-cursor dead zone); a 200-character selection survives ~1100 characters of streamed output; scrolling away leaves the reader in place. Menu-routed ⌘A could **not** be exercised — a bare exec never becomes the key app — so that path rests on the responder validating `selectAll:`.
4 changes: 2 additions & 2 deletions BrewUITests/Screens/ConsoleScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,8 @@ struct ConsoleScreen: Screen {
line: UInt = #line,
) -> Self {
expand(file: file, line: line)
// macOS exposes a `List` row's `Text` through the accessibility *value*, with no label at all,
// so matching on label alone silently never matches.
// macOS exposes text through the accessibility *value*, with no label at all, so matching on
// label alone silently never matches.
let predicate = NSPredicate(format: "label CONTAINS %@ OR value CONTAINS %@", substring, substring)
let match = output.element.descendants(matching: .any).matching(predicate).firstMatch
XCTAssertTrue(
Expand Down
14 changes: 14 additions & 0 deletions Sources/BrewFeatureConsole/ViewModels/ConsoleBodyContent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//
// ConsoleBodyContent.swift
// BrewFeatureConsole
//

import BrewCore
import BrewRepositoryInterfaces

/// What the expanded console body shows. The job's identity travels with its lines because the text
/// view treats a different job as a different document.
enum ConsoleBodyContent: Equatable {
case noActivity
case output(jobID: CommandJobID, lines: [BrewCommandOutputLine])
}
65 changes: 65 additions & 0 deletions Sources/BrewFeatureConsole/ViewModels/ConsoleTranscript.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
//
// ConsoleTranscript.swift
// BrewFeatureConsole
//

import BrewCore
import Foundation

/// The console body's output as one text document, plus the smallest edit that brings an already
/// rendered copy of it up to date.
///
/// Every change `brew` makes to the buffer lands in a suffix of it, so an update is "replace from the
/// first line that differs to the end" — re-rendering the whole thing per line would be quadratic over
/// a run and would drop the user's selection. Offsets are UTF-16, as `NSTextStorage` indexes.
struct ConsoleTranscript: Equatable {
private(set) var lines: [BrewCommandOutputLine] = []

/// Cached so an update doesn't re-measure the buffer.
private var lengths: [Int] = []

struct Edit: Equatable {
let location: Int
let length: Int
let lines: [BrewCommandOutputLine]
}

/// The renderer must agree with this character-for-character, or the offsets address the wrong text.
static func text(of line: BrewCommandOutputLine) -> String {
line.spans.map(\.text).joined() + "\n"
}

var text: String {
lines.map(Self.text(of:)).joined()
}

var length: Int {
lengths.reduce(0, +)
}

/// The edit a rendered document needs to catch up, or `nil` when nothing changed.
mutating func update(to newLines: [BrewCommandOutputLine]) -> Edit? {
let common = commonPrefixCount(with: newLines)
guard common < lines.count || common < newLines.count else {
return nil
}
let location = lengths.prefix(common).reduce(0, +)
let replacedLength = lengths.dropFirst(common).reduce(0, +)
let replacement = Array(newLines[common...])
lines = newLines
lengths = Array(lengths.prefix(common)) + replacement.map { Self.text(of: $0).utf16.count }
return Edit(location: location, length: replacedLength, lines: replacement)
}

/// Identity is deliberately not compared: a redrawn progress row keeps its id and changes its text.
private func commonPrefixCount(with newLines: [BrewCommandOutputLine]) -> Int {
var index = 0
while index < lines.count, index < newLines.count,
lines[index].stream == newLines[index].stream,
lines[index].text == newLines[index].text
{
index += 1
}
return index
}
}
7 changes: 7 additions & 0 deletions Sources/BrewFeatureConsole/ViewModels/ConsoleViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ final class ConsoleViewModel {
return repository.orderedIDs.last.flatMap { repository.jobs[$0] }
}

var bodyContent: ConsoleBodyContent {
guard let job = selectedJob else {
return .noActivity
}
return .output(jobID: job.id, lines: job.output)
}

var statusPresentation: ConsoleStatusPresentation {
if let active = activeJob {
return ConsoleStatusPresentation(
Expand Down
71 changes: 55 additions & 16 deletions Sources/BrewFeatureConsole/Views/ANSIConsoleText.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,57 @@
// BrewFeatureConsole
//

import AppKit
import BrewCore
import BrewUIComponents
import SwiftUI

/// Spans without an explicit foreground fall back to `defaultColor` so an uncoloured line looks exactly
/// as it did before ANSI support. Bold spans get a bold monospaced font; every other run is left
/// fontless so it inherits the `Text`'s base font — setting a font on all runs would defeat that.
/// Renders output lines into the attributed text the console's text view displays.
///
/// Colours are named on the SwiftUI token palette and bridged with `NSColor(_:)`, which keeps the
/// asset's light/dark variants — the design system stays the source even though the drawing is AppKit's.
enum ANSIConsoleText {
/// Spans are resolved once where the line is built, off the main actor.
static func attributed(for line: BrewCommandOutputLine, defaultColor: Color) -> AttributedString {
var result = AttributedString()
static let font = NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular)
static let boldFont = NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .bold)

private static let lineSpacing = BrewSpacing.xxs

/// Newline-terminated to match ``ConsoleTranscript/text(of:)``, whose offsets address this text.
static func attributed(for line: BrewCommandOutputLine) -> NSAttributedString {
let result = NSMutableAttributedString()
let fallback = defaultColor(for: line.stream)
for span in line.spans {
var piece = AttributedString(span.text)
piece.foregroundColor = span.style.foreground.map(color(for:)) ?? defaultColor
if span.style.bold {
piece.font = .system(.body, design: .monospaced).weight(.bold)
}
result.append(piece)
result.append(NSAttributedString(
string: span.text,
attributes: attributes(
color: span.style.foreground.map(color(for:)) ?? fallback,
bold: span.style.bold,
),
))
}
result.append(NSAttributedString(string: "\n", attributes: attributes(color: fallback, bold: false)))
return result
}

/// Maps a terminal-palette colour onto a SwiftUI colour. Semantic system colours are used so the
/// output adapts to light/dark and stays legible on the console surface; bright variants reuse the
/// same hue since the display palette doesn't distinguish them.
private static func color(for ansiColor: ANSIColor) -> Color {
static func attributed(for lines: [BrewCommandOutputLine]) -> NSAttributedString {
let result = NSMutableAttributedString()
for line in lines {
result.append(attributed(for: line))
}
return result
}

static func defaultColor(for stream: BrewCommandOutputLine.Stream) -> Color {
switch stream {
case .stdout:
.brewTextPrimary
case .stderr:
.brewStatusError
}
}

/// Bright variants reuse their base hue, since the display palette doesn't distinguish them.
static func color(for ansiColor: ANSIColor) -> Color {
switch ansiColor {
case .black, .brightBlack:
.brewTextSecondary
Expand All @@ -48,4 +73,18 @@ enum ANSIConsoleText {
.brewTextPrimary
}
}

private static func attributes(color: Color, bold: Bool) -> [NSAttributedString.Key: Any] {
[
.font: bold ? boldFont : font,
.foregroundColor: NSColor(color),
.paragraphStyle: paragraphStyle,
]
}

private static let paragraphStyle: NSParagraphStyle = {
let style = NSMutableParagraphStyle()
style.lineSpacing = lineSpacing
return style
}()
}
48 changes: 6 additions & 42 deletions Sources/BrewFeatureConsole/Views/ConsoleBody.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,62 +3,26 @@
// Brew
//

import BrewAccessibilityID
import BrewCore
import BrewRepositoryInterfaces
import BrewUIComponents
import SwiftUI

/// Output area of the expanded console — virtualizing `List` over the selected job's output buffer
/// with auto-pin-to-bottom when new lines arrive. User scroll-lock-on-scroll-up is deferred to polish.
/// Output area of the expanded console.
struct ConsoleBody: View {
let viewModel: ConsoleViewModel

var body: some View {
if let job = viewModel.selectedJob {
outputList(for: job)
} else {
switch viewModel.bodyContent {
case .noActivity:
ContentUnavailableView(
"No activity",
systemImage: "terminal",
description: Text("Run a brew command to see output here."),
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.brewSurfaceElevated)
}
}

private func outputList(for job: CommandJob) -> some View {
ScrollViewReader { proxy in
List(job.output) { line in
Text(ANSIConsoleText.attributed(
for: line,
defaultColor: line.stream == .stderr ? Color.brewStatusError : Color.brewTextPrimary,
))
.font(.system(.body, design: .monospaced))
.textSelection(.enabled)
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(top: 1, leading: BrewSpacing.lg, bottom: 1, trailing: BrewSpacing.lg))
.id(line.id)
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(Color.brewSurfaceElevated)
.axid(.consoleOutput)
.onAppear {
if let last = job.output.last {
proxy.scrollTo(last.id, anchor: .bottom)
}
}
.onChange(of: job.output.count) {
guard let last = job.output.last else {
return
}
withAnimation(.linear(duration: 0.1)) {
proxy.scrollTo(last.id, anchor: .bottom)
}
}
case let .output(jobID, lines):
ConsoleTextView(lines: lines, jobID: jobID)
.background(Color.brewSurfaceElevated)
}
}
}
Loading
Loading