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 <path> 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>
This commit is contained in:
Ismail Pelaseyed 2026-03-05 02:48:28 +01:00 committed by GitHub
parent 6f210dd2c7
commit d72b014d6d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1292 additions and 2 deletions

View file

@ -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 <path> [--workspace <id|ref|index>] [--surface <id|ref|index>] [--window <id|ref|index>]"
)
} 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 <path>")
} else {
subArgs = []
}
}
guard let rawPath = subArgs.first, !rawPath.isEmpty else {
throw CLIError(message: "markdown open requires a file path. Usage: cmux markdown open <path>")
}
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 <path> [--workspace <id|ref|index>] [--surface <id|ref|index>] [--window <id|ref|index>]"
)
}
if let extraArg = trailingArgs.first {
throw CLIError(
message:
"markdown open: unexpected argument '\(extraArg)'. Usage: cmux markdown open <path> [--workspace <id|ref|index>] [--surface <id|ref|index>] [--window <id|ref|index>]"
)
}
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 <path> [options]
cmux markdown <path> (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 <id|ref|index> Target workspace (default: $CMUX_WORKSPACE_ID)
--surface <id|ref|index> Source surface to split from (default: focused surface)
--window <id|ref|index> 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 <id|ref>] [--surface <id|ref>] [--command <cmd>]
display-message [-p|--print] <text>
markdown [open] <path> (open markdown file in formatted viewer panel with live reload)
browser [--surface <id|ref|index> | <surface>] <subcommand> ...
browser open [url] (create browser split in caller's workspace; if surface supplied, behaves like navigate)
browser open-split [url]

View file

@ -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 = "<group>"; };
A5001414 /* BrowserPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanelView.swift; sourceTree = "<group>"; };
A5001415 /* PanelContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/PanelContentView.swift; sourceTree = "<group>"; };
A5001418 /* MarkdownPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownPanel.swift; sourceTree = "<group>"; };
A5001419 /* MarkdownPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownPanelView.swift; sourceTree = "<group>"; };
A5001416 /* Workspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Workspace.swift; sourceTree = "<group>"; };
A5001417 /* WorkspaceContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentView.swift; sourceTree = "<group>"; };
A5001090 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
@ -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 */

View file

@ -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

View file

@ -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()
}
}

View file

@ -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)
}
}
}

View file

@ -5,6 +5,7 @@ import Combine
public enum PanelType: String, Codable, Sendable {
case terminal
case browser
case markdown
}
enum FocusFlashCurve: Equatable {

View file

@ -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
)
}
}
}
}

View file

@ -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 {

View file

@ -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)

View file

@ -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 {

View file

@ -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.

View file

@ -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 <path>', and the panel auto-updates when the file changes."

View file

@ -0,0 +1,69 @@
# Command Reference (cmux Markdown)
## Opening a Markdown Panel
```bash
cmux markdown open <path>
cmux markdown <path> # shorthand (implicit "open")
```
### Options
| Flag | Description | Default |
|------|-------------|---------|
| `--workspace <id\|ref\|index>` | Target workspace | `$CMUX_WORKSPACE_ID` |
| `--surface <id\|ref\|index>` | Source surface to split from | Focused surface |
| `--window <id\|ref>` | 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)

View file

@ -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.

View file

@ -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 |

View file

@ -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 <path> [options]\n cmux markdown <path> (shorthand for 'open')",
"markdown subcommand help should include shorthand usage",
failures,
)
require(
cli_content,
"--window <id|ref|index> Target window",
"markdown subcommand help should document window index handles",
failures,
)
require(
cli_content,
"markdown [open] <path> (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())