diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index fa65d6dcf094..edf8598302c5 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -4,8 +4,8 @@ 34499 CLI/cmux.swift 17954 Sources/AppDelegate.swift 16427 Sources/ContentView.swift -14270 Sources/TerminalController.swift -13172 Sources/Workspace.swift +14271 Sources/TerminalController.swift +13242 Sources/Workspace.swift 12348 cmuxTests/AppDelegateShortcutRoutingTests.swift 12296 Sources/GhosttyTerminalView.swift 11669 Sources/Panels/BrowserPanel.swift @@ -24,8 +24,8 @@ 4483 Sources/cmuxApp.swift 4482 Sources/Panels/FilePreviewPanel.swift 4367 cmuxTests/BrowserPanelTests.swift -4283 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift 4121 Sources/BrowserWindowPortal.swift +3964 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift 3934 Sources/Feed/FeedPanelView.swift 3926 cmuxTests/TabManagerUnitTests.swift 3896 cmuxTests/WindowAndDragTests.swift @@ -41,11 +41,11 @@ 2524 cmuxTests/CommandPaletteSearchEngineTests.swift 2404 Sources/Mobile/MobileHostService.swift 2328 cmuxTests/CJKIMEInputTests.swift +2318 Sources/SessionPersistence.swift 2242 Sources/TerminalNotificationStore.swift 2229 Sources/TerminalWindowPortal.swift 2126 cmuxTests/CmuxConfigTests.swift 2085 cmuxTests/ShortcutAndCommandPaletteTests.swift -2078 Sources/SessionPersistence.swift 2011 Sources/KeyboardShortcutSettingsFileStore.swift 1944 Sources/RestorableAgentSession.swift 1900 cmuxTests/NotificationAndMenuBarTests.swift diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index 4557c220d663..01eb11660266 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -1398,6 +1398,15 @@ struct SessionTerminalPanelSnapshot: Codable, Sendable { /// Whether the agent process was actively running when this snapshot was captured. /// Nil means unknown (legacy snapshots); treated as true for backwards compatibility. var wasAgentRunning: Bool? + /// The collapsed, ≤48-character match prefix of the workspace's most recent + /// submitted prompt, used to re-inject an OSC 133 semantic prompt mark into + /// replayed scrollback on restore (Ghostty's VT export drops OSC 133). Only + /// the bounded match key is stored — and only when the saved scrollback + /// already contains the matching prompt row — so no prompt text beyond what + /// the scrollback already carries is persisted. Nil for terminals that did + /// not own the submitted prompt and for legacy snapshots. See + /// https://github.com/manaflow-ai/cmux/issues/6691. + var lastPromptMarkKey: String? init( workingDirectory: String? = nil, @@ -1409,7 +1418,8 @@ struct SessionTerminalPanelSnapshot: Codable, Sendable { textBoxDraft: SessionTextBoxInputDraftSnapshot? = nil, isRemoteTerminal: Bool? = nil, remotePTYSessionID: String? = nil, - wasAgentRunning: Bool? = nil + wasAgentRunning: Bool? = nil, + lastPromptMarkKey: String? = nil ) { self.workingDirectory = workingDirectory self.scrollback = scrollback @@ -1421,6 +1431,7 @@ struct SessionTerminalPanelSnapshot: Codable, Sendable { self.isRemoteTerminal = isRemoteTerminal self.remotePTYSessionID = remotePTYSessionID self.wasAgentRunning = wasAgentRunning + self.lastPromptMarkKey = lastPromptMarkKey } } @@ -1924,13 +1935,32 @@ enum SessionScrollbackReplayStore { private static let ansiEscape = "\u{001B}" private static let ansiReset = "\u{001B}[0m" + /// OSC 133 ; A semantic prompt-start marker (matching cmux's shell + /// integration emission `\e]133;A;cl=line\a`) that we re-inject into + /// replayed scrollback so a rebuilt screen regains the per-row + /// semantic-prompt metadata that Ghostty's `write_screen_file:copy,vt` + /// export drops. See https://github.com/manaflow-ai/cmux/issues/6691. + static let semanticPromptStartMark = "\u{001B}]133;A;cl=line\u{0007}" + + /// Characters a prompt row may lead with before the message text — shell and + /// agent prompt sigils only. Deliberately EXCLUDES Markdown list/heading + /// markers (`-`, `*`, `+`, `#`) and blockquote-only prose, so an agent plan + /// bullet ("- refactor the login flow") restating the user's request does not + /// get mistaken for the prompt row. + private static let promptSigilCharacters: Set = [">", "❯", "›", "»", "▶", "│", "┃", "$", "%"] + static func replayEnvironment( for scrollback: String?, + lastUserMessage: String? = nil, tempDirectory: URL = FileManager.default.temporaryDirectory ) -> [String: String] { guard let replayText = normalizedScrollback(scrollback) else { return [:] } + // Ghostty's VT export drops OSC 133 semantic-prompt marks, so re-inject + // one before the restored last user message to keep prompt navigation + // working after a rebuild (#6691). + let markedText = reinjectingLastPromptMark(into: replayText, lastUserMessage: lastUserMessage) guard let replayFileURL = writeReplayFile( - contents: replayText, + contents: markedText, tempDirectory: tempDirectory ) else { return [:] @@ -2056,6 +2086,216 @@ enum SessionScrollbackReplayStore { } } + /// Re-injects an OSC 133 ; A semantic prompt-start marker before the most + /// recent row in the replayed scrollback that *begins* with `lastUserMessage` + /// (modulo a short prompt sigil), so the rebuilt screen regains a + /// semantic-prompt row at the user's most recent prompt. + /// + /// Ghostty's `write_screen_file:copy,vt` export (used to capture session + /// scrollback) drops OSC 133 markers, and agents such as Claude Code emit + /// the prompt-start mark only once at process startup — so after an + /// auto-resume rebuild the marker never returns and prompt-navigation + /// affordances (jump-to-prompt, click-to-move) stay broken. See + /// https://github.com/manaflow-ai/cmux/issues/6691. + static func reinjectingLastPromptMark(into scrollback: String, lastUserMessage: String?) -> String { + var lines = scrollback.components(separatedBy: "\n") + guard let targetIndex = promptRowIndex(in: lines, lastUserMessage: lastUserMessage) else { return scrollback } + + // Don't double-mark a row that already carries a prompt-start marker + // (e.g. a shell whose live OSC 133 survived for some other reason). + guard !lines[targetIndex].contains("\u{001B}]133;A") else { return scrollback } + + lines[targetIndex] = semanticPromptStartMark + lines[targetIndex] + return lines.joined(separator: "\n") + } + + /// The bounded prompt match key to persist for `lastUserMessage` — the exact + /// collapsed, ≤48-character needle used for re-injection — but ONLY when the + /// saved scrollback already contains the matching prompt row; otherwise nil. + /// + /// Used at capture so the workspace-scoped last prompt is persisted only into + /// the snapshot of the terminal whose scrollback actually carries it (never an + /// unrelated panel's snapshot), and so we never write any prompt text the + /// saved scrollback does not already contain — the persisted key is exactly + /// the substring proven present, not the full (up to 240-char) message. + static func persistablePromptMatchKey(forScrollback scrollback: String?, lastUserMessage: String?) -> String? { + guard let scrollback, let needle = promptMatchNeedle(lastUserMessage) else { return nil } + guard promptRowIndex(in: scrollback.components(separatedBy: "\n"), lastUserMessage: lastUserMessage) != nil else { + return nil + } + return needle + } + + /// Index of the row to mark: the UNIQUE row whose text, after a known prompt + /// sigil, *begins* with `lastUserMessage`, or nil when there is no match or + /// the match is ambiguous. + /// + /// Two requirements, both needed for correctness: + /// 1. Require a known leading prompt sigil (`>`, `❯`, `│`, `$`, …) followed by + /// the message. Anchoring to a real sigil — not an unconstrained substring + /// scan, not arbitrary punctuation, and NOT a bare line — excludes agent + /// output that echoes the user's words mid-sentence ("I'll refactor the + /// login flow"), Markdown list/heading echoes ("- refactor…", "# Refactor…"), + /// and bare output lines that merely open with the same words ("refactor the + /// login flow is done…"). A plain `>` is also Markdown blockquote syntax, + /// so angle prompts require the styled agent-prompt prefix shape; unstyled + /// single blockquotes fail closed when the real prompt row is absent. + /// 2. Mark ONLY when exactly one sigil-prefixed row matches. If two or more + /// match — because the user repeated the prompt or because two prompt-shaped + /// rows carry the same request — we cannot reliably tell the real prompt + /// from the echo/duplicate, so we no-op rather than risk marking + /// the wrong row (agent output or a stale turn). Best-effort and safe: when + /// it acts there is a single unambiguous candidate. + /// The match is whitespace-stripped (robust to soft and hard wraps) and may + /// continue into following rows for wrapped prompts. + private static func promptRowIndex(in lines: [String], lastUserMessage: String?) -> Int? { + guard let needle = promptMatchNeedle(lastUserMessage) else { return nil } + let needleChars = Array(needle.filter { !$0.isWhitespace }) + guard needleChars.count >= 3 else { return nil } + + let compactRows: [[Character]] = lines.map { Array(visiblePlainText(of: $0).filter { !$0.isWhitespace }) } + var match: Int? + for index in compactRows.indices where !compactRows[index].isEmpty { + // Leading run of up to 4 known prompt-sigil characters. + var sigil = 0 + let row = compactRows[index] + while sigil < row.count, sigil < 4, promptSigilCharacters.contains(row[sigil]) { + sigil += 1 + } + // Require at least one prompt sigil; never match a bare line. + guard sigil >= 1 else { continue } + let rowMatches = (1...sigil).contains { offset in + guard promptSigilOffsetCanStartPrompt(row: row, rawLine: lines[index], offset: offset) else { + return false + } + compactPrefixMatches(needleChars, rows: compactRows, startRow: index, startOffset: offset) + } + guard rowMatches else { continue } + if match != nil { return nil } // ambiguous (repeat or echo) → don't guess + match = index + } + return match + } + + private static func promptSigilOffsetCanStartPrompt(row: [Character], rawLine: String, offset: Int) -> Bool { + let sigils = row.prefix(offset) + guard sigils.contains(">") else { return true } + guard sigils.allSatisfy({ $0 == ">" }) else { return true } + return rawLineHasStyledAnglePromptSigil(rawLine) + } + + private static func rawLineHasStyledAnglePromptSigil(_ line: String) -> Bool { + guard line.contains("\u{001B}"), + let promptIndex = line.firstIndex(of: ">") else { + return false + } + guard line[.. Bool { + var needleIndex = 0 + var rowIndex = startRow + var charIndex = startOffset + while needleIndex < needle.count { + if rowIndex >= rows.count { return false } + if charIndex >= rows[rowIndex].count { + rowIndex += 1 + charIndex = 0 + continue + } + if rows[rowIndex][charIndex] != needle[needleIndex] { return false } + needleIndex += 1 + charIndex += 1 + } + return true + } + + /// Distinctive leading slice of the last user message used to anchor it to a + /// prompt row in captured scrollback. Whitespace (including newlines) is + /// collapsed here and stripped entirely by the caller, so a long or multiline + /// prompt that wrapped across several captured rows still matches. Returns nil + /// for empty/too-short messages so re-injection no-ops. + private static func promptMatchNeedle(_ message: String?) -> String? { + guard let message else { return nil } + let collapsed = message + .split(whereSeparator: { $0.isWhitespace }) + .joined(separator: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + guard collapsed.count >= 3 else { return nil } + return String(collapsed.prefix(48)) + } + + /// The visible text of a captured scrollback row with ANSI/OSC escape + /// sequences removed and whitespace collapsed, so message matching is + /// robust to the SGR colors and OSC markers Ghostty interleaves through the + /// exported text. Used only to compute a match key, never as replay output. + private static func visiblePlainText(of line: String) -> String { + strippingEscapeSequences(line) + .split(whereSeparator: { $0.isWhitespace }) + .joined(separator: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Removes CSI (`ESC [ … final`), OSC (`ESC ] … BEL`/`ST`), and two-byte + /// escape sequences from a single line. Best-effort and used only for + /// match-key computation, so unterminated sequences simply drop the rest. + private static func strippingEscapeSequences(_ line: String) -> String { + guard line.contains("\u{001B}") else { return line } + let chars = Array(line) + let count = chars.count + var output = String() + output.reserveCapacity(count) + var index = 0 + while index < count { + let char = chars[index] + guard char == "\u{001B}", index + 1 < count else { + if char != "\u{001B}" { output.append(char) } + index += 1 + continue + } + let introducer = chars[index + 1] + switch introducer { + case "[": + // CSI: consume params/intermediates through a final byte 0x40–0x7E. + index += 2 + while index < count { + let scalar = chars[index].unicodeScalars.first?.value ?? 0 + index += 1 + if (0x40...0x7E).contains(scalar) { break } + } + case "]", "P", "X", "^", "_": + // String sequences — OSC, DCS, SOS, PM, APC (e.g. Kitty graphics + // payloads) — terminate on BEL or ST (`ESC \`). Consume the whole + // sequence so its payload can't pollute the match key. + index += 2 + while index < count { + if chars[index] == "\u{0007}" { index += 1; break } + if chars[index] == "\u{001B}" { + if index + 1 < count, chars[index + 1] == "\\" { index += 2 } else { index += 1 } + break + } + index += 1 + } + default: + // Two-byte escape (charset designation, `ESC =`, …): drop both. + index += 2 + } + } + return output + } + private static func writeReplayFile(contents: String, tempDirectory: URL) -> URL? { guard let data = contents.data(using: .utf8) else { return nil } let directory = tempDirectory.appendingPathComponent(directoryName, isDirectory: true) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index d732e31160f5..0113d1a842d4 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -5143,6 +5143,7 @@ class TerminalController { guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: workspaceId) else { return } _ = tabManager.handlePromptSubmit( workspaceId: workspaceId, + surfaceId: v2UUIDAny(event.surfaceId), message: event.submittedPromptMessage, iMessageModeEnabled: iMessageModeEnabled ) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 4a2dbb9781e1..89c92b81825c 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -150,6 +150,7 @@ extension Workspace { defer { suppressClosedPanelHistory = previousSuppressClosedPanelHistory } restoredTerminalScrollbackByPanelId.removeAll(keepingCapacity: false) + restoredPromptMarkKeysByPanelId.removeAll(keepingCapacity: false) #if DEBUG debugSessionSnapshotScrollbackFallbackPanelIds.removeAll(keepingCapacity: false) debugSessionSnapshotSyntheticScrollbackByPanelId.removeAll(keepingCapacity: false) @@ -525,7 +526,12 @@ extension Workspace { textBoxDraft: terminalPanel.sessionTextBoxDraftSnapshot(), isRemoteTerminal: activeRemoteTerminalSurfaceIds.contains(panelId), remotePTYSessionID: remotePTYSessionIDForSnapshot(panelId: panelId), - wasAgentRunning: agentWasRunning + wasAgentRunning: agentWasRunning, + lastPromptMarkKey: sessionPromptMarkKeyForSnapshot( + panelId: panelId, + includeScrollback: includeScrollback, + resolvedScrollback: resolvedScrollback + ) ) browserSnapshot = nil markdownSnapshot = nil @@ -1007,6 +1013,41 @@ extension Workspace { return resolved } + private func sessionPromptMarkKeyForSnapshot( + panelId: UUID, + includeScrollback: Bool, + resolvedScrollback: String? + ) -> String? { + guard includeScrollback else { return nil } + + let key: String? + if latestSubmittedPanelId == panelId { + // Persist only the bounded prompt match KEY, and only into the + // snapshot of the terminal that owned the submitted prompt and whose + // saved scrollback actually contains that prompt row. The owner gate + // keeps a workspace-scoped prompt out of unrelated panels that happen + // to contain the same text. + key = SessionScrollbackReplayStore.persistablePromptMatchKey( + forScrollback: resolvedScrollback, + lastUserMessage: latestSubmittedMessage + ) + } else if let restoredPromptMarkKey = restoredPromptMarkKeysByPanelId[panelId] { + key = SessionScrollbackReplayStore.persistablePromptMatchKey( + forScrollback: resolvedScrollback, + lastUserMessage: restoredPromptMarkKey + ) + } else { + key = nil + } + + if let key { + restoredPromptMarkKeysByPanelId[panelId] = key + } else { + restoredPromptMarkKeysByPanelId.removeValue(forKey: panelId) + } + return key + } + #if DEBUG func debugSeedSessionSnapshotScrollback(charactersPerTerminal: Int) -> (terminals: Int, characters: Int) { for panelId in debugSessionSnapshotScrollbackFallbackPanelIds { @@ -1372,7 +1413,11 @@ extension Workspace { #endif let shouldReplayLocalScrollback = restoredRemotePTYAttachCommand == nil && shouldReplayScrollback let restoredScrollback = shouldReplayLocalScrollback ? snapshot.terminal?.scrollback : nil - let replayEnvironment = SessionScrollbackReplayStore.replayEnvironment(for: restoredScrollback) + let restoredPromptMarkKey = shouldReplayLocalScrollback ? snapshot.terminal?.lastPromptMarkKey : nil + let replayEnvironment = SessionScrollbackReplayStore.replayEnvironment( + for: restoredScrollback, + lastUserMessage: restoredPromptMarkKey + ) // Reuse the persisted surface id so the restored terminal keeps // the same identity (the panel/surface id IS the ghostty surface // id), which keeps agent-session terminal bindings valid across @@ -1472,8 +1517,19 @@ extension Workspace { let fallbackScrollback = SessionPersistencePolicy.truncatedScrollback(restoredScrollback) if let fallbackScrollback { restoredTerminalScrollbackByPanelId[terminalPanel.id] = fallbackScrollback + if let restoredPromptMarkKey = restoredPromptMarkKey.flatMap({ + SessionScrollbackReplayStore.persistablePromptMatchKey( + forScrollback: fallbackScrollback, + lastUserMessage: $0 + ) + }) { + restoredPromptMarkKeysByPanelId[terminalPanel.id] = restoredPromptMarkKey + } else { + restoredPromptMarkKeysByPanelId.removeValue(forKey: terminalPanel.id) + } } else { restoredTerminalScrollbackByPanelId.removeValue(forKey: terminalPanel.id) + restoredPromptMarkKeysByPanelId.removeValue(forKey: terminalPanel.id) } if let restorableAgent { restoredAgentSnapshotsByPanelId[terminalPanel.id] = restorableAgent @@ -2537,6 +2593,7 @@ final class Workspace: Identifiable, ObservableObject { @Published private(set) var latestConversationMessage: String? @Published private(set) var latestSubmittedMessage: String? @Published private(set) var latestSubmittedAt: Date? + private(set) var latestSubmittedPanelId: UUID? var logEntries: [SidebarLogEntry] { get { sidebarMetadata.logEntries } set { sidebarMetadata.logEntries = newValue } @@ -2634,6 +2691,7 @@ final class Workspace: Identifiable, ObservableObject { /// Agent runtime maps that affect sidebar status visibility. let sidebarAgentRuntimeObservation = WorkspaceSidebarAgentRuntimeObservationModel() var restoredTerminalScrollbackByPanelId: [UUID: String] = [:] + private var restoredPromptMarkKeysByPanelId: [UUID: String] = [:] #if DEBUG var debugSessionSnapshotScrollbackFallbackPanelIds: Set = [] var debugSessionSnapshotSyntheticScrollbackByPanelId: [UUID: String] = [:] @@ -5120,6 +5178,7 @@ final class Workspace: Identifiable, ObservableObject { latestConversationMessage = nil latestSubmittedMessage = nil latestSubmittedAt = nil + latestSubmittedPanelId = nil logEntries.removeAll() progress = nil gitBranch = nil @@ -5279,6 +5338,9 @@ final class Workspace: Identifiable, ObservableObject { restoredAgentResumeStatesByPanelId = restoredAgentResumeStatesByPanelId.filter { validSurfaceIds.contains($0.key) } + restoredPromptMarkKeysByPanelId = restoredPromptMarkKeysByPanelId.filter { + validSurfaceIds.contains($0.key) + } restoredResumeSessionWorkingDirectoriesByPanelId = restoredResumeSessionWorkingDirectoriesByPanelId.filter { validSurfaceIds.contains($0.key) } @@ -5540,14 +5602,21 @@ final class Workspace: Identifiable, ObservableObject { } @discardableResult - func recordSubmittedMessage(_ message: String?) -> Bool { + func recordSubmittedMessage(_ message: String?, panelId: UUID? = nil) -> Bool { guard let preview = Self.conversationMessagePreview(from: message) else { return false } _ = recordConversationMessage(preview) latestSubmittedMessage = preview latestSubmittedAt = Date() + latestSubmittedPanelId = submittedPromptOwnerPanelId(panelId) return true } + private func submittedPromptOwnerPanelId(_ panelId: UUID?) -> UUID? { + let candidate = panelId ?? focusedPanelId + guard let candidate, panels[candidate] is TerminalPanel else { return nil } + return candidate + } + var isRemoteWorkspace: Bool { remoteConfiguration != nil } @@ -8906,6 +8975,7 @@ final class Workspace: Identifiable, ObservableObject { recomputeListeningPorts() clearRemoteConfigurationIfWorkspaceBecameLocal() restoredTerminalScrollbackByPanelId.removeAll(keepingCapacity: false) + restoredPromptMarkKeysByPanelId.removeAll(keepingCapacity: false) #if DEBUG debugSessionSnapshotScrollbackFallbackPanelIds.removeAll(keepingCapacity: false) debugSessionSnapshotSyntheticScrollbackByPanelId.removeAll(keepingCapacity: false) diff --git a/Sources/WorkspacePromptSubmit.swift b/Sources/WorkspacePromptSubmit.swift index f1d575c2b186..5508c26894e8 100644 --- a/Sources/WorkspacePromptSubmit.swift +++ b/Sources/WorkspacePromptSubmit.swift @@ -145,11 +145,13 @@ extension TabManager { @discardableResult func handlePromptSubmit( workspaceId: UUID, + surfaceId: UUID? = nil, message: String?, iMessageModeEnabled: Bool = IMessageModeSettings.isEnabled() ) -> (messageRecorded: Bool, reordered: Bool, index: Int)? { handleConversationMessage( workspaceId: workspaceId, + surfaceId: surfaceId, message: message, iMessageModeEnabled: iMessageModeEnabled, kind: .promptSubmission, @@ -165,6 +167,7 @@ extension TabManager { ) -> (messageRecorded: Bool, reordered: Bool, index: Int)? { handleConversationMessage( workspaceId: workspaceId, + surfaceId: nil, message: message, iMessageModeEnabled: iMessageModeEnabled, kind: .assistantFinal, @@ -174,6 +177,7 @@ extension TabManager { private func handleConversationMessage( workspaceId: UUID, + surfaceId: UUID?, message: String?, iMessageModeEnabled: Bool, kind: ConversationMessageKind, @@ -188,7 +192,7 @@ extension TabManager { let messageRecorded: Bool switch kind { case .promptSubmission: - messageRecorded = workspace.recordSubmittedMessage(message) + messageRecorded = workspace.recordSubmittedMessage(message, panelId: surfaceId) if messageRecorded { CmuxEventBus.shared.publishWorkspacePromptSubmitted( workspaceId: workspaceId, diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 1a0f2bf013e5..f6eac46835d8 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -927,6 +927,7 @@ C65930020000000000000002 /* SessionPersistencePolicy+CrashStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C65930020000000000000001 /* SessionPersistencePolicy+CrashStorage.swift */; }; F6572002A1B2C3D4E5F60718 /* SessionPersistenceResumeBindingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6572003A1B2C3D4E5F60718 /* SessionPersistenceResumeBindingTests.swift */; }; F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */; }; + 6691A0006691A0006691A000 /* SessionPromptMarkReplayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6691A0016691A0016691A001 /* SessionPromptMarkReplayTests.swift */; }; E30780000000000000000014 /* SessionRemoteWorkspaceSnapshot+Restore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E30780000000000000000013 /* SessionRemoteWorkspaceSnapshot+Restore.swift */; }; A5001670 /* SessionRestoredTerminalCommandStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001671 /* SessionRestoredTerminalCommandStore.swift */; }; A50016B1A1B2C3D4E5F60718 /* SessionSnapshotDebugBenchmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50016B0A1B2C3D4E5F60718 /* SessionSnapshotDebugBenchmark.swift */; }; @@ -2234,6 +2235,7 @@ C65930020000000000000001 /* SessionPersistencePolicy+CrashStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionPersistencePolicy+CrashStorage.swift"; sourceTree = ""; }; F6572003A1B2C3D4E5F60718 /* SessionPersistenceResumeBindingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceResumeBindingTests.swift; sourceTree = ""; }; F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceTests.swift; sourceTree = ""; }; + 6691A0016691A0016691A001 /* SessionPromptMarkReplayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPromptMarkReplayTests.swift; sourceTree = ""; }; E30780000000000000000013 /* SessionRemoteWorkspaceSnapshot+Restore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionRemoteWorkspaceSnapshot+Restore.swift"; sourceTree = ""; }; A5001671 /* SessionRestoredTerminalCommandStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionRestoredTerminalCommandStore.swift; sourceTree = ""; }; A50016B0A1B2C3D4E5F60718 /* SessionSnapshotDebugBenchmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/SessionSnapshotDebugBenchmark.swift; sourceTree = ""; }; @@ -3724,6 +3726,7 @@ F6572003A1B2C3D4E5F60718 /* SessionPersistenceResumeBindingTests.swift */, F6572013A1B2C3D4E5F60718 /* SurfaceResumeBindingCodexUpdateCheckTests.swift */, F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */, + 6691A0016691A0016691A001 /* SessionPromptMarkReplayTests.swift */, B35750000000000000000009 /* PiVaultAgentPersistenceTests.swift */, CDFE000000000000000000C2 /* AgentChatProseStreamerTests.swift */, C7A51F000000000000000001 /* AgentChatSessionRegistryClaudeObservationTests.swift */, @@ -5603,6 +5606,7 @@ 8A3392FE64E0605D942213D1 /* SessionIndexViewTests.swift in Sources */, F6572002A1B2C3D4E5F60718 /* SessionPersistenceResumeBindingTests.swift in Sources */, F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */, + 6691A0006691A0006691A000 /* SessionPromptMarkReplayTests.swift in Sources */, 583A675AA1224E8D82A44883 /* SetAutoTitleSocketTests.swift in Sources */, A50019B2 /* SettingsSearchIndexTests.swift in Sources */, D36090010000000000000003 /* SettingsWindowPresenterTests.swift in Sources */, diff --git a/cmuxTests/SessionPromptMarkReplayTests.swift b/cmuxTests/SessionPromptMarkReplayTests.swift new file mode 100644 index 000000000000..8f8604abe53b --- /dev/null +++ b/cmuxTests/SessionPromptMarkReplayTests.swift @@ -0,0 +1,341 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@Suite("Session prompt mark replay") +struct SessionPromptMarkReplayTests { + // Regression for https://github.com/manaflow-ai/cmux/issues/6691. + // + // Ghostty's `write_screen_file:copy,vt` export drops OSC 133 semantic-prompt + // markers, so replayed scrollback loses the prompt row metadata that drives + // jump-to-prompt and click-to-move. + @Test func reinjectsSemanticPromptMarkForLastUserMessage() { + let esc = "\u{001B}" + let reset = "\(esc)[0m" + let promptPrefix = "\(esc)[2m> \(reset)" + let message = "refactor the login flow" + let scrollback = [ + "\(esc)[1mWelcome to the session\(reset)", + "\(promptPrefix)\(message)", + "\(esc)[32m● Done editing files\(reset)", + ].joined(separator: "\n") + + let marked = SessionScrollbackReplayStore.reinjectingLastPromptMark( + into: scrollback, + lastUserMessage: message + ) + + #expect(marked.contains("\(esc)]133;A")) + #expect(marked.contains("\(SessionScrollbackReplayStore.semanticPromptStartMark)\(promptPrefix)\(message)")) + #expect(marked.contains("Welcome to the session")) + #expect(marked.contains("● Done editing files")) + } + + @Test func leavesScrollbackUntouchedWithoutLastUserMessage() { + let scrollback = "$ ls\nfile-a\nfile-b\n" + #expect( + SessionScrollbackReplayStore.reinjectingLastPromptMark(into: scrollback, lastUserMessage: nil) + == scrollback + ) + #expect( + SessionScrollbackReplayStore.reinjectingLastPromptMark(into: scrollback, lastUserMessage: " ") + == scrollback + ) + } + + @Test func marksStyledPromptWithPlainBlockquoteEcho() { + let esc = "\u{001B}" + let reset = "\(esc)[0m" + let message = "fix the flaky test" + let promptRow = "\(esc)[2m> \(reset)\(esc)[1mfix\(reset) the \(esc)[4mflaky\(reset) test" + let lines = [ + promptRow, + "\(esc)[33m⏺\(reset) Here's the plan:", + "> fix the flaky test", + "\(esc)[32m● done\(reset)", + ] + let scrollback = lines.joined(separator: "\n") + + let marked = SessionScrollbackReplayStore.reinjectingLastPromptMark( + into: scrollback, + lastUserMessage: message + ) + + #expect(marked.contains("\(SessionScrollbackReplayStore.semanticPromptStartMark)\(promptRow)")) + #expect(marked != scrollback) + } + + @Test func doesNotMarkSinglePlainBlockquoteWithoutPromptRow() { + let esc = "\u{001B}" + let message = "fix the flaky test" + let scrollback = [ + "\(esc)[33m⏺\(esc)[0m Here's the plan:", + "> fix the flaky test", + "\(esc)[32m● done\(esc)[0m", + ].joined(separator: "\n") + + let marked = SessionScrollbackReplayStore.reinjectingLastPromptMark( + into: scrollback, + lastUserMessage: message + ) + + #expect(marked == scrollback) + #expect(!marked.contains("\(esc)]133;A")) + #expect( + SessionScrollbackReplayStore.persistablePromptMatchKey(forScrollback: scrollback, lastUserMessage: message) + == nil + ) + } + + @Test func doesNotMarkColoredBlockquoteWithoutPromptRow() { + let esc = "\u{001B}" + let message = "deploy the release" + let scrollback = [ + "\(esc)[33m> deploy the release\(esc)[0m", + "\(esc)[32m● done\(esc)[0m", + ].joined(separator: "\n") + + let marked = SessionScrollbackReplayStore.reinjectingLastPromptMark( + into: scrollback, + lastUserMessage: message + ) + + #expect(marked == scrollback) + #expect(!marked.contains("\(esc)]133;A")) + #expect( + SessionScrollbackReplayStore.persistablePromptMatchKey(forScrollback: scrollback, lastUserMessage: message) + == nil + ) + } + + @Test func marksSinglePromptThroughInterleavedSGR() { + let esc = "\u{001B}" + let reset = "\(esc)[0m" + let message = "fix the flaky test" + let lines = [ + "\(esc)[1mready\(reset)", + "\(esc)[2m> \(reset)\(esc)[1mfix\(reset) the \(esc)[4mflaky\(reset) test", + "\(esc)[32m● done\(reset)", + ] + let scrollback = lines.joined(separator: "\n") + + let marked = SessionScrollbackReplayStore.reinjectingLastPromptMark( + into: scrollback, + lastUserMessage: message + ) + let markedLines = marked.components(separatedBy: "\n") + + #expect(markedLines[1].hasPrefix(SessionScrollbackReplayStore.semanticPromptStartMark)) + #expect(marked.components(separatedBy: "\(esc)]133;A").count - 1 == 1) + } + + @Test func marksFirstRowOfWrappedPrompt() { + let esc = "\u{001B}" + let reset = "\(esc)[0m" + let message = "please refactor the authentication flow and add regression tests" + let firstRow = "\(esc)[2m> \(reset)please refactor the authentication flow and" + let secondRow = "add regression tests" + let lines = [ + "\(esc)[1magent ready\(reset)", + firstRow, + secondRow, + "\(esc)[32m● working\(reset)", + ] + let scrollback = lines.joined(separator: "\n") + + let marked = SessionScrollbackReplayStore.reinjectingLastPromptMark( + into: scrollback, + lastUserMessage: message + ) + let markedLines = marked.components(separatedBy: "\n") + + #expect(markedLines.count == 4) + #expect(markedLines[1].hasPrefix(SessionScrollbackReplayStore.semanticPromptStartMark)) + #expect(!markedLines[2].contains("\(esc)]133;A")) + #expect(marked.components(separatedBy: "\(esc)]133;A").count - 1 == 1) + } + + @Test func doesNotMarkAgentOutputEchoingTheMessage() { + let esc = "\u{001B}" + let reset = "\(esc)[0m" + let message = "refactor the login flow" + let lines = [ + "\(esc)[2m> \(reset)refactor the login flow", + "\(esc)[33m⏺ I'll refactor the login flow now…\(reset)", + "\(esc)[32m● Done\(reset)", + ] + let scrollback = lines.joined(separator: "\n") + + let marked = SessionScrollbackReplayStore.reinjectingLastPromptMark( + into: scrollback, + lastUserMessage: message + ) + let markedLines = marked.components(separatedBy: "\n") + + #expect(markedLines[0].hasPrefix(SessionScrollbackReplayStore.semanticPromptStartMark)) + #expect(!markedLines[1].contains("\(esc)]133;A")) + #expect(marked.components(separatedBy: "\(esc)]133;A").count - 1 == 1) + } + + @Test func prefersSigilPromptOverBareAgentEcho() { + let esc = "\u{001B}" + let reset = "\(esc)[0m" + let message = "refactor the login flow" + let lines = [ + "\(esc)[2m> \(reset)refactor the login flow", + "\(esc)[33m⏺\(reset) working…", + "refactor the login flow is done; tests pass", + ] + let scrollback = lines.joined(separator: "\n") + + let marked = SessionScrollbackReplayStore.reinjectingLastPromptMark( + into: scrollback, + lastUserMessage: message + ) + let markedLines = marked.components(separatedBy: "\n") + + #expect(markedLines[0].hasPrefix(SessionScrollbackReplayStore.semanticPromptStartMark)) + #expect(!markedLines[2].contains("\(esc)]133;A")) + #expect(marked.components(separatedBy: "\(esc)]133;A").count - 1 == 1) + } + + @Test func doesNotMarkMarkdownBulletEchoingTheMessage() { + let esc = "\u{001B}" + let reset = "\(esc)[0m" + let message = "refactor the login flow" + let lines = [ + "\(esc)[2m> \(reset)refactor the login flow", + "\(esc)[1mPlan:\(reset)", + "- refactor the login flow", + "# Refactor the login flow", + "- add regression tests", + ] + let scrollback = lines.joined(separator: "\n") + + let marked = SessionScrollbackReplayStore.reinjectingLastPromptMark( + into: scrollback, + lastUserMessage: message + ) + let markedLines = marked.components(separatedBy: "\n") + + #expect(markedLines[0].hasPrefix(SessionScrollbackReplayStore.semanticPromptStartMark)) + #expect(!markedLines[2].contains("\(esc)]133;A")) + #expect(!markedLines[3].contains("\(esc)]133;A")) + #expect(marked.components(separatedBy: "\(esc)]133;A").count - 1 == 1) + } + + @Test func marksMultilinePromptAcrossRows() { + let esc = "\u{001B}" + let reset = "\(esc)[0m" + let message = "first line\nsecond line of the prompt" + let scrollback = [ + "\(esc)[2m> \(reset)first line", + "second line of the prompt", + "\(esc)[32m● ok\(esc)[0m", + ].joined(separator: "\n") + + let marked = SessionScrollbackReplayStore.reinjectingLastPromptMark( + into: scrollback, + lastUserMessage: message + ) + let markedLines = marked.components(separatedBy: "\n") + #expect(markedLines[0].hasPrefix(SessionScrollbackReplayStore.semanticPromptStartMark)) + } + + @Test func replayEnvironmentReinjectsPromptMarkIntoReplayFile() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-scrollback-replay-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let esc = "\u{001B}" + let reset = "\(esc)[0m" + let message = "add a dark mode toggle" + let promptPrefix = "\(esc)[2m> \(reset)" + let scrollback = "\(esc)[1mclaude\(reset)\n\(promptPrefix)\(message)\n\(esc)[32m● Done\(reset)\n" + + let environment = SessionScrollbackReplayStore.replayEnvironment( + for: scrollback, + lastUserMessage: message, + tempDirectory: tempDir + ) + let path = try #require(environment[SessionScrollbackReplayStore.environmentKey]) + let contents = try String(contentsOfFile: path, encoding: .utf8) + + #expect(contents.contains("\(SessionScrollbackReplayStore.semanticPromptStartMark)\(promptPrefix)\(message)")) + #expect(contents.contains("claude")) + #expect(contents.contains("● Done")) + } + + @Test func terminalSnapshotPromptMarkKeyRoundTripsIntoReplayInjection() throws { + let esc = "\u{001B}" + let message = "wire up the settings panel" + let promptPrefix = "\(esc)[2m> \(esc)[0m" + let scrollback = "\(esc)[1mclaude\(esc)[0m\n\(promptPrefix)\(message)\n\(esc)[32m● Done\(esc)[0m\n" + let key = try #require(SessionScrollbackReplayStore.persistablePromptMatchKey( + forScrollback: scrollback, + lastUserMessage: message + )) + let snapshot = SessionTerminalPanelSnapshot(scrollback: scrollback, lastPromptMarkKey: key) + + let data = try JSONEncoder().encode(snapshot) + let decoded = try JSONDecoder().decode(SessionTerminalPanelSnapshot.self, from: data) + #expect(decoded.lastPromptMarkKey == key) + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-scrollback-replay-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let environment = SessionScrollbackReplayStore.replayEnvironment( + for: decoded.scrollback, + lastUserMessage: decoded.lastPromptMarkKey, + tempDirectory: tempDir + ) + let path = try #require(environment[SessionScrollbackReplayStore.environmentKey]) + let contents = try String(contentsOfFile: path, encoding: .utf8) + + #expect(contents.contains("\(SessionScrollbackReplayStore.semanticPromptStartMark)\(promptPrefix)\(message)")) + } + + @Test func persistablePromptMatchKeyGatesPersistenceAndBoundsKey() { + let esc = "\u{001B}" + let message = "refactor the login flow" + let withPrompt = "\(esc)[2m> \(esc)[0m\(message)\n\(esc)[32m● working\(esc)[0m\n" + let unrelated = "building project…\n$ swift build\nCompiling…\n" + + #expect( + SessionScrollbackReplayStore.persistablePromptMatchKey(forScrollback: withPrompt, lastUserMessage: message) + != nil + ) + #expect( + SessionScrollbackReplayStore.persistablePromptMatchKey(forScrollback: unrelated, lastUserMessage: message) + == nil + ) + #expect(SessionScrollbackReplayStore.persistablePromptMatchKey(forScrollback: nil, lastUserMessage: message) == nil) + #expect(SessionScrollbackReplayStore.persistablePromptMatchKey(forScrollback: withPrompt, lastUserMessage: nil) == nil) + + let longMessage = String(repeating: "alpha bravo ", count: 30) + let longScrollback = "\(esc)[2m> \(esc)[0m\(longMessage)\noutput\n" + let key = SessionScrollbackReplayStore.persistablePromptMatchKey( + forScrollback: longScrollback, + lastUserMessage: longMessage + ) + #expect(key != nil) + #expect((key ?? "").count <= 48) + #expect(key != longMessage) + } + + @Test func terminalSnapshotWithoutScrollbackDecodesNilPromptMarkKey() throws { + let snapshot = SessionTerminalPanelSnapshot(workingDirectory: "/tmp") + #expect(snapshot.lastPromptMarkKey == nil) + let data = try JSONEncoder().encode(snapshot) + let decoded = try JSONDecoder().decode(SessionTerminalPanelSnapshot.self, from: data) + #expect(decoded.lastPromptMarkKey == nil) + } +} diff --git a/cmuxTests/WorkspacePromptSubmitTests.swift b/cmuxTests/WorkspacePromptSubmitTests.swift index f8e7bb0055ca..13fb7cc2ef9c 100644 --- a/cmuxTests/WorkspacePromptSubmitTests.swift +++ b/cmuxTests/WorkspacePromptSubmitTests.swift @@ -270,6 +270,55 @@ struct WorkspacePromptSubmitTests { #expect(workspace.latestSubmittedAt != nil) } + @Test func testPromptMarkKeyPersistsOnlyForSubmittedPromptPanel() throws { + let workspace = Workspace() + let submittedPanelId = try #require(workspace.focusedPanelId) + let unrelatedPanel = workspace.createReplacementTerminalPanel() + workspace.focusPanel(submittedPanelId) + + let esc = "\u{001B}" + let message = "deploy the release" + workspace.restoredTerminalScrollbackByPanelId[submittedPanelId] = "\(esc)[2m> \(esc)[0m\(message)\nowner output\n" + workspace.restoredTerminalScrollbackByPanelId[unrelatedPanel.id] = "$ \(message)\nunrelated output\n" + + #expect(workspace.recordSubmittedMessage(message)) + + let snapshot = workspace.sessionSnapshot(includeScrollback: true) + let submittedSnapshot = try #require(snapshot.panels.first { $0.id == submittedPanelId }?.terminal) + let unrelatedSnapshot = try #require(snapshot.panels.first { $0.id == unrelatedPanel.id }?.terminal) + + #expect(submittedSnapshot.lastPromptMarkKey == message) + #expect(unrelatedSnapshot.lastPromptMarkKey == nil) + } + + @Test func testRestoredPromptMarkKeySurvivesResnapshotWithoutNewSubmit() throws { + let source = Workspace() + let sourcePanelId = try #require(source.focusedPanelId) + + let esc = "\u{001B}" + let message = "deploy the release" + source.restoredTerminalScrollbackByPanelId[sourcePanelId] = "\(esc)[2m> \(esc)[0m\(message)\nowner output\n" + + #expect(source.recordSubmittedMessage(message, panelId: sourcePanelId)) + let sourceSnapshot = source.sessionSnapshot(includeScrollback: true) + let sourceTerminalSnapshot = try #require(sourceSnapshot.panels.first { $0.id == sourcePanelId }?.terminal) + #expect(sourceTerminalSnapshot.lastPromptMarkKey == message) + + let restored = Workspace() + let restoredPanelIds = restored.restoreSessionSnapshot(sourceSnapshot) + let restoredPanelId = try #require(restoredPanelIds[sourcePanelId]) + + let metadataOnlySnapshot = restored.sessionSnapshot(includeScrollback: false) + let metadataOnlyTerminalSnapshot = try #require( + metadataOnlySnapshot.panels.first { $0.id == restoredPanelId }?.terminal + ) + #expect(metadataOnlyTerminalSnapshot.lastPromptMarkKey == nil) + + let resnapshot = restored.sessionSnapshot(includeScrollback: true) + let restoredTerminalSnapshot = try #require(resnapshot.panels.first { $0.id == restoredPanelId }?.terminal) + #expect(restoredTerminalSnapshot.lastPromptMarkKey == message) + } + @Test func testIMessageModeUsesManagedSettingsKey() throws { let suiteName = "cmux.iMessageMode.test.\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suiteName))