Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 117 additions & 42 deletions CLI/cmux_open.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1324,17 +1324,23 @@ extension CMUXCLI {
}
let env = ProcessInfo.processInfo.environment
let baselineStorePath = CMUXAgentTurnDiffBaselineFile.path(env: env)
let record = try latestAgentTurnDiffBaseline(
if let record = try latestAgentTurnDiffBaseline(
repoRoot: repoRoot,
workspaceId: workspaceId,
surfaceId: surfaceId,
env: env
)
_ = try gitStdout(["cat-file", "-e", "\(record.baseCommit)^{tree}"], in: repoRoot)
patch = try joinedGitDiffPatches([
gitStdout(gitDiffPatchArguments([record.baseCommit, "--"]), in: repoRoot),
gitUntrackedPatchSinceBaseline(record: record, in: repoRoot, storePath: baselineStorePath)
])
) {
_ = try gitStdout(["cat-file", "-e", "\(record.baseCommit)^{tree}"], in: repoRoot)
patch = try joinedGitDiffPatches([
gitStdout(gitDiffPatchArguments([record.baseCommit, "--"]), in: repoRoot),
gitUntrackedPatchSinceBaseline(record: record, in: repoRoot, storePath: baselineStorePath)
])
} else {
// No last-turn baseline recorded yet: emit an empty patch so the
// viewer renders the friendly empty diff state (with the source
// switcher) instead of throwing a developer-facing CLI error.
patch = ""
}
sourceLabel = "git last-turn \(workspaceId) \(surfaceId)"
}
return DiffInput(
Expand Down Expand Up @@ -2301,23 +2307,26 @@ extension CMUXCLI {
"refs/cmux/last-turn/untracked/\(blob)"
}

/// Returns the most recent last-turn diff baseline recorded for the given
/// workspace/surface, or `nil` when no baseline has been recorded yet.
///
/// A missing baseline is not an error: it means there is simply nothing to
/// diff for the last turn, so callers render the friendly empty diff state
/// (with the source switcher) rather than surfacing a raw CLI error.
private func latestAgentTurnDiffBaseline(
repoRoot: String,
workspaceId: String,
surfaceId: String,
env: [String: String]
) throws -> CMUXAgentTurnDiffBaselineRecord {
) throws -> CMUXAgentTurnDiffBaselineRecord? {
let store = try readAgentTurnDiffBaselineStore(path: CMUXAgentTurnDiffBaselineFile.path(env: env))
let repoRoot = standardizedDiffSourcePath(repoRoot)
let candidates = store.records.filter { record in
standardizedDiffSourcePath(record.repoRoot) == repoRoot
&& diffScopeIdentifierEquals(record.workspaceId, workspaceId)
&& diffScopeIdentifierEquals(record.surfaceId, surfaceId)
}
guard let record = candidates.max(by: { $0.capturedAt < $1.capturedAt }) else {
throw CLIError(message: "No last-turn diff baseline recorded for this workspace and surface yet. Run another agent turn with cmux hooks active, or use --unstaged, --staged, or --branch.")
}
return record
return candidates.max(by: { $0.capturedAt < $1.capturedAt })
}

private func readAgentTurnDiffBaselineStore(path: String) throws -> CMUXAgentTurnDiffBaselineStore {
Expand Down Expand Up @@ -3189,26 +3198,39 @@ extension CMUXCLI {
}
var selectedContext = try sourceContext(for: selectedSource, repoRoot: repoRoot)
var selectedInput: DiffInput?
// When non-nil, the selected source has no changes: render the friendly,
// non-error empty diff state (with the source switcher) instead of failing.
var selectedEmptyMessage: String?
if !shouldDeferSelectedSource {
do {
selectedInput = try nonEmptyGitDiffInput(source: selectedSource, context: selectedContext)
} catch let error as EmptyDiffSourceError {
guard selectedSource != .lastTurn else {
throw CLIError(message: error.message)
}
var fallback: (source: DiffSource, context: DiffSourceContext, input: DiffInput)?
for candidate in DiffSource.allCases where candidate != selectedSource {
guard let candidateContext = try? sourceContext(for: candidate, repoRoot: repoRoot),
let candidateInput = try? nonEmptyGitDiffInput(source: candidate, context: candidateContext) else {
continue
if selectedSource == .lastTurn {
// Last turn is the user's explicit intent, so never silently
// switch sources; show its empty state and keep the switcher.
selectedEmptyMessage = error.message
selectedInput = nil
} else {
var fallback: (source: DiffSource, context: DiffSourceContext, input: DiffInput)?
for candidate in DiffSource.allCases where candidate != selectedSource {
guard let candidateContext = try? sourceContext(for: candidate, repoRoot: repoRoot),
let candidateInput = try? nonEmptyGitDiffInput(source: candidate, context: candidateContext) else {
continue
}
fallback = (candidate, candidateContext, candidateInput)
break
}
if let fallback {
selectedSource = fallback.source
selectedContext = fallback.context
selectedInput = fallback.input
} else {
// Every source is empty: show the originally selected
// source's empty state rather than a raw error.
selectedEmptyMessage = error.message
selectedInput = nil
}
fallback = (candidate, candidateContext, candidateInput)
break
}
guard let fallback else { throw CLIError(message: error.message) }
selectedSource = fallback.source
selectedContext = fallback.context
selectedInput = fallback.input
}
}
let fileURLs = Dictionary(uniqueKeysWithValues: DiffSource.allCases.map { source in
Expand Down Expand Up @@ -3504,6 +3526,24 @@ extension CMUXCLI {
repoRoot: repoRoot,
branchBaseRef: selectedSource == .branch ? selectedContext.branchBaseRef : nil
)
} else if let selectedEmptyMessage {
// Friendly, non-error empty diff state: the panel shows plain-language
// text plus the source switcher so the user can pick another diff.
try writeDiffViewerStatusHTML(
to: selectedFileURL,
title: titleOverride ?? selectedSource.title,
sourceLabel: "git \(selectedSource.slug)",
message: selectedEmptyMessage,
isError: false,
pollForReplacement: false,
layout: layout,
appearance: appearance,
sourceOptions: sourceOptions,
repoOptions: selectedRepoOptions,
baseOptions: selectedSource == .branch ? baseOptions : [],
repoRoot: repoRoot,
branchBaseRef: selectedSource == .branch ? selectedContext.branchBaseRef : nil
)
}
let assets = try ensureDiffViewerAssets(nextTo: selectedFileURL)
let pageURLs = [selectedFileURL] + deferredPages.map(\.url)
Expand Down Expand Up @@ -3674,21 +3714,10 @@ extension CMUXCLI {
baseOptions: fallback.baseOptions,
sourceSet: sourceSet
)
try? writeDiffViewerStatusHTML(
to: page.url,
title: page.titleOverride ?? page.source.title,
sourceLabel: "git \(page.source.slug)",
message: error.message,
isError: true,
pollForReplacement: false,
layout: sourceSet.layout,
appearance: sourceSet.appearance,
sourceOptions: page.sourceOptions,
repoOptions: page.repoOptions,
baseOptions: page.baseOptions,
repoRoot: page.context.repoRoot,
branchBaseRef: page.source == .branch ? page.context.branchBaseRef : nil
)
// The originally selected source is empty; leave its own page as
// a friendly empty state so switching back to it never shows a
// raw error.
writeDeferredDiffViewerEmptyState(message: error.message, page: page, sourceSet: sourceSet)
completion.completedPageURLs.insert(page.url)
return completion
} catch is EmptyDiffSourceError {
Expand All @@ -3697,10 +3726,56 @@ extension CMUXCLI {
throw fallbackError
}
}
throw error
// No source has changes: render the selected source's friendly empty state.
return writeDeferredDiffViewerEmptyState(message: error.message, page: page, sourceSet: sourceSet)
} catch let error as EmptyDiffSourceError {
// Sources that never fall back (last turn) still render their own
// friendly empty state rather than surfacing a developer-facing error.
return writeDeferredDiffViewerEmptyState(message: error.message, page: page, sourceSet: sourceSet)
}
}

/// Writes the friendly, non-error empty diff state for a deferred source page
/// and returns a completion describing the (empty) result.
///
/// Used when a source has no changes to show: the panel renders plain-language
/// text plus the source switcher instead of a raw error, and the CLI exits
/// successfully so the launcher never emits an error beep.
@discardableResult
private func writeDeferredDiffViewerEmptyState(
message: String,
page: DiffViewerDeferredSourcePage,
sourceSet: DiffViewerDeferredSourceSet
) -> DiffViewerDeferredCompletion {
try? writeDiffViewerStatusHTML(
to: page.url,
title: page.titleOverride ?? page.source.title,
sourceLabel: "git \(page.source.slug)",
message: message,
isError: false,
pollForReplacement: false,
layout: sourceSet.layout,
appearance: sourceSet.appearance,
sourceOptions: page.sourceOptions,
repoOptions: page.repoOptions,
baseOptions: page.source == .branch ? page.baseOptions : [],
repoRoot: page.context.repoRoot,
branchBaseRef: page.source == .branch ? page.context.branchBaseRef : nil
)
return DiffViewerDeferredCompletion(
input: DiffInput(
patch: "",
sourceLabel: "git \(page.source.slug)",
defaultTitle: page.titleOverride ?? page.source.title,
emptyMessage: message,
externalURL: nil
),
fileURL: page.url,
viewerURL: page.viewerURL,
completedPageURLs: [page.url]
)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

private func writeDeferredDiffViewerSource(
page: DiffViewerDeferredSourcePage,
source: DiffSource,
Expand Down
10 changes: 10 additions & 0 deletions Sources/Panels/CmuxWebView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -798,6 +798,16 @@ final class CmuxWebView: WKWebView {
super.keyDown(with: event)
}

// Prevents NSBeep when an action selector reaches this view with no handler.
// A status-only diff/web page (e.g. the empty diff state) has no focusable or
// editable content, so a click or key command can fall through to the responder
// chain; the default `doCommand(by:)` would emit the system "unable to act" beep.
// WebKit routes editable/selectable interactions through its internal content
// view, so an empty override here only suppresses genuinely unhandled actions.
override func doCommand(by selector: Selector) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
// Intentionally empty - prevents the system beep on unhandled commands.
}
Comment thread
austinywang marked this conversation as resolved.
Outdated

// MARK: - Focus on click

// The SwiftUI Color.clear overlay (.onTapGesture) that focuses panes can't receive
Expand Down
Loading
Loading