Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,16 @@ public struct TerminalCatalogSection: SettingCatalogSection {
userDefaultsKey: "terminal.reflowHardWrapOnCopy"
)

/// Whether a paste Ghostty flags as unsafe asks for confirmation in a
/// sheet on the terminal's window. Off by default: cmux has always
/// approved these pastes without asking. Ghostty's
/// `clipboard-paste-protection` decides which pastes are unsafe.
public let confirmUnsafePaste = DefaultsKey<Bool>(
id: "terminal.confirmUnsafePaste",
defaultValue: false,
userDefaultsKey: "terminal.confirmUnsafePaste"
)

/// Whether macOS text-editing gestures are replayed as their line-editor
/// equivalents: Command and Option arrow motion, and the Command and Option
/// deletion chords. Off by default, because the mode claims chords the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,10 @@ struct SettingCatalogTests {
#expect(!SettingCatalog().terminal.reflowHardWrapOnCopy.defaultValue)
}

@Test func unsafePasteConfirmationDefaultsOff() {
#expect(!SettingCatalog().terminal.confirmUnsafePaste.defaultValue)
}

@Test func keyIdsMatchTheirSectionPrefix() {
// Each key's dotted id must start with its section's prefix; this is
// the convention that lets the JSON store use `id` as the JSON path.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,14 @@ extension Array where Element == CuratedSettingEntry {
paths: ["terminal.reflowHardWrapOnCopy"],
synonyms: "terminal.reflowHardWrapOnCopy reflow hard wrap copy soft wrap line breaks newlines paste"
),
.init(
section: .terminal,
id: "confirm-unsafe-paste",
title: String(localized: "settings.terminal.confirmUnsafePaste", defaultValue: "Confirm Unsafe Pastes"),
detailText: String(localized: "settings.terminal.confirmUnsafePaste.subtitleOff", defaultValue: "Pastes Ghostty flags as unsafe go through without asking."),
paths: ["terminal.confirmUnsafePaste"],
synonyms: "terminal.confirmUnsafePaste confirm unsafe paste protection warning multi-line newline clipboard sheet"
),
.init(
section: .terminal,
id: "password-input-indicator",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public struct TerminalSection: View {
@State private var scrollBar: DefaultsValueModel<Bool>
@State private var copyOnSelect: DefaultsValueModel<Bool>
@State private var reflowHardWrapOnCopy: DefaultsValueModel<Bool>
@State private var confirmUnsafePaste: DefaultsValueModel<Bool>
@State private var textEditingGestures: DefaultsValueModel<Bool>
@State private var passwordInputIndicator: DefaultsValueModel<Bool>
@State private var passwordInputDots: DefaultsValueModel<Bool>
Expand Down Expand Up @@ -55,6 +56,7 @@ public struct TerminalSection: View {
_scrollBar = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.showScrollBar))
_copyOnSelect = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.copyOnSelect))
_reflowHardWrapOnCopy = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.reflowHardWrapOnCopy))
_confirmUnsafePaste = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.confirmUnsafePaste))
_textEditingGestures = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.textEditingGestures))
_passwordInputIndicator = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.showPasswordInputIndicator))
_passwordInputDots = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.showPasswordInputDots))
Expand Down Expand Up @@ -94,6 +96,7 @@ public struct TerminalSection: View {
scrollBar,
copyOnSelect,
reflowHardWrapOnCopy,
confirmUnsafePaste,
textEditingGestures,
passwordInputIndicator,
passwordInputDots,
Expand Down Expand Up @@ -448,6 +451,19 @@ public struct TerminalSection: View {
.accessibilityIdentifier("SettingsTerminalReflowHardWrapOnCopyToggle")
}
SettingsCardDivider()
SettingsCardRow(
configurationReview: .json("terminal.confirmUnsafePaste"),
String(localized: "settings.terminal.confirmUnsafePaste", defaultValue: "Confirm Unsafe Pastes"),
subtitle: confirmUnsafePaste.current
? String(localized: "settings.terminal.confirmUnsafePaste.subtitleOn", defaultValue: "A paste Ghostty flags as unsafe, such as several lines into a program without bracketed paste, waits for you to confirm it in a sheet on the window.")
: String(localized: "settings.terminal.confirmUnsafePaste.subtitleOff", defaultValue: "Pastes Ghostty flags as unsafe go through without asking.")
) {
Toggle("", isOn: Binding(get: { confirmUnsafePaste.current }, set: { confirmUnsafePaste.set($0) }))
.labelsHidden()
.controlSize(.small)
.accessibilityIdentifier("SettingsTerminalConfirmUnsafePasteToggle")
}
SettingsCardDivider()
SettingsCardRow(
configurationReview: .json("terminal.textEditingGestures"),
String(localized: "settings.terminal.textEditingGestures", defaultValue: "Text Editing Gestures"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ struct SettingsRowAnchorResolutionTests {
"terminal.autoResumeAgentSessions",
"terminal.copyOnSelect",
"terminal.reflowHardWrapOnCopy",
"terminal.confirmUnsafePaste",
"terminal.showPasswordInputIndicator",
"terminal.showPasswordInputDots",
"terminal.textEditingGestures",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/// Decides how cmux answers Ghostty's clipboard confirmation callback.
///
/// Ghostty asks for confirmation in two cases: a paste its
/// `clipboard-paste-protection` flags as unsafe, and an OSC 52 clipboard read
/// under `clipboard-read = ask`. cmux approves both without asking unless
/// `terminal.confirmUnsafePaste` is on, and that setting only covers pastes.
public struct TerminalUnsafePasteConfirmationPolicy: Equatable, Sendable {
/// How to answer one confirmation request.
public enum Decision: Equatable, Sendable {
/// Complete the request as confirmed, without asking.
case approve
/// Ask in a sheet attached to the terminal's window.
case askInWindowSheet
/// Complete the request with no text.
case reject
}

/// The `terminal.confirmUnsafePaste` setting.
public var confirmationEnabled: Bool

/// The number of lines the sheet preview keeps.
public var maximumPreviewLines: Int

/// The number of characters the sheet preview keeps per line.
public var maximumPreviewLineLength: Int

/// Creates a policy.
///
/// - Parameters:
/// - confirmationEnabled: The `terminal.confirmUnsafePaste` setting.
/// - maximumPreviewLines: The number of lines the preview keeps.
/// - maximumPreviewLineLength: The number of characters the preview
/// keeps per line.
public init(
confirmationEnabled: Bool,
maximumPreviewLines: Int = 8,
maximumPreviewLineLength: Int = 160
) {
self.confirmationEnabled = confirmationEnabled
self.maximumPreviewLines = max(1, maximumPreviewLines)
self.maximumPreviewLineLength = max(1, maximumPreviewLineLength)
}

/// Returns how to answer a confirmation request.
///
/// - Parameters:
/// - isPasteRequest: Whether Ghostty flagged a paste, as opposed to an
/// OSC 52 read.
/// - hasWindow: Whether the terminal view is in a window a sheet can
/// attach to.
/// - Returns: The decision. A paste the user asked to confirm is rejected
/// when there is no window to ask in, rather than pasted unasked.
public func decision(isPasteRequest: Bool, hasWindow: Bool) -> Decision {
guard isPasteRequest, confirmationEnabled else { return .approve }
return hasWindow ? .askInWindowSheet : .reject
}

/// A bounded excerpt of the pasted text for the confirmation sheet.
///
/// Keeps at most ``maximumPreviewLines`` lines and
/// ``maximumPreviewLineLength`` characters per line, marking each cut
/// with an ellipsis, so a large paste cannot grow the sheet past the
/// window.
///
/// - Parameter text: The text waiting to be pasted.
/// - Returns: The excerpt, with line breaks normalized to `\n`.
public func preview(of text: String) -> String {
let lines = text.split(
omittingEmptySubsequences: false,
whereSeparator: \.isNewline
)
var kept = lines.prefix(maximumPreviewLines).map { line -> String in
guard line.count > maximumPreviewLineLength else { return String(line) }
return String(line.prefix(maximumPreviewLineLength)) + "…"
}
if lines.count > maximumPreviewLines {
kept.append("…")
}
return kept.joined(separator: "\n")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import CmuxTerminalCore
import Testing

@Suite
struct TerminalUnsafePasteConfirmationPolicyTests {
@Test func approvesUnsafePastesWhenTheSettingIsOff() {
let policy = TerminalUnsafePasteConfirmationPolicy(confirmationEnabled: false)
for hasWindow in [true, false] {
#expect(policy.decision(isPasteRequest: true, hasWindow: hasWindow) == .approve)
}
}

@Test func asksInAWindowSheetWhenTheSettingIsOn() {
let policy = TerminalUnsafePasteConfirmationPolicy(confirmationEnabled: true)
#expect(policy.decision(isPasteRequest: true, hasWindow: true) == .askInWindowSheet)
}

@Test func rejectsAPasteItCannotAskAboutWhenTheSettingIsOn() {
let policy = TerminalUnsafePasteConfirmationPolicy(confirmationEnabled: true)
#expect(policy.decision(isPasteRequest: true, hasWindow: false) == .reject)
}

@Test func leavesOSC52ReadsApprovedWhateverTheSetting() {
for enabled in [true, false] {
let policy = TerminalUnsafePasteConfirmationPolicy(confirmationEnabled: enabled)
for hasWindow in [true, false] {
#expect(policy.decision(isPasteRequest: false, hasWindow: hasWindow) == .approve)
}
}
}

@Test func previewKeepsShortTextUnchanged() {
let policy = TerminalUnsafePasteConfirmationPolicy(confirmationEnabled: true)
#expect(policy.preview(of: "echo one\necho two") == "echo one\necho two")
}

@Test func previewCutsExtraLinesAndLongLines() {
let text = (1...5).map { "line \($0)" }.joined(separator: "\n")
let fewLines = TerminalUnsafePasteConfirmationPolicy(
confirmationEnabled: true,
maximumPreviewLines: 3
)
#expect(fewLines.preview(of: text) == "line 1\nline 2\nline 3\n…")
let shortLines = TerminalUnsafePasteConfirmationPolicy(
confirmationEnabled: true,
maximumPreviewLineLength: 4
)
#expect(shortLines.preview(of: "abcdefghij") == "abcd…")
}

@Test func previewNormalizesCarriageReturnLineBreaks() {
let policy = TerminalUnsafePasteConfirmationPolicy(confirmationEnabled: true)
#expect(policy.preview(of: "a\r\nb\rc") == "a\nb\nc")
}
}
Loading
Loading