From d72b014d6de73abcb8a48f2d4ad0e708d33281c9 Mon Sep 17 00:00:00 2001 From: Ismail Pelaseyed Date: Thu, 5 Mar 2026 02:48:28 +0100 Subject: [PATCH] feat: add markdown viewer panel with live file watching (#883) * Add markdown viewer panel with live file watching Introduce a new PanelType.markdown that renders .md files in a dedicated panel using MarkdownUI (SwiftUI), with live file watching via DispatchSource so content auto-updates when the file changes on disk. - New MarkdownPanel class with file system watcher (write/delete/rename/extend) - New MarkdownPanelView with custom cmux theme (headings, code blocks, tables, blockquotes, inline code, lists, horizontal rules, light/dark mode) - Full workspace integration: SurfaceKind, creation methods, tab subscription - Session persistence: snapshot/restore across app restarts - V2 socket command: markdown.open (validates path, resolves workspace, splits) - CLI command: cmux markdown open with routing flags and help text - Agent skill: skills/cmux-markdown/ with SKILL.md, openai.yaml, and references - Cross-link from skills/cmux/SKILL.md to the new markdown skill - SPM dependency: gonzalezreal/swift-markdown-ui 2.4.1 * Fix unreachable guard in markdown subcommand dispatch Use looksLikePath() to distinguish subcommands from path arguments so the guard can catch unknown subcommands and future subcommands are parsed correctly. * Use .isoLatin1 fallback instead of .ascii for encoding recovery ASCII is a strict subset of UTF-8, so falling back to .ascii after UTF-8 fails is dead code. Use .isoLatin1 which accepts all 256 byte values and covers legacy encodings like Windows-1252. * Mark fileWatchSource as nonisolated(unsafe) for deinit safety deinit is not guaranteed to run on the main actor, so accessing @MainActor-isolated storage is a data race under strict concurrency. DispatchSource.cancel() is thread-safe, so nonisolated(unsafe) is sufficient with a documented invariant that writes only occur on main. * Fix file watcher reattach: retry loop with cancellation guard - Replace one-shot 500ms retry with up to 6 attempts (3s total window) so files that reappear after a slow atomic replace are picked up - Add isClosed flag checked before each retry to prevent restarting the watcher after close()/deinit * Harden path validation in markdown.open command Reject directories and non-absolute paths before panel creation to prevent ambiguous behavior and generic downstream failures. * Always reattach file watcher on delete/rename events After an atomic save (delete old + create new), the DispatchSource still points to the old inode. Previously we only reattached when the file was unreadable, so successful atomic saves left the watcher on a stale inode and live updates silently stopped. Now we always stop and reattach: immediately if the new file is readable, via retry loop if not. * Restore markdown panels even when file is missing at launch MarkdownPanel already handles unavailable files gracefully (shows 'file unavailable' UI and retries via the reattach loop). Dropping the panel on restore lost the user's layout for files that may reappear shortly after (network drives, build artifacts, etc.). * Harden markdown CLI parsing and startup reconnect behavior --------- Co-authored-by: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> --- CLI/cmux.swift | 115 +++++++ GhosttyTabs.xcodeproj/project.pbxproj | 25 ++ .../xcshareddata/swiftpm/Package.resolved | 29 +- Sources/Panels/MarkdownPanel.swift | 182 +++++++++++ Sources/Panels/MarkdownPanelView.swift | 285 ++++++++++++++++++ Sources/Panels/Panel.swift | 1 + Sources/Panels/PanelContentView.swift | 10 + Sources/SessionPersistence.swift | 5 + Sources/TerminalController.swift | 95 ++++++ Sources/Workspace.swift | 169 ++++++++++- skills/cmux-markdown/SKILL.md | 125 ++++++++ skills/cmux-markdown/agents/openai.yaml | 4 + skills/cmux-markdown/references/commands.md | 69 +++++ .../cmux-markdown/references/live-reload.md | 53 ++++ skills/cmux/SKILL.md | 1 + tests/test_markdown_open_regressions.py | 126 ++++++++ 16 files changed, 1292 insertions(+), 2 deletions(-) create mode 100644 Sources/Panels/MarkdownPanel.swift create mode 100644 Sources/Panels/MarkdownPanelView.swift create mode 100644 skills/cmux-markdown/SKILL.md create mode 100644 skills/cmux-markdown/agents/openai.yaml create mode 100644 skills/cmux-markdown/references/commands.md create mode 100644 skills/cmux-markdown/references/live-reload.md create mode 100644 tests/test_markdown_open_regressions.py 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())