Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import Foundation
/// Pure and incremental: ``consume(_:)`` may be fed arbitrary chunk
/// boundaries, including ones that split an escape sequence (the tail is
/// carried over). Read ``blocks`` after feeding.
public final class OSC133CommandParser {
public struct OSC133CommandParser {
/// The command blocks parsed so far, oldest first.
public private(set) var blocks: [TerminalCommandBlock] = []

Expand Down Expand Up @@ -48,7 +48,7 @@ public final class OSC133CommandParser {
/// Feeds a chunk of raw terminal output through the state machine.
///
/// - Parameter text: A slice of the PTY stream, any length.
public func consume(_ text: String) {
public mutating func consume(_ text: String) {
let stream = pending + text
pending = ""
var index = stream.startIndex
Expand Down Expand Up @@ -78,7 +78,7 @@ public final class OSC133CommandParser {

/// Publishes the running block's output: the already-folded completed
/// lines plus the open line folded on its own (O(open line), not O(total)).
private func flushOpenOutput() {
private mutating func flushOpenOutput() {
guard phase == .output, let openIndex else { return }
blocks[openIndex].output = foldedOutput + Self.foldLine(openLine)
}
Expand Down Expand Up @@ -206,7 +206,7 @@ public final class OSC133CommandParser {

// MARK: - State transitions

private func apply(_ action: EscapeAction) {
private mutating func apply(_ action: EscapeAction) {
switch action {
case .promptStart:
finalizeOpenOutput()
Expand All @@ -231,7 +231,7 @@ public final class OSC133CommandParser {
}
}

private func appendText(_ char: Character) {
private mutating func appendText(_ char: Character) {
switch phase {
case .command:
commandBuffer.append(char)
Expand All @@ -251,7 +251,7 @@ public final class OSC133CommandParser {
}
}

private func openBlock() {
private mutating func openBlock() {
let block = TerminalCommandBlock(
id: nextID,
command: commandBuffer.trimmingCharacters(in: .whitespacesAndNewlines),
Expand All @@ -264,7 +264,7 @@ public final class OSC133CommandParser {
openIndex = blocks.count - 1
}

private func closeBlock(exitCode: Int?) {
private mutating func closeBlock(exitCode: Int?) {
guard let openIndex else { return }
blocks[openIndex].output = foldedOutput + Self.foldLine(openLine)
blocks[openIndex].exitCode = exitCode
Expand All @@ -274,7 +274,7 @@ public final class OSC133CommandParser {
openLine = ""
}

private func finalizeOpenOutput() {
private mutating func finalizeOpenOutput() {
// A new prompt without a D mark (e.g. Ctrl-C, or a shell that skipped
// D): close the open block with an unknown exit code.
if openIndex != nil { closeBlock(exitCode: nil) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ struct OSC133CommandParserTests {

@Test("a complete command/output/exit cycle yields one finished block")
func happyPath() {
let parser = OSC133CommandParser()
var parser = OSC133CommandParser()
parser.consume(mark("A") + "user@host$ " + mark("B") + "echo hi" + mark("C") + "hi\n" + mark("D;0"))
#expect(parser.blocks.count == 1)
let block = parser.blocks[0]
Expand All @@ -25,15 +25,15 @@ struct OSC133CommandParserTests {

@Test("a nonzero exit code marks the block failed")
func failure() {
let parser = OSC133CommandParser()
var parser = OSC133CommandParser()
parser.consume(mark("A") + mark("B") + "false" + mark("C") + mark("D;1"))
#expect(parser.blocks[0].exitCode == 1)
#expect(parser.blocks[0].failed)
}

@Test("a block with no D mark stays running until the next prompt closes it")
func runningUntilNextPrompt() {
let parser = OSC133CommandParser()
var parser = OSC133CommandParser()
parser.consume(mark("A") + mark("B") + "sleep 5" + mark("C") + "working")
#expect(parser.blocks.count == 1)
#expect(parser.blocks[0].isRunning)
Expand All @@ -46,7 +46,7 @@ struct OSC133CommandParserTests {

@Test("two commands produce two blocks with distinct ids")
func twoCommands() {
let parser = OSC133CommandParser()
var parser = OSC133CommandParser()
parser.consume(mark("A") + mark("B") + "ls" + mark("C") + "a b\n" + mark("D;0"))
parser.consume(mark("A") + mark("B") + "pwd" + mark("C") + "/tmp\n" + mark("D;0"))
#expect(parser.blocks.count == 2)
Expand All @@ -57,7 +57,7 @@ struct OSC133CommandParserTests {

@Test("an escape sequence split across chunks is parsed once completed")
func splitEscape() {
let parser = OSC133CommandParser()
var parser = OSC133CommandParser()
let full = mark("A") + mark("B") + "id" + mark("C") + "uid=0\n" + mark("D;0")
let mid = full.index(full.startIndex, offsetBy: 3)
parser.consume(String(full[..<mid]))
Expand All @@ -70,7 +70,7 @@ struct OSC133CommandParserTests {

@Test("streaming output updates the running block incrementally")
func streaming() {
let parser = OSC133CommandParser()
var parser = OSC133CommandParser()
parser.consume(mark("A") + mark("B") + "build" + mark("C"))
parser.consume("step 1\n")
#expect(parser.blocks[0].output == "step 1\n")
Expand All @@ -83,21 +83,21 @@ struct OSC133CommandParserTests {

@Test("carriage-return progress redraws fold to the final per-line state")
func carriageReturnFold() {
let parser = OSC133CommandParser()
var parser = OSC133CommandParser()
parser.consume(mark("A") + mark("B") + "dl" + mark("C") + "10%\r50%\r100%\n" + mark("D;0"))
#expect(parser.blocks[0].output == "100%\n")
}

@Test("entering the alt screen flags the running block interactive")
func altScreen() {
let parser = OSC133CommandParser()
var parser = OSC133CommandParser()
parser.consume(mark("A") + mark("B") + "vim" + mark("C") + "\u{1b}[?1049h")
#expect(parser.blocks[0].isInteractive)
}

@Test("non-133 OSC and CSI sequences are stripped from output")
func stripsOtherSequences() {
let parser = OSC133CommandParser()
var parser = OSC133CommandParser()
// OSC 0 (window title) + SGR color around the text.
let noise = "\u{1b}]0;my title\u{07}" + "\u{1b}[31mred\u{1b}[0m"
parser.consume(mark("A") + mark("B") + "x" + mark("C") + noise + "\n" + mark("D;0"))
Expand All @@ -106,28 +106,28 @@ struct OSC133CommandParserTests {

@Test("CRLF line endings are preserved, not blanked")
func crlfPreserved() {
let parser = OSC133CommandParser()
var parser = OSC133CommandParser()
parser.consume(mark("A") + mark("B") + "x" + mark("C") + "line1\r\nline2\r\n" + mark("D;0"))
#expect(parser.blocks[0].output == "line1\nline2\n")
}

@Test("a CR progress redraw still folds even with surrounding CRLF lines")
func crlfAndProgressMix() {
let parser = OSC133CommandParser()
var parser = OSC133CommandParser()
parser.consume(mark("A") + mark("B") + "x" + mark("C") + "start\r\n10%\r99%\r100%\r\ndone\r\n" + mark("D;0"))
#expect(parser.blocks[0].output == "start\n100%\ndone\n")
}

@Test("alt screen batched with other private modes is still detected")
func batchedAltScreen() {
let parser = OSC133CommandParser()
var parser = OSC133CommandParser()
parser.consume(mark("A") + mark("B") + "tmux" + mark("C") + "\u{1b}[?1049;2004h")
#expect(parser.blocks[0].isInteractive)
}

@Test("an unterminated OSC does not hang and the parser resyncs after it")
func unterminatedOSCResyncs() {
let parser = OSC133CommandParser()
var parser = OSC133CommandParser()
// A title OSC with no terminator, far longer than the escape cap.
let junk = "\u{1b}]0;" + String(repeating: "x", count: 9000)
parser.consume(mark("A") + mark("B") + "echo" + mark("C") + junk)
Expand All @@ -140,7 +140,7 @@ struct OSC133CommandParserTests {

@Test("large output is folded once and parses correctly")
func largeOutput() {
let parser = OSC133CommandParser()
var parser = OSC133CommandParser()
let big = (1...2000).map { "line \($0)" }.joined(separator: "\n")
parser.consume(mark("A") + mark("B") + "seq" + mark("C") + big + "\n" + mark("D;0"))
#expect(parser.blocks[0].output == big + "\n")
Expand All @@ -149,7 +149,7 @@ struct OSC133CommandParserTests {

@Test("a CRLF split across consume chunks is not blanked")
func crlfSplitAcrossChunks() {
let parser = OSC133CommandParser()
var parser = OSC133CommandParser()
parser.consume(mark("A") + mark("B") + "x" + mark("C") + "ab\r")
// mid-stream the open line looks cleared, but the bytes are retained
parser.consume("\ncd\r\n" + mark("D;0"))
Expand All @@ -158,7 +158,7 @@ struct OSC133CommandParserTests {

@Test("output fed one character at a time parses identically (incremental fold)")
func byteAtATimeStreaming() {
let parser = OSC133CommandParser()
var parser = OSC133CommandParser()
let full = mark("A") + mark("B") + "run" + mark("C")
+ "start\r\n10%\r99%\r100%\r\ndone\r\n" + mark("D;0")
for char in full {
Expand All @@ -172,7 +172,7 @@ struct OSC133CommandParserTests {

@Test("a bare prompt with no command yields an empty command string")
func bareCommand() {
let parser = OSC133CommandParser()
var parser = OSC133CommandParser()
parser.consume(mark("A") + mark("B") + mark("C") + mark("D;0"))
#expect(parser.blocks[0].command.isEmpty)
#expect(parser.blocks[0].output.isEmpty)
Expand Down
17 changes: 17 additions & 0 deletions Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -201790,6 +201790,23 @@
}
}
},
"mobile.chat.error.serviceUnavailable": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Agent chat transcript service is not configured"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "エージェントチャットのトランスクリプトサービスが設定されていません"
}
}
}
},
"mobile.chat.error.transcriptNotReadable": {
"extractionState": "manual",
"localizations": {
Expand Down
10 changes: 4 additions & 6 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -790,7 +790,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
/// Strongly-held observers for every active TabManager. Each observer owns
/// Combine subscriptions that publish workspace.updated to mobile clients.
private var mobileWorkspaceListObservers: [ObjectIdentifier: MobileWorkspaceListObserver] = [:]

private let agentChatTranscriptService = AgentChatTranscriptService()
/// The app's settings dependency container, handed over by `cmuxApp` via
/// `configure(...)` before any main window is created. AppKit builds the
/// main window's `NSHostingView` itself, so it injects this into the
Expand Down Expand Up @@ -1986,14 +1986,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
MobileHostService.shared.configure(auth: auth.coordinator)
DeviceRegistryClient.shared.configure(auth: auth.coordinator)
PresenceHeartbeatClient.shared.configure(auth: auth.coordinator)
TerminalController.shared.attachAuth(
coordinator: auth.coordinator,
browserSignIn: auth.browserSignIn
)
TerminalController.shared.attachAuth(coordinator: auth.coordinator, browserSignIn: auth.browserSignIn)
TerminalController.shared.agentChatTranscriptService = agentChatTranscriptService
auth.start()
ensureMobileWorkspaceListObserver(for: tabManager)
MobileTerminalRenderObserver.shared.start()
AgentChatTranscriptService.shared.start()
agentChatTranscriptService.start { TerminalController.shared.adoptDetectedAgentSessions(workspaceID: $0) }
installMobileHostSettingsObserver()
scheduleGhosttyCrashBreadcrumbIfNeeded(notificationStore: notificationStore)
disableSuddenTerminationIfNeeded()
Expand Down
3 changes: 1 addition & 2 deletions Sources/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2647,8 +2647,7 @@ struct ContentView: View {
})

view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .ghosttyDidSetTitle)) { notification in
guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID,
tabId == tabManager.selectedTabId else { return }
guard GhosttyTitleChange(notification: notification)?.tabId == tabManager.selectedTabId else { return }
scheduleTitlebarTextRefresh()
})
Comment on lines 2649 to 2652

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 The optional-chain comparison introduces a nil == nil case that the original code did not have. tabManager.selectedTabId is UUID?, so when the failable GhosttyTitleChange.init returns nil (malformed notification) and selectedTabId is also nil (no tab selected), the guard passes and scheduleTitlebarTextRefresh() is called incorrectly. The original code used a non-optional tabId: UUID on the left-hand side, making UUID == nil always false. Use a guard let to preserve the original semantics.

Suggested change
view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .ghosttyDidSetTitle)) { notification in
guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID,
tabId == tabManager.selectedTabId else { return }
guard GhosttyTitleChange(notification: notification)?.tabId == tabManager.selectedTabId else { return }
scheduleTitlebarTextRefresh()
})
view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .ghosttyDidSetTitle)) { notification in
guard let change = GhosttyTitleChange(notification: notification),
change.tabId == tabManager.selectedTabId else { return }
scheduleTitlebarTextRefresh()
})


Expand Down
7 changes: 2 additions & 5 deletions Sources/GhosttyTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3279,15 +3279,12 @@ class GhosttyApp {
.flatMap { String(cString: $0) } ?? ""
if let tabId = surfaceView.tabId,
let surfaceId = surfaceView.terminalSurface?.id {
let change = GhosttyTitleChange(tabId: tabId, surfaceId: surfaceId, title: title)
DispatchQueue.main.async {
NotificationCenter.default.post(
name: .ghosttyDidSetTitle,
object: surfaceView,
userInfo: [
GhosttyNotificationKey.tabId: tabId,
GhosttyNotificationKey.surfaceId: surfaceId,
GhosttyNotificationKey.title: title,
]
userInfo: change.userInfo
)
}
}
Expand Down
31 changes: 31 additions & 0 deletions Sources/GhosttyTitleChange.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import Foundation

/// Typed payload for `.ghosttyDidSetTitle` notifications.
struct GhosttyTitleChange: Equatable, Sendable {
let tabId: UUID
let surfaceId: UUID
let title: String

init(tabId: UUID, surfaceId: UUID, title: String) {
self.tabId = tabId
self.surfaceId = surfaceId
self.title = title
}

init?(notification: Notification) {
guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID,
let surfaceId = notification.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID,
let title = notification.userInfo?[GhosttyNotificationKey.title] as? String else {
return nil
}
self.init(tabId: tabId, surfaceId: surfaceId, title: title)
}

var userInfo: [String: Any] {
[
GhosttyNotificationKey.tabId: tabId,
GhosttyNotificationKey.surfaceId: surfaceId,
GhosttyNotificationKey.title: title,
]
}
}
28 changes: 28 additions & 0 deletions Sources/GhosttyTitleChangeSubscription.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import Foundation

/// Owns a `.ghosttyDidSetTitle` observer and delivers typed title changes.
final class GhosttyTitleChangeSubscription {
private let center: NotificationCenter
private let observer: NSObjectProtocol

init(
center: NotificationCenter = .default,
handler: @escaping @MainActor (GhosttyTitleChange) -> Void
) {
self.center = center
observer = center.addObserver(
forName: Notification.Name.ghosttyDidSetTitle,
object: nil,
queue: .main
) { notification in
guard let change = GhosttyTitleChange(notification: notification) else { return }
Task { @MainActor in
handler(change)
}
}
}

deinit {
center.removeObserver(observer)
}
}
Loading
Loading