diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift index 947e37e1..82898631 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift @@ -11,6 +11,40 @@ final class GhosttySurfaceView: NSView, Identifiable { let length: UInt64 } + private final class CachedValue { + private var value: T? + private let fetch: () -> T + private let duration: Duration + private var expiryTask: Task? + + init(duration: Duration, fetch: @escaping () -> T) { + self.duration = duration + self.fetch = fetch + } + + deinit { + expiryTask?.cancel() + } + + func get() -> T { + if let value { + return value + } + + let fetched = fetch() + value = fetched + expiryTask?.cancel() + expiryTask = Task { [weak self] in + guard let self else { return } + try? await ContinuousClock().sleep(for: self.duration) + guard !Task.isCancelled else { return } + self.value = nil + self.expiryTask = nil + } + return fetched + } + } + private let runtime: GhosttyRuntime let id = UUID() let bridge: GhosttySurfaceBridge @@ -34,6 +68,10 @@ final class GhosttySurfaceView: NSView, Identifiable { private var eventMonitor: Any? private var notificationObservers: [NSObjectProtocol] = [] private var prevPressureStage: Int = 0 + private lazy var cachedScreenContents = CachedValue(duration: .milliseconds(500)) { + [weak self] in + self?.readScreenContents() ?? "" + } var passwordInput: Bool = false { didSet { let input = SecureInput.shared @@ -99,6 +137,17 @@ final class GhosttySurfaceView: NSView, Identifiable { return normalized } + static func accessibilityLine(for index: Int, in content: String) -> Int { + let clampedIndex = min(max(index, 0), content.count) + let prefix = String(content.prefix(clampedIndex)) + return max(0, prefix.components(separatedBy: .newlines).count - 1) + } + + static func accessibilityString(for range: NSRange, in content: String) -> String? { + guard let swiftRange = Range(range, in: content) else { return nil } + return String(content[swiftRange]) + } + override var acceptsFirstResponder: Bool { true } init( @@ -352,8 +401,8 @@ final class GhosttySurfaceView: NSView, Identifiable { } override func accessibilityRole() -> NSAccessibility.Role? { - // De facto role used by terminal emulators; AppKit doesn't provide a named constant for it. - NSAccessibility.Role(rawValue: "AXTerminal") + // Match Ghostty.app so speech/input tools can treat the surface as editable text. + .textArea } override func accessibilityLabel() -> String? { @@ -369,13 +418,57 @@ final class GhosttySurfaceView: NSView, Identifiable { } override func accessibilityValue() -> Any? { - bridge.state.pwd + cachedScreenContents.get() } override func accessibilityHelp() -> String? { accessibilityPaneIndexHelp } + override func accessibilitySelectedTextRange() -> NSRange { + selectedRange() + } + + override func accessibilitySelectedText() -> String? { + guard let surface else { return nil } + var text = ghostty_text_s() + guard ghostty_surface_read_selection(surface, &text) else { return nil } + defer { ghostty_surface_free_text(surface, &text) } + let value = String(cString: text.text) + return value.isEmpty ? nil : value + } + + override func accessibilityNumberOfCharacters() -> Int { + cachedScreenContents.get().count + } + + override func accessibilityVisibleCharacterRange() -> NSRange { + let content = cachedScreenContents.get() + return NSRange(location: 0, length: content.count) + } + + override func accessibilityLine(for index: Int) -> Int { + Self.accessibilityLine(for: index, in: cachedScreenContents.get()) + } + + override func accessibilityString(for range: NSRange) -> String? { + Self.accessibilityString(for: range, in: cachedScreenContents.get()) + } + + override func accessibilityAttributedString(for range: NSRange) -> NSAttributedString? { + guard let surface else { return nil } + guard let plainString = accessibilityString(for: range) else { return nil } + + var attributes: [NSAttributedString.Key: Any] = [:] + if let fontRaw = ghostty_surface_quicklook_font(surface) { + let font = Unmanaged.fromOpaque(fontRaw) + attributes[.font] = font.takeUnretainedValue() + font.release() + } + + return NSAttributedString(string: plainString, attributes: attributes) + } + override func becomeFirstResponder() -> Bool { let result = super.becomeFirstResponder() if result { @@ -403,6 +496,29 @@ final class GhosttySurfaceView: NSView, Identifiable { } } + private func readScreenContents() -> String { + guard let surface else { return "" } + var text = ghostty_text_s() + let selection = ghostty_selection_s( + top_left: ghostty_point_s( + tag: GHOSTTY_POINT_SCREEN, + coord: GHOSTTY_POINT_COORD_TOP_LEFT, + x: 0, + y: 0 + ), + bottom_right: ghostty_point_s( + tag: GHOSTTY_POINT_SCREEN, + coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT, + x: 0, + y: 0 + ), + rectangle: false + ) + guard ghostty_surface_read_text(surface, selection, &text) else { return "" } + defer { ghostty_surface_free_text(surface, &text) } + return String(cString: text.text) + } + override func keyDown(with event: NSEvent) { guard let surface else { interpretKeyEvents([event]) diff --git a/supacodeTests/GhosttySurfaceViewTests.swift b/supacodeTests/GhosttySurfaceViewTests.swift index 8a9d2c2c..0b52261d 100644 --- a/supacodeTests/GhosttySurfaceViewTests.swift +++ b/supacodeTests/GhosttySurfaceViewTests.swift @@ -1,3 +1,4 @@ +import Foundation import Testing @testable import supacode @@ -18,4 +19,30 @@ struct GhosttySurfaceViewTests { @Test func normalizedWorkingDirectoryPathKeepsRootPath() { #expect(GhosttySurfaceView.normalizedWorkingDirectoryPath("/") == "/") } + + @Test func accessibilityLineCountsLineBreaksUpToIndex() { + let content = "alpha\nbeta\ngamma" + + #expect(GhosttySurfaceView.accessibilityLine(for: 0, in: content) == 0) + #expect(GhosttySurfaceView.accessibilityLine(for: 5, in: content) == 0) + #expect(GhosttySurfaceView.accessibilityLine(for: 6, in: content) == 1) + #expect(GhosttySurfaceView.accessibilityLine(for: content.count, in: content) == 2) + } + + @Test func accessibilityStringReturnsSubstringForValidRange() { + let content = "alpha\nbeta" + + #expect( + GhosttySurfaceView.accessibilityString( + for: NSRange(location: 6, length: 4), + in: content + ) == "beta" + ) + #expect( + GhosttySurfaceView.accessibilityString( + for: NSRange(location: 99, length: 1), + in: content + ) == nil + ) + } } -- 2.51.2 From 0d321b2f1185442a92e3583e9fa9233afcb18c77 Mon Sep 17 00:00:00 2001 From: khoi <6994441+khoi@users.noreply.github.com> Date: Mon, 2 Mar 2026 13:29:14 +0700 Subject: [PATCH 2/3] Prune archived worktrees from terminal state --- .../Features/App/Reducer/AppFeature.swift | 5 +- .../AppFeatureArchivedSelectionTests.swift | 56 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index dc3c4dab..c50b1938 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -188,7 +188,10 @@ struct AppFeature { } case .repositories(.delegate(.repositoriesChanged(let repositories))): - let ids = Set(repositories.flatMap { $0.worktrees.map(\.id) }) + let archivedIDs = state.repositories.archivedWorktreeIDSet + let ids = Set( + repositories.flatMap { $0.worktrees.map(\.id) }.filter { !archivedIDs.contains($0) } + ) let recencyIDs = CommandPaletteFeature.recencyRetentionIDs(from: repositories) let worktrees = state.repositories.worktreesForInfoWatcher() state.runScriptStatusByWorktreeID = state.runScriptStatusByWorktreeID.filter { ids.contains($0.key) } diff --git a/supacodeTests/AppFeatureArchivedSelectionTests.swift b/supacodeTests/AppFeatureArchivedSelectionTests.swift index dd22cc8e..a95d904d 100644 --- a/supacodeTests/AppFeatureArchivedSelectionTests.swift +++ b/supacodeTests/AppFeatureArchivedSelectionTests.swift @@ -48,4 +48,60 @@ struct AppFeatureArchivedSelectionTests { await store.finish() #expect(saved.value.isEmpty) } + + @Test(.dependencies) func repositoriesChangedPrunesArchivedWorktreesFromTerminalAndRunScriptStatus() async { + let rootURL = URL(fileURLWithPath: "/tmp/repo") + let activeWorktree = Worktree( + id: "/tmp/repo/wt-active", + name: "wt-active", + detail: "", + workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt-active"), + repositoryRootURL: rootURL + ) + let archivedWorktree = Worktree( + id: "/tmp/repo/wt-archived", + name: "wt-archived", + detail: "", + workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt-archived"), + repositoryRootURL: rootURL + ) + let repository = Repository( + id: rootURL.path(percentEncoded: false), + rootURL: rootURL, + name: "repo", + worktrees: IdentifiedArray(uniqueElements: [activeWorktree, archivedWorktree]) + ) + var repositoriesState = RepositoriesFeature.State(repositories: [repository]) + repositoriesState.selection = .worktree(activeWorktree.id) + repositoriesState.archivedWorktreeIDs = [archivedWorktree.id] + var appState = AppFeature.State( + repositories: repositoriesState, + settings: SettingsFeature.State() + ) + appState.runScriptStatusByWorktreeID = [ + activeWorktree.id: true, + archivedWorktree.id: true, + ] + let sentCommands = LockIsolated<[TerminalClient.Command]>([]) + let store = TestStore(initialState: appState) { + AppFeature() + } withDependencies: { + $0.terminalClient.send = { command in + sentCommands.withValue { $0.append(command) } + } + $0.worktreeInfoWatcher.send = { _ in } + } + store.exhaustivity = .off + + await store.send(.repositories(.delegate(.repositoriesChanged([repository])))) { + $0.runScriptStatusByWorktreeID = [activeWorktree.id: true] + } + await store.finish() + + #expect( + sentCommands.value == [ + .prune([activeWorktree.id]) + ] + ) + } } -- 2.51.2 From e63fed1aa59627764aff65238306fcf12eef4dea Mon Sep 17 00:00:00 2001 From: khoi <6994441+khoi@users.noreply.github.com> Date: Mon, 2 Mar 2026 13:43:53 +0700 Subject: [PATCH 3/3] bump v0.6.3 --- supacode.xcodeproj/project.pbxproj | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/supacode.xcodeproj/project.pbxproj b/supacode.xcodeproj/project.pbxproj index 40c54608..e52e7f46 100644 --- a/supacode.xcodeproj/project.pbxproj +++ b/supacode.xcodeproj/project.pbxproj @@ -435,7 +435,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = YES; - CURRENT_PROJECT_VERSION = 107; + CURRENT_PROJECT_VERSION = 108; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = ""; @@ -457,7 +457,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 0.6.2; + MARKETING_VERSION = 0.6.3; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -490,7 +490,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COMPILATION_CACHE_ENABLE_CACHING = NO; - CURRENT_PROJECT_VERSION = 107; + CURRENT_PROJECT_VERSION = 108; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = ""; @@ -512,7 +512,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 0.6.2; + MARKETING_VERSION = 0.6.3; OTHER_LDFLAGS = ( "$(inherited)", "-lc++", @@ -541,12 +541,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 107; + CURRENT_PROJECT_VERSION = 108; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 0.6.2; + MARKETING_VERSION = 0.6.3; PRODUCT_BUNDLE_IDENTIFIER = app.supabit.supacodeTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -563,12 +563,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 107; + CURRENT_PROJECT_VERSION = 108; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.1; - MARKETING_VERSION = 0.6.2; + MARKETING_VERSION = 0.6.3; PRODUCT_BUNDLE_IDENTIFIER = app.supabit.supacodeTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO;