diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 3108185a..17a13f91 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1511,6 +1511,10 @@ struct CMUXCLI { let bridged = replaceToken(commandArgs, from: "--panel", to: "--surface") try runBrowserCommand(commandArgs: ["is-webview-focused"] + bridged, client: client, jsonOutput: jsonOutput, idFormat: idFormat) + // Markdown commands + case "markdown": + try runMarkdownCommand(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat) + default: print(usage()) throw CLIError(message: "Unknown command: \(command)") @@ -1524,6 +1528,96 @@ struct CMUXCLI { return (cwd as NSString).appendingPathComponent(expanded) } + // MARK: - Markdown Commands + + private func runMarkdownCommand( + commandArgs: [String], + client: SocketClient, + jsonOutput: Bool, + idFormat: CLIIDFormat + ) throws { + var args = commandArgs + + // Parse routing flags + let (workspaceOpt, argsAfterWorkspace) = parseOption(args, name: "--workspace") + let (windowOpt, argsAfterWindow) = parseOption(argsAfterWorkspace, name: "--window") + let (surfaceOpt, argsAfterSurface) = parseOption(argsAfterWindow, name: "--surface") + args = argsAfterSurface + + // Determine subcommand. Explicit "open" is supported, otherwise treat + // a single positional argument as shorthand path. + let subArgs: [String] + if let first = args.first, first.lowercased() == "open" { + subArgs = Array(args.dropFirst()) + } else if args.count == 1, let first = args.first, !first.hasPrefix("-") { + subArgs = [first] + } else { + // Allow path-like first tokens (e.g. plan.md) with trailing args + // so we can surface specific trailing-arg/flag errors below. + if let first = args.first, first.hasPrefix("-") { + throw CLIError( + message: + "markdown open: unknown flag '\(first)'. Usage: cmux markdown open [--workspace ] [--surface ] [--window ]" + ) + } else if let first = args.first, looksLikePath(first) || first.contains(".") { + subArgs = args + } else if let first = args.first { + throw CLIError(message: "Unknown markdown subcommand: \(first). Usage: cmux markdown open ") + } else { + subArgs = [] + } + } + + guard let rawPath = subArgs.first, !rawPath.isEmpty else { + throw CLIError(message: "markdown open requires a file path. Usage: cmux markdown open ") + } + let trailingArgs = Array(subArgs.dropFirst()) + if let unknownFlag = trailingArgs.first(where: { $0.hasPrefix("-") }) { + throw CLIError( + message: + "markdown open: unknown flag '\(unknownFlag)'. Usage: cmux markdown open [--workspace ] [--surface ] [--window ]" + ) + } + if let extraArg = trailingArgs.first { + throw CLIError( + message: + "markdown open: unexpected argument '\(extraArg)'. Usage: cmux markdown open [--workspace ] [--surface ] [--window ]" + ) + } + + let absolutePath = resolvePath(rawPath) + + // Build params + var params: [String: Any] = ["path": absolutePath] + if let surfaceRaw = surfaceOpt { + if let surface = try normalizeSurfaceHandle(surfaceRaw, client: client) { + params["surface_id"] = surface + } + } + let workspaceRaw = workspaceOpt ?? (windowOpt == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) + if let workspaceRaw { + if let workspace = try normalizeWorkspaceHandle(workspaceRaw, client: client) { + params["workspace_id"] = workspace + } + } + if let windowRaw = windowOpt { + if let window = try normalizeWindowHandle(windowRaw, client: client) { + params["window_id"] = window + } + } + + let payload = try client.sendV2(method: "markdown.open", params: params) + + if jsonOutput { + print(jsonString(formatIDs(payload, mode: idFormat))) + } else { + let surfaceText = formatHandle(payload, kind: "surface", idFormat: idFormat) ?? "unknown" + let paneText = formatHandle(payload, kind: "pane", idFormat: idFormat) ?? "unknown" + let filePath = (payload["path"] as? String) ?? absolutePath + print("OK surface=\(surfaceText) pane=\(paneText) path=\(filePath)") + } + } + /// Returns true if the argument looks like a filesystem path rather than a CLI command. private func looksLikePath(_ arg: String) -> Bool { if arg == "." || arg == ".." { return true } @@ -4551,6 +4645,25 @@ struct CMUXCLI { return "Legacy alias for 'cmux browser focus-webview'. Run 'cmux browser --help' for details." case "is-webview-focused": return "Legacy alias for 'cmux browser is-webview-focused'. Run 'cmux browser --help' for details." + case "markdown": + return """ + Usage: cmux markdown open [options] + cmux markdown (shorthand for 'open') + + Open a markdown file in a formatted viewer panel with live file watching. + The file is rendered with rich formatting (headings, code blocks, tables, + lists, blockquotes) and automatically updates when the file changes on disk. + + Options: + --workspace Target workspace (default: $CMUX_WORKSPACE_ID) + --surface Source surface to split from (default: focused surface) + --window Target window + + Examples: + cmux markdown open plan.md + cmux markdown ~/project/CHANGELOG.md + cmux markdown open ./docs/design.md --workspace 0 + """ default: return nil } @@ -6459,6 +6572,8 @@ struct CMUXCLI { respawn-pane [--workspace ] [--surface ] [--command ] display-message [-p|--print] + markdown [open] (open markdown file in formatted viewer panel with live reload) + browser [--surface | ] ... browser open [url] (create browser split in caller's workspace; if surface supplied, behaves like navigate) browser open-split [url] diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 56f76d80..0b4326bd 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -28,6 +28,9 @@ A5001402 /* BrowserPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001412 /* BrowserPanel.swift */; }; A5001403 /* TerminalPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001413 /* TerminalPanelView.swift */; }; A5001404 /* BrowserPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001414 /* BrowserPanelView.swift */; }; + A5001420 /* MarkdownPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001418 /* MarkdownPanel.swift */; }; + A5001421 /* MarkdownPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001419 /* MarkdownPanelView.swift */; }; + A5001290 /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = A5001291 /* MarkdownUI */; }; A5001405 /* PanelContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001415 /* PanelContentView.swift */; }; A5001406 /* Workspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001416 /* Workspace.swift */; }; A5001407 /* WorkspaceContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001417 /* WorkspaceContentView.swift */; }; @@ -170,6 +173,8 @@ A5001413 /* TerminalPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/TerminalPanelView.swift; sourceTree = ""; }; A5001414 /* BrowserPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanelView.swift; sourceTree = ""; }; A5001415 /* PanelContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/PanelContentView.swift; sourceTree = ""; }; + A5001418 /* MarkdownPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownPanel.swift; sourceTree = ""; }; + A5001419 /* MarkdownPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownPanelView.swift; sourceTree = ""; }; A5001416 /* Workspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Workspace.swift; sourceTree = ""; }; A5001417 /* WorkspaceContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentView.swift; sourceTree = ""; }; A5001090 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -236,6 +241,7 @@ A5001230 /* Sparkle in Frameworks */, A5001250 /* Sentry in Frameworks */, A5001270 /* PostHog in Frameworks */, + A5001290 /* MarkdownUI in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -363,6 +369,8 @@ A5001412 /* BrowserPanel.swift */, A5001413 /* TerminalPanelView.swift */, A5001414 /* BrowserPanelView.swift */, + A5001418 /* MarkdownPanel.swift */, + A5001419 /* MarkdownPanelView.swift */, A5001510 /* CmuxWebView.swift */, A5001415 /* PanelContentView.swift */, A5001211 /* UpdateController.swift */, @@ -473,6 +481,7 @@ A5001251 /* Sentry */, A5001271 /* PostHog */, A5001261 /* Bonsplit */, + A5001291 /* MarkdownUI */, ); name = GhosttyTabs; productName = GhosttyTabs; @@ -558,6 +567,7 @@ A5001232 /* XCRemoteSwiftPackageReference "Sparkle" */, A5001252 /* XCRemoteSwiftPackageReference "sentry-cocoa" */, A5001272 /* XCRemoteSwiftPackageReference "posthog-ios" */, + A5001292 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */, A5001260 /* XCLocalSwiftPackageReference "bonsplit" */, ); productRefGroup = A5001042 /* Products */; @@ -608,6 +618,8 @@ A5001402 /* BrowserPanel.swift in Sources */, A5001403 /* TerminalPanelView.swift in Sources */, A5001404 /* BrowserPanelView.swift in Sources */, + A5001420 /* MarkdownPanel.swift in Sources */, + A5001421 /* MarkdownPanelView.swift in Sources */, A5001500 /* CmuxWebView.swift in Sources */, A5001405 /* PanelContentView.swift in Sources */, A5001201 /* UpdateController.swift in Sources */, @@ -966,6 +978,14 @@ minimumVersion = 3.41.0; }; }; + A5001292 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/gonzalezreal/swift-markdown-ui"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.4.1; + }; + }; A5001260 /* XCLocalSwiftPackageReference "bonsplit" */ = { isa = XCLocalSwiftPackageReference; relativePath = vendor/bonsplit; @@ -993,6 +1013,11 @@ package = A5001260 /* XCLocalSwiftPackageReference "bonsplit" */; productName = Bonsplit; }; + A5001291 /* MarkdownUI */ = { + isa = XCSwiftPackageProductDependency; + package = A5001292 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */; + productName = MarkdownUI; + }; /* End XCSwiftPackageProductDependency section */ /* Begin XCConfigurationList section */ diff --git a/GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 492ab4e9..3bf056ae 100644 --- a/GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "a1df212ee81645b29368e6cc39c83aebbbafb5c592f726afc990bab228304987", + "originHash" : "b66d812c506be67c70b46c63421ab2eb2db013613c74252ad1205f662ada079b", "pins" : [ + { + "identity" : "networkimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/NetworkImage", + "state" : { + "revision" : "2849f5323265386e200484b0d0f896e73c3411b9", + "version" : "6.0.1" + } + }, { "identity" : "posthog-ios", "kind" : "remoteSourceControl", @@ -27,6 +36,24 @@ "revision" : "5581748cef2bae787496fe6d61139aebe0a451f6", "version" : "2.8.1" } + }, + { + "identity" : "swift-cmark", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-cmark", + "state" : { + "revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe", + "version" : "0.7.1" + } + }, + { + "identity" : "swift-markdown-ui", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/swift-markdown-ui", + "state" : { + "revision" : "5f613358148239d0292c0cef674a3c2314737f9e", + "version" : "2.4.1" + } } ], "version" : 3 diff --git a/Sources/Panels/MarkdownPanel.swift b/Sources/Panels/MarkdownPanel.swift new file mode 100644 index 00000000..74e48b89 --- /dev/null +++ b/Sources/Panels/MarkdownPanel.swift @@ -0,0 +1,182 @@ +import Foundation +import Combine + +/// A panel that renders a markdown file with live file-watching. +/// When the file changes on disk, the content is automatically reloaded. +@MainActor +final class MarkdownPanel: Panel, ObservableObject { + let id: UUID + let panelType: PanelType = .markdown + + /// Absolute path to the markdown file being displayed. + let filePath: String + + /// The workspace this panel belongs to. + private(set) var workspaceId: UUID + + /// Current markdown content read from the file. + @Published private(set) var content: String = "" + + /// Title shown in the tab bar (filename). + @Published private(set) var displayTitle: String = "" + + /// SF Symbol icon for the tab bar. + var displayIcon: String? { "doc.richtext" } + + /// Whether the file has been deleted or is unreadable. + @Published private(set) var isFileUnavailable: Bool = false + + /// Token incremented to trigger focus flash animation. + @Published private(set) var focusFlashToken: Int = 0 + + // MARK: - File watching + + // nonisolated(unsafe) because deinit is not guaranteed to run on the + // main actor, but DispatchSource.cancel() is thread-safe. + private nonisolated(unsafe) var fileWatchSource: DispatchSourceFileSystemObject? + private var fileDescriptor: Int32 = -1 + private var isClosed: Bool = false + private let watchQueue = DispatchQueue(label: "com.cmux.markdown-file-watch", qos: .utility) + + /// Maximum number of reattach attempts after a file delete/rename event. + private static let maxReattachAttempts = 6 + /// Delay between reattach attempts (total window: attempts * delay = 3s). + private static let reattachDelay: TimeInterval = 0.5 + + // MARK: - Init + + init(workspaceId: UUID, filePath: String) { + self.id = UUID() + self.workspaceId = workspaceId + self.filePath = filePath + self.displayTitle = (filePath as NSString).lastPathComponent + + loadFileContent() + startFileWatcher() + if isFileUnavailable && fileWatchSource == nil { + // Session restore can create a panel before the file is recreated. + // Retry briefly so atomic-rename recreations can reconnect. + scheduleReattach(attempt: 1) + } + } + + // MARK: - Panel protocol + + func focus() { + // Markdown panel is read-only; no first responder to manage. + } + + func unfocus() { + // No-op for read-only panel. + } + + func close() { + isClosed = true + stopFileWatcher() + } + + func triggerFlash() { + focusFlashToken += 1 + } + + // MARK: - File I/O + + private func loadFileContent() { + do { + let newContent = try String(contentsOfFile: filePath, encoding: .utf8) + content = newContent + isFileUnavailable = false + } catch { + // Fallback: try ISO Latin-1, which accepts all 256 byte values, + // covering legacy encodings like Windows-1252. + if let data = FileManager.default.contents(atPath: filePath), + let decoded = String(data: data, encoding: .isoLatin1) { + content = decoded + isFileUnavailable = false + } else { + isFileUnavailable = true + } + } + } + + // MARK: - File watcher via DispatchSource + + private func startFileWatcher() { + let fd = open(filePath, O_EVTONLY) + guard fd >= 0 else { return } + fileDescriptor = fd + + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fd, + eventMask: [.write, .delete, .rename, .extend], + queue: watchQueue + ) + + source.setEventHandler { [weak self] in + guard let self else { return } + let flags = source.data + if flags.contains(.delete) || flags.contains(.rename) { + // File was deleted or renamed. The old file descriptor points to + // a stale inode, so we must always stop and reattach the watcher + // even if the new file is already readable (atomic save case). + DispatchQueue.main.async { + self.stopFileWatcher() + self.loadFileContent() + if self.isFileUnavailable { + // File not yet replaced — retry until it reappears. + self.scheduleReattach(attempt: 1) + } else { + // File already replaced — reattach to the new inode immediately. + self.startFileWatcher() + } + } + } else { + // Content changed — reload. + DispatchQueue.main.async { + self.loadFileContent() + } + } + } + + source.setCancelHandler { + Darwin.close(fd) + } + + source.resume() + fileWatchSource = source + } + + /// Retry reattaching the file watcher up to `maxReattachAttempts` times. + /// Each attempt checks if the file has reappeared. Bails out early if + /// the panel has been closed. + private func scheduleReattach(attempt: Int) { + guard attempt <= Self.maxReattachAttempts else { return } + watchQueue.asyncAfter(deadline: .now() + Self.reattachDelay) { [weak self] in + guard let self else { return } + DispatchQueue.main.async { + guard !self.isClosed else { return } + if FileManager.default.fileExists(atPath: self.filePath) { + self.isFileUnavailable = false + self.loadFileContent() + self.startFileWatcher() + } else { + self.scheduleReattach(attempt: attempt + 1) + } + } + } + } + + private func stopFileWatcher() { + if let source = fileWatchSource { + source.cancel() + fileWatchSource = nil + } + // File descriptor is closed by the cancel handler. + fileDescriptor = -1 + } + + deinit { + // DispatchSource cancel is safe from any thread. + fileWatchSource?.cancel() + } +} diff --git a/Sources/Panels/MarkdownPanelView.swift b/Sources/Panels/MarkdownPanelView.swift new file mode 100644 index 00000000..b3b7a971 --- /dev/null +++ b/Sources/Panels/MarkdownPanelView.swift @@ -0,0 +1,285 @@ +import SwiftUI +import MarkdownUI + +/// SwiftUI view that renders a MarkdownPanel's content using MarkdownUI. +struct MarkdownPanelView: View { + @ObservedObject var panel: MarkdownPanel + let isFocused: Bool + let isVisibleInUI: Bool + let portalPriority: Int + let onRequestPanelFocus: () -> Void + + @State private var focusFlashOpacity: Double = 0.0 + @State private var focusFlashAnimationGeneration: Int = 0 + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + Group { + if panel.isFileUnavailable { + fileUnavailableView + } else { + markdownContentView + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(backgroundColor) + .overlay { + RoundedRectangle(cornerRadius: FocusFlashPattern.ringCornerRadius) + .stroke(cmuxAccentColor().opacity(focusFlashOpacity), lineWidth: 3) + .shadow(color: cmuxAccentColor().opacity(focusFlashOpacity * 0.35), radius: 10) + .padding(FocusFlashPattern.ringInset) + .allowsHitTesting(false) + } + .contentShape(Rectangle()) + .onTapGesture { + onRequestPanelFocus() + } + .onChange(of: panel.focusFlashToken) { _ in + triggerFocusFlashAnimation() + } + } + + // MARK: - Content + + private var markdownContentView: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + // File path breadcrumb + filePathHeader + .padding(.horizontal, 24) + .padding(.top, 16) + .padding(.bottom, 8) + + Divider() + .padding(.horizontal, 16) + + // Rendered markdown + Markdown(panel.content) + .markdownTheme(cmuxMarkdownTheme) + .textSelection(.enabled) + .padding(.horizontal, 24) + .padding(.vertical, 16) + } + } + } + + private var filePathHeader: some View { + HStack(spacing: 6) { + Image(systemName: "doc.richtext") + .foregroundColor(.secondary) + .font(.system(size: 12)) + Text(panel.filePath) + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + } + } + + private var fileUnavailableView: some View { + VStack(spacing: 12) { + Image(systemName: "doc.questionmark") + .font(.system(size: 40)) + .foregroundColor(.secondary) + Text("File unavailable") + .font(.headline) + .foregroundColor(.primary) + Text(panel.filePath) + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(.secondary) + .lineLimit(2) + .multilineTextAlignment(.center) + Text("The file may have been moved or deleted.") + .font(.caption) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Theme + + private var backgroundColor: Color { + colorScheme == .dark + ? Color(nsColor: NSColor(white: 0.12, alpha: 1.0)) + : Color(nsColor: NSColor(white: 0.98, alpha: 1.0)) + } + + private var cmuxMarkdownTheme: Theme { + let isDark = colorScheme == .dark + + return Theme() + // Text + .text { + ForegroundColor(isDark ? .white.opacity(0.9) : .primary) + FontSize(14) + } + // Headings + .heading1 { configuration in + VStack(alignment: .leading, spacing: 8) { + configuration.label + .markdownTextStyle { + FontWeight(.bold) + FontSize(28) + ForegroundColor(isDark ? .white : .primary) + } + Divider() + } + .markdownMargin(top: 24, bottom: 16) + } + .heading2 { configuration in + VStack(alignment: .leading, spacing: 6) { + configuration.label + .markdownTextStyle { + FontWeight(.bold) + FontSize(22) + ForegroundColor(isDark ? .white : .primary) + } + Divider() + } + .markdownMargin(top: 20, bottom: 12) + } + .heading3 { configuration in + configuration.label + .markdownTextStyle { + FontWeight(.semibold) + FontSize(18) + ForegroundColor(isDark ? .white : .primary) + } + .markdownMargin(top: 16, bottom: 8) + } + .heading4 { configuration in + configuration.label + .markdownTextStyle { + FontWeight(.semibold) + FontSize(16) + ForegroundColor(isDark ? .white : .primary) + } + .markdownMargin(top: 12, bottom: 6) + } + .heading5 { configuration in + configuration.label + .markdownTextStyle { + FontWeight(.medium) + FontSize(14) + ForegroundColor(isDark ? .white : .primary) + } + .markdownMargin(top: 10, bottom: 4) + } + .heading6 { configuration in + configuration.label + .markdownTextStyle { + FontWeight(.medium) + FontSize(13) + ForegroundColor(isDark ? .white.opacity(0.7) : .secondary) + } + .markdownMargin(top: 8, bottom: 4) + } + // Code blocks + .codeBlock { configuration in + ScrollView(.horizontal, showsIndicators: true) { + configuration.label + .markdownTextStyle { + FontFamilyVariant(.monospaced) + FontSize(13) + ForegroundColor(isDark ? Color(red: 0.9, green: 0.9, blue: 0.9) : Color(red: 0.2, green: 0.2, blue: 0.2)) + } + .padding(12) + } + .background(isDark + ? Color(nsColor: NSColor(white: 0.08, alpha: 1.0)) + : Color(nsColor: NSColor(white: 0.93, alpha: 1.0))) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .markdownMargin(top: 8, bottom: 8) + } + // Inline code + .code { + FontFamilyVariant(.monospaced) + FontSize(13) + ForegroundColor(isDark ? Color(red: 0.85, green: 0.6, blue: 0.95) : Color(red: 0.6, green: 0.2, blue: 0.7)) + BackgroundColor(isDark + ? Color(nsColor: NSColor(white: 0.18, alpha: 1.0)) + : Color(nsColor: NSColor(white: 0.92, alpha: 1.0))) + } + // Block quotes + .blockquote { configuration in + HStack(spacing: 0) { + RoundedRectangle(cornerRadius: 1.5) + .fill(isDark ? Color.white.opacity(0.2) : Color.gray.opacity(0.4)) + .frame(width: 3) + configuration.label + .markdownTextStyle { + ForegroundColor(isDark ? .white.opacity(0.6) : .secondary) + FontSize(14) + } + .padding(.leading, 12) + } + .markdownMargin(top: 8, bottom: 8) + } + // Links + .link { + ForegroundColor(Color.accentColor) + } + // Strong + .strong { + FontWeight(.semibold) + } + // Tables + .table { configuration in + configuration.label + .markdownTableBorderStyle(.init(color: isDark ? .white.opacity(0.15) : .gray.opacity(0.3))) + .markdownTableBackgroundStyle( + .alternatingRows( + isDark + ? Color(nsColor: NSColor(white: 0.14, alpha: 1.0)) + : Color(nsColor: NSColor(white: 0.96, alpha: 1.0)), + isDark + ? Color(nsColor: NSColor(white: 0.10, alpha: 1.0)) + : Color(nsColor: NSColor(white: 1.0, alpha: 1.0)) + ) + ) + .markdownMargin(top: 8, bottom: 8) + } + // Thematic break (horizontal rule) + .thematicBreak { + Divider() + .markdownMargin(top: 16, bottom: 16) + } + // List items + .listItem { configuration in + configuration.label + .markdownMargin(top: 4, bottom: 4) + } + // Paragraphs + .paragraph { configuration in + configuration.label + .markdownMargin(top: 4, bottom: 8) + } + } + + // MARK: - Focus Flash + + private func triggerFocusFlashAnimation() { + focusFlashAnimationGeneration &+= 1 + let generation = focusFlashAnimationGeneration + focusFlashOpacity = FocusFlashPattern.values.first ?? 0 + + for segment in FocusFlashPattern.segments { + DispatchQueue.main.asyncAfter(deadline: .now() + segment.delay) { + guard focusFlashAnimationGeneration == generation else { return } + withAnimation(focusFlashAnimation(for: segment.curve, duration: segment.duration)) { + focusFlashOpacity = segment.targetOpacity + } + } + } + } + + private func focusFlashAnimation(for curve: FocusFlashCurve, duration: TimeInterval) -> Animation { + switch curve { + case .easeIn: + return .easeIn(duration: duration) + case .easeOut: + return .easeOut(duration: duration) + } + } +} diff --git a/Sources/Panels/Panel.swift b/Sources/Panels/Panel.swift index a0a719c4..09ec66b6 100644 --- a/Sources/Panels/Panel.swift +++ b/Sources/Panels/Panel.swift @@ -5,6 +5,7 @@ import Combine public enum PanelType: String, Codable, Sendable { case terminal case browser + case markdown } enum FocusFlashCurve: Equatable { diff --git a/Sources/Panels/PanelContentView.swift b/Sources/Panels/PanelContentView.swift index 1374a5a7..adec500f 100644 --- a/Sources/Panels/PanelContentView.swift +++ b/Sources/Panels/PanelContentView.swift @@ -41,6 +41,16 @@ struct PanelContentView: View { onRequestPanelFocus: onRequestPanelFocus ) } + case .markdown: + if let markdownPanel = panel as? MarkdownPanel { + MarkdownPanelView( + panel: markdownPanel, + isFocused: isFocused, + isVisibleInUI: isVisibleInUI, + portalPriority: portalPriority, + onRequestPanelFocus: onRequestPanelFocus + ) + } } } } diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index 289909df..53eb995e 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -235,6 +235,10 @@ struct SessionBrowserPanelSnapshot: Codable, Sendable { var forwardHistoryURLStrings: [String]? } +struct SessionMarkdownPanelSnapshot: Codable, Sendable { + var filePath: String +} + struct SessionPanelSnapshot: Codable, Sendable { var id: UUID var type: PanelType @@ -248,6 +252,7 @@ struct SessionPanelSnapshot: Codable, Sendable { var ttyName: String? var terminal: SessionTerminalPanelSnapshot? var browser: SessionBrowserPanelSnapshot? + var markdown: SessionMarkdownPanelSnapshot? } enum SessionSplitOrientation: String, Codable, Sendable { diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 3052e9f9..43995ccb 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1477,6 +1477,11 @@ class TerminalController { return v2Result(id: id, self.v2BrowserInputKeyboard(params: params)) case "browser.input_touch": return v2Result(id: id, self.v2BrowserInputTouch(params: params)) + + // Markdown + case "markdown.open": + return v2Result(id: id, self.v2MarkdownOpen(params: params)) + case "surface.read_text": return v2Result(id: id, self.v2SurfaceReadText(params: params)) @@ -1621,6 +1626,7 @@ class TerminalController { "notification.clear", "app.focus_override.set", "app.simulate_active", + "markdown.open", "browser.open_split", "browser.navigate", "browser.back", @@ -5249,6 +5255,95 @@ class TerminalController { return rep.representation(using: .png, properties: [:]) } + // MARK: - Markdown + + private func v2MarkdownOpen(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let rawPath = v2String(params, "path") else { + return .err(code: "invalid_params", message: "Missing 'path' parameter", data: nil) + } + + // Resolve the path (expand ~ and standardize) + let expandedPath = NSString(string: rawPath).expandingTildeInPath + let filePath = NSString(string: expandedPath).standardizingPath + + // Reject paths that aren't absolute after resolution + guard filePath.hasPrefix("/") else { + return .err(code: "invalid_params", message: "Path must be absolute: \(filePath)", data: ["path": filePath]) + } + + // Validate the file exists and is a regular file (not a directory) + var isDir: ObjCBool = false + guard FileManager.default.fileExists(atPath: filePath, isDirectory: &isDir) else { + return .err(code: "not_found", message: "File not found: \(filePath)", data: ["path": filePath]) + } + guard !isDir.boolValue else { + return .err(code: "invalid_params", message: "Path is a directory, not a file: \(filePath)", data: ["path": filePath]) + } + guard FileManager.default.isReadableFile(atPath: filePath) else { + return .err(code: "permission_denied", message: "File not readable: \(filePath)", data: ["path": filePath]) + } + + var result: V2CallResult = .err(code: "internal_error", message: "Failed to create markdown panel", data: nil) + v2MainSync { + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + result = .err(code: "not_found", message: "Workspace not found", data: nil) + return + } + v2MaybeFocusWindow(for: tabManager) + v2MaybeSelectWorkspace(tabManager, workspace: ws) + + let sourceSurfaceId = v2UUID(params, "surface_id") ?? ws.focusedPanelId + guard let sourceSurfaceId else { + result = .err(code: "not_found", message: "No focused surface to split", data: nil) + return + } + guard ws.panels[sourceSurfaceId] != nil else { + result = .err(code: "not_found", message: "Source surface not found", data: ["surface_id": sourceSurfaceId.uuidString]) + return + } + + let sourcePaneUUID = ws.paneId(forPanelId: sourceSurfaceId)?.id + + let createdPanel = ws.newMarkdownSplit( + from: sourceSurfaceId, + orientation: .horizontal, + filePath: filePath, + focus: v2FocusAllowed() + ) + + guard let markdownPanelId = createdPanel?.id else { + result = .err(code: "internal_error", message: "Failed to create markdown panel", data: nil) + return + } + + let targetPaneUUID = ws.paneId(forPanelId: markdownPanelId)?.id + let windowId = v2ResolveWindowId(tabManager: tabManager) + result = .ok([ + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId), + "workspace_id": ws.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), + "pane_id": v2OrNull(targetPaneUUID?.uuidString), + "pane_ref": v2Ref(kind: .pane, uuid: targetPaneUUID), + "surface_id": markdownPanelId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: markdownPanelId), + "source_surface_id": sourceSurfaceId.uuidString, + "source_surface_ref": v2Ref(kind: .surface, uuid: sourceSurfaceId), + "source_pane_id": v2OrNull(sourcePaneUUID?.uuidString), + "source_pane_ref": v2Ref(kind: .pane, uuid: sourcePaneUUID), + "target_pane_id": v2OrNull(targetPaneUUID?.uuidString), + "target_pane_ref": v2Ref(kind: .pane, uuid: targetPaneUUID), + "path": filePath + ]) + } + return result + } + + // MARK: - Browser + private func v2BrowserOpenSplit(params: [String: Any]) -> V2CallResult { guard let tabManager = v2ResolveTabManager(params: params) else { return .err(code: "unavailable", message: "TabManager not available", data: nil) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 010310b4..70ff179d 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -307,6 +307,7 @@ extension Workspace { let terminalSnapshot: SessionTerminalPanelSnapshot? let browserSnapshot: SessionBrowserPanelSnapshot? + let markdownSnapshot: SessionMarkdownPanelSnapshot? switch panel.panelType { case .terminal: guard let terminalPanel = panel as? TerminalPanel else { return nil } @@ -327,6 +328,7 @@ extension Workspace { scrollback: resolvedScrollback ) browserSnapshot = nil + markdownSnapshot = nil case .browser: guard let browserPanel = panel as? BrowserPanel else { return nil } terminalSnapshot = nil @@ -339,6 +341,12 @@ extension Workspace { backHistoryURLStrings: historySnapshot.backHistoryURLStrings, forwardHistoryURLStrings: historySnapshot.forwardHistoryURLStrings ) + markdownSnapshot = nil + case .markdown: + guard let mdPanel = panel as? MarkdownPanel else { return nil } + terminalSnapshot = nil + browserSnapshot = nil + markdownSnapshot = SessionMarkdownPanelSnapshot(filePath: mdPanel.filePath) } return SessionPanelSnapshot( @@ -353,7 +361,8 @@ extension Workspace { listeningPorts: listeningPorts, ttyName: ttyName, terminal: terminalSnapshot, - browser: browserSnapshot + browser: browserSnapshot, + markdown: markdownSnapshot ) } @@ -513,6 +522,19 @@ extension Workspace { } applySessionPanelMetadata(snapshot, toPanelId: browserPanel.id) return browserPanel.id + case .markdown: + guard let filePath = snapshot.markdown?.filePath else { + return nil + } + guard let markdownPanel = newMarkdownSurface( + inPane: paneId, + filePath: filePath, + focus: false + ) else { + return nil + } + applySessionPanelMetadata(snapshot, toPanelId: markdownPanel.id) + return markdownPanel.id } } @@ -984,6 +1006,7 @@ final class Workspace: Identifiable, ObservableObject { private enum SurfaceKind { static let terminal = "terminal" static let browser = "browser" + static let markdown = "markdown" } // MARK: - Initialization @@ -1296,6 +1319,31 @@ final class Workspace: Identifiable, ObservableObject { } panelSubscriptions[browserPanel.id] = subscription } + + private func installMarkdownPanelSubscription(_ markdownPanel: MarkdownPanel) { + let subscription = markdownPanel.$displayTitle + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self, weak markdownPanel] newTitle in + guard let self = self, + let markdownPanel = markdownPanel, + let tabId = self.surfaceIdFromPanelId(markdownPanel.id) else { return } + guard let existing = self.bonsplitController.tab(tabId) else { return } + + if self.panelTitles[markdownPanel.id] != newTitle { + self.panelTitles[markdownPanel.id] = newTitle + } + let resolvedTitle = self.resolvedPanelTitle(panelId: markdownPanel.id, fallback: newTitle) + guard existing.title != resolvedTitle else { return } + self.bonsplitController.updateTab( + tabId, + title: resolvedTitle, + hasCustomTitle: self.panelCustomTitles[markdownPanel.id] != nil + ) + } + panelSubscriptions[markdownPanel.id] = subscription + } + // MARK: - Panel Access func panel(for surfaceId: TabID) -> (any Panel)? { @@ -1311,12 +1359,18 @@ final class Workspace: Identifiable, ObservableObject { panels[panelId] as? BrowserPanel } + func markdownPanel(for panelId: UUID) -> MarkdownPanel? { + panels[panelId] as? MarkdownPanel + } + private func surfaceKind(for panel: any Panel) -> String { switch panel.panelType { case .terminal: return SurfaceKind.terminal case .browser: return SurfaceKind.browser + case .markdown: + return SurfaceKind.markdown } } @@ -2149,6 +2203,119 @@ final class Workspace: Identifiable, ObservableObject { return browserPanel } + // MARK: - Markdown Panel Creation + + /// Create a new markdown panel split from an existing panel. + func newMarkdownSplit( + from panelId: UUID, + orientation: SplitOrientation, + insertFirst: Bool = false, + filePath: String, + focus: Bool = true + ) -> MarkdownPanel? { + // Find the pane containing the source panel + guard let sourceTabId = surfaceIdFromPanelId(panelId) else { return nil } + var sourcePaneId: PaneID? + for paneId in bonsplitController.allPaneIds { + let tabs = bonsplitController.tabs(inPane: paneId) + if tabs.contains(where: { $0.id == sourceTabId }) { + sourcePaneId = paneId + break + } + } + + guard let paneId = sourcePaneId else { return nil } + + // Create markdown panel + let markdownPanel = MarkdownPanel(workspaceId: id, filePath: filePath) + panels[markdownPanel.id] = markdownPanel + panelTitles[markdownPanel.id] = markdownPanel.displayTitle + + // Pre-generate the bonsplit tab ID so the mapping exists before the split lands. + let newTab = Bonsplit.Tab( + title: markdownPanel.displayTitle, + icon: markdownPanel.displayIcon, + kind: SurfaceKind.markdown, + isDirty: markdownPanel.isDirty, + isLoading: false, + isPinned: false + ) + surfaceIdToPanelId[newTab.id] = markdownPanel.id + let previousFocusedPanelId = focusedPanelId + + // Create the split with the markdown tab already present in the new pane. + // Mark this split as programmatic so didSplitPane doesn't auto-create a terminal. + isProgrammaticSplit = true + defer { isProgrammaticSplit = false } + guard bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) != nil else { + surfaceIdToPanelId.removeValue(forKey: newTab.id) + panels.removeValue(forKey: markdownPanel.id) + panelTitles.removeValue(forKey: markdownPanel.id) + return nil + } + + // Suppress old view's becomeFirstResponder during reparenting. + let previousHostedView = focusedTerminalPanel?.hostedView + if focus { + previousHostedView?.suppressReparentFocus() + focusPanel(markdownPanel.id) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + previousHostedView?.clearSuppressReparentFocus() + } + } else { + preserveFocusAfterNonFocusSplit( + preferredPanelId: previousFocusedPanelId, + splitPanelId: markdownPanel.id, + previousHostedView: previousHostedView + ) + } + + installMarkdownPanelSubscription(markdownPanel) + + return markdownPanel + } + + /// Create a new markdown surface (tab) in the specified pane. + @discardableResult + func newMarkdownSurface( + inPane paneId: PaneID, + filePath: String, + focus: Bool? = nil + ) -> MarkdownPanel? { + let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) + + let markdownPanel = MarkdownPanel(workspaceId: id, filePath: filePath) + panels[markdownPanel.id] = markdownPanel + panelTitles[markdownPanel.id] = markdownPanel.displayTitle + + guard let newTabId = bonsplitController.createTab( + title: markdownPanel.displayTitle, + icon: markdownPanel.displayIcon, + kind: SurfaceKind.markdown, + isDirty: markdownPanel.isDirty, + isLoading: false, + isPinned: false, + inPane: paneId + ) else { + panels.removeValue(forKey: markdownPanel.id) + panelTitles.removeValue(forKey: markdownPanel.id) + return nil + } + + surfaceIdToPanelId[newTabId] = markdownPanel.id + + // Match terminal behavior: enforce deterministic selection + focus. + if shouldFocusNewTab { + bonsplitController.focusPane(paneId) + bonsplitController.selectTab(newTabId) + applyTabSelection(tabId: newTabId, inPane: paneId) + } + + installMarkdownPanelSubscription(markdownPanel) + + return markdownPanel + } + /// Close a panel. /// Returns true when a bonsplit tab close request was issued. func closePanel(_ panelId: UUID, force: Bool = false) -> Bool { diff --git a/skills/cmux-markdown/SKILL.md b/skills/cmux-markdown/SKILL.md new file mode 100644 index 00000000..8d2aac73 --- /dev/null +++ b/skills/cmux-markdown/SKILL.md @@ -0,0 +1,125 @@ +--- +name: cmux-markdown +description: Open markdown files in a formatted viewer panel with live reload. Use when you need to display plans, documentation, or notes alongside the terminal with rich rendering (headings, code blocks, tables, lists). +--- + +# Markdown Viewer with cmux + +Use this skill to display markdown files in a dedicated panel with rich formatting and live file watching. + +## Core Workflow + +1. Write your plan or notes to a `.md` file. +2. Open it in a markdown panel. +3. The panel auto-updates when the file changes on disk. + +```bash +# Open a markdown file as a split panel next to the current terminal +cmux markdown open plan.md + +# Absolute path +cmux markdown open /path/to/PLAN.md + +# Target a specific workspace +cmux markdown open design.md --workspace workspace:2 +``` + +## When to Use + +- Displaying an agent plan or task list alongside the terminal +- Showing documentation, changelogs, or READMEs while working +- Reviewing notes that update in real-time (e.g., a plan file being written by another process) + +## Live File Watching + +The panel automatically re-renders when the file changes on disk. This works with: + +- Direct writes (`echo "..." >> plan.md`) +- Editor saves (vim, nano, VS Code) +- Atomic file replacement (write to temp, rename over original) +- Agent-generated plan files that are updated progressively + +If the file is deleted, the panel shows a "file unavailable" state. During atomic replace, the panel attempts automatic reconnection within its short retry window. If the file returns later, close and reopen the panel. + +## Agent Integration + +### Opening a plan file + +Write your plan to a file, then open it: + +```bash +cat > plan.md << 'EOF' +# Task Plan + +## Steps +1. Analyze the codebase +2. Implement the feature +3. Write tests +4. Verify the build +EOF + +cmux markdown open plan.md +``` + +### Updating a plan in real-time + +The panel live-reloads, so simply overwrite the file as work progresses: + +```bash +# The markdown panel updates automatically when the file changes +echo "## Step 1: Complete" >> plan.md +``` + +### Recommended AGENTS.md instruction + +Add this to your project's `AGENTS.md` to instruct coding agents to use the markdown viewer: + +```markdown +## Plan Display + +When creating a plan or task list, write it to a `.md` file and open it in cmux: + + cmux markdown open plan.md + +The panel renders markdown with rich formatting and auto-updates when the file changes. +``` + +## Routing + +```bash +# Open in the caller's workspace (default -- uses CMUX_WORKSPACE_ID) +cmux markdown open plan.md + +# Open in a specific workspace +cmux markdown open plan.md --workspace workspace:2 + +# Open splitting from a specific surface +cmux markdown open plan.md --surface surface:5 + +# Open in a specific window +cmux markdown open plan.md --window window:1 +``` + +## Deep-Dive References + +| Reference | When to Use | +|-----------|-------------| +| [references/commands.md](references/commands.md) | Full command syntax and options | +| [references/live-reload.md](references/live-reload.md) | File watching behavior, atomic writes, edge cases | + +## Rendering Support + +The markdown panel renders: + +- Headings (h1-h6) with dividers on h1/h2 +- Fenced code blocks with monospaced font +- Inline code with highlighted background +- Tables with alternating row colors +- Ordered and unordered lists (nested) +- Blockquotes with left border +- Bold, italic, strikethrough +- Links (clickable) +- Horizontal rules +- Images (inline) + +Supports both light and dark mode. diff --git a/skills/cmux-markdown/agents/openai.yaml b/skills/cmux-markdown/agents/openai.yaml new file mode 100644 index 00000000..0ce42fe4 --- /dev/null +++ b/skills/cmux-markdown/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "cmux Markdown Viewer" + short_description: "Open markdown files in a formatted panel with live reload alongside the terminal." + default_prompt: "Use this skill to display markdown plans, docs, or notes in a cmux panel: write to a .md file, run 'cmux markdown open ', and the panel auto-updates when the file changes." diff --git a/skills/cmux-markdown/references/commands.md b/skills/cmux-markdown/references/commands.md new file mode 100644 index 00000000..f40f635d --- /dev/null +++ b/skills/cmux-markdown/references/commands.md @@ -0,0 +1,69 @@ +# Command Reference (cmux Markdown) + +## Opening a Markdown Panel + +```bash +cmux markdown open +cmux markdown # shorthand (implicit "open") +``` + +### Options + +| Flag | Description | Default | +|------|-------------|---------| +| `--workspace ` | Target workspace | `$CMUX_WORKSPACE_ID` | +| `--surface ` | Source surface to split from | Focused surface | +| `--window ` | Target window | Current window | + +### Output + +``` +OK surface=surface:8 pane=pane:3 path=/absolute/path/to/file.md +``` + +With `--json`: + +```json +{ + "window_id": "...", + "workspace_id": "...", + "pane_id": "...", + "surface_id": "...", + "path": "/absolute/path/to/file.md" +} +``` + +## Path Resolution + +- Relative paths are resolved against the caller's current working directory. +- `~` is expanded to the home directory. +- The resolved absolute path is returned in the output. + +```bash +# These are equivalent when run from /Users/me/project +cmux markdown open plan.md +cmux markdown open ./plan.md +cmux markdown open /Users/me/project/plan.md +``` + +## Panel Behavior + +- The panel opens as a **horizontal split** to the right of the source surface. +- The tab title shows the filename (e.g., `plan.md`). +- The tab icon is a document icon. +- Content is **read-only** with text selection enabled. +- The file path is displayed as a breadcrumb at the top of the panel. + +## Session Persistence + +Markdown panels are saved and restored across sessions. On restore, the panel re-reads the file from disk. If the file no longer exists at restore time, the panel is not recreated. + +## Help + +```bash +cmux markdown --help +cmux markdown -h +``` + +See also: +- [live-reload.md](live-reload.md) diff --git a/skills/cmux-markdown/references/live-reload.md b/skills/cmux-markdown/references/live-reload.md new file mode 100644 index 00000000..ca0ba724 --- /dev/null +++ b/skills/cmux-markdown/references/live-reload.md @@ -0,0 +1,53 @@ +# Live Reload Behavior + +The markdown panel watches the file on disk and automatically re-renders when it changes. This enables real-time plan tracking as agents or editors update the file. + +## How It Works + +The panel uses a kernel-level file system watcher (`DispatchSource` with `O_EVTONLY`) that monitors the file for: + +- **Write events** -- content was modified in place +- **Extend events** -- content was appended +- **Delete events** -- file was removed (atomic replace step 1) +- **Rename events** -- file was moved or renamed + +## Supported Write Patterns + +| Pattern | Supported | Notes | +|---------|-----------|-------| +| Direct write (`echo >>`) | Yes | Triggers write/extend event | +| Editor save (vim, nano) | Yes | Most editors use atomic write (see below) | +| Atomic replace (write tmp + rename) | Yes | Handled via delete/rename recovery | +| `sed -i` | Yes | Uses atomic replace internally | +| VS Code / IDE save | Yes | Uses atomic replace | +| Agent progressive writes | Yes | Each write triggers a re-render | + +## Atomic File Replacement + +Many editors and tools write files atomically: write to a temporary file, then rename it over the original. This shows up as a **delete** event followed by a new file appearing at the same path. + +The panel handles this by: + +1. Detecting the delete/rename event +2. Attempting to re-read the file immediately (in case the rename already happened) +3. If the file is missing, wait 500 ms and check again (the new file may not yet be in place) +4. Reconnecting the file watcher to the new inode + +## File Unavailable State + +If the file is deleted and does not reappear within the retry window, the panel shows a "file unavailable" state with the original path. The panel does not close automatically -- the user must close it manually. + +If the file later reappears at the same path (e.g., the user recreates it), the panel does NOT automatically reconnect. Close and reopen the panel to pick up the new file. + +## Performance + +- Re-reads are dispatched to the main thread and run synchronously. +- Large files (100KB+) may cause brief UI hitches during re-render. For extremely large markdown files, consider splitting into smaller documents. +- The file watcher runs on a low-priority background queue and has negligible CPU impact. + +## Tips for Agents + +- **Write the full plan file first, then open it.** This avoids the panel showing a partially written file. +- **Append-style updates work well.** Adding sections to the end of a file triggers a smooth re-render. +- **Overwriting the entire file is fine.** The atomic replace handling ensures no data is lost. +- **Don't delete and recreate rapidly.** If writing a new version, prefer overwriting in place or using atomic replacement. diff --git a/skills/cmux/SKILL.md b/skills/cmux/SKILL.md index 336102d0..515315cc 100644 --- a/skills/cmux/SKILL.md +++ b/skills/cmux/SKILL.md @@ -51,3 +51,4 @@ cmux trigger-flash --surface surface:7 | [references/panes-surfaces.md](references/panes-surfaces.md) | Splits, surfaces, move/reorder, focus routing | | [references/trigger-flash-and-health.md](references/trigger-flash-and-health.md) | Flash cue and surface health checks | | [../cmux-browser/SKILL.md](../cmux-browser/SKILL.md) | Browser automation on surface-backed webviews | +| [../cmux-markdown/SKILL.md](../cmux-markdown/SKILL.md) | Markdown viewer panel with live file watching | diff --git a/tests/test_markdown_open_regressions.py b/tests/test_markdown_open_regressions.py new file mode 100644 index 00000000..88af9c96 --- /dev/null +++ b/tests/test_markdown_open_regressions.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Regression tests for markdown-open CLI parsing/help behavior.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def get_repo_root() -> Path: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode == 0: + return Path(result.stdout.strip()) + return Path.cwd() + + +def require(content: str, needle: str, message: str, failures: list[str]) -> None: + if needle not in content: + failures.append(message) + + +def main() -> int: + repo_root = get_repo_root() + cli_path = repo_root / "CLI" / "cmux.swift" + panel_path = repo_root / "Sources" / "Panels" / "MarkdownPanel.swift" + + if not cli_path.exists(): + print(f"FAIL: missing expected file: {cli_path}") + return 1 + if not panel_path.exists(): + print(f"FAIL: missing expected file: {panel_path}") + return 1 + + cli_content = cli_path.read_text(encoding="utf-8") + panel_content = panel_path.read_text(encoding="utf-8") + failures: list[str] = [] + + # CLI parser behavior. + require( + cli_content, + 'if let first = args.first, first.lowercased() == "open" {', + "markdown parser should explicitly support the 'open' subcommand", + failures, + ) + require( + cli_content, + "args.count == 1", + "markdown parser should accept single-arg shorthand path", + failures, + ) + require( + cli_content, + "args.count == 1, let first = args.first, !first.hasPrefix(\"-\")", + "markdown parser should reject option-like single args from shorthand path mode", + failures, + ) + require( + cli_content, + "let trailingArgs = Array(subArgs.dropFirst())", + "markdown parser should validate trailing arguments", + failures, + ) + require( + cli_content, + 'trailingArgs.first(where: { $0.hasPrefix("-") })', + "markdown parser should detect unknown trailing flags", + failures, + ) + require( + cli_content, + "markdown open: unexpected argument", + "markdown parser should error on unexpected trailing args", + failures, + ) + + # Help text should document shorthand and full index handle support. + require( + cli_content, + "Usage: cmux markdown open [options]\n cmux markdown (shorthand for 'open')", + "markdown subcommand help should include shorthand usage", + failures, + ) + require( + cli_content, + "--window Target window", + "markdown subcommand help should document window index handles", + failures, + ) + require( + cli_content, + "markdown [open] (open markdown file in formatted viewer panel with live reload)", + "top-level help should include markdown shorthand syntax", + failures, + ) + + # Session restore edge case: file missing at startup should still attempt reconnect. + require( + panel_content, + "if isFileUnavailable && fileWatchSource == nil {", + "MarkdownPanel should schedule reattach when watcher cannot start at init", + failures, + ) + require( + panel_content, + "scheduleReattach(attempt: 1)", + "MarkdownPanel should trigger reattach retries for missing files", + failures, + ) + + if failures: + print("FAIL: markdown-open regression(s) detected") + for failure in failures: + print(f"- {failure}") + return 1 + + print("PASS: markdown-open CLI/help/reattach regression checks are present") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())