diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CustomToolbarActionEditorView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CustomToolbarActionEditorView.swift index b4613eaf08c5..fbeadf7d31f3 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CustomToolbarActionEditorView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CustomToolbarActionEditorView.swift @@ -5,11 +5,9 @@ import SwiftUI /// Create or edit a user-defined terminal toolbar action. /// -/// A custom action sends literal text when its bar button is tapped — a command -/// like `claude --dangerously-skip-permissions`, a snippet, or any keystrokes. -/// The "Run after typing" toggle appends a Return so the action submits a -/// command instead of only typing it. Saving hands a ``CustomToolbarAction`` -/// back to the caller, which persists it through ``TerminalAccessoryConfiguration``. +/// A custom action can send literal text, one modified terminal key, or a +/// multi-step macro. Saving hands a ``CustomToolbarAction`` back to the caller, +/// which persists it through ``TerminalAccessoryConfiguration``. struct CustomToolbarActionEditorView: View { @Environment(\.dismiss) private var dismiss @@ -17,8 +15,12 @@ struct CustomToolbarActionEditorView: View { private let onSave: (CustomToolbarAction) -> Void @State private var title: String + @State private var mode: ToolbarActionEditorMode @State private var commandText: String @State private var runAfterTyping: Bool + @State private var keyModifiers: TerminalKeyModifier + @State private var selectedKey: TerminalSpecialKey + @State private var macroSteps: [ToolbarMacroStepDraft] /// Creates the editor. /// - Parameters: @@ -29,8 +31,12 @@ struct CustomToolbarActionEditorView: View { self.onSave = onSave let seed = Self.seed(from: action) _title = State(initialValue: seed.title) + _mode = State(initialValue: seed.mode) _commandText = State(initialValue: seed.text) _runAfterTyping = State(initialValue: seed.runAfterTyping) + _keyModifiers = State(initialValue: seed.keyModifiers) + _selectedKey = State(initialValue: seed.selectedKey) + _macroSteps = State(initialValue: seed.macroSteps) } var body: some View { @@ -53,28 +59,26 @@ struct CustomToolbarActionEditorView: View { } Section { - TextField( - L10n.string("mobile.toolbar.editor.commandPlaceholder", defaultValue: "claude --dangerously-skip-permissions"), - text: $commandText, - axis: .vertical - ) - .lineLimit(1...6) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) - .font(.system(.body, design: .monospaced)) - .accessibilityIdentifier("CustomActionCommandField") - - Toggle(isOn: $runAfterTyping) { - Text(L10n.string("mobile.toolbar.editor.runAfterTyping", defaultValue: "Run after typing")) + Picker( + L10n.string("mobile.toolbar.editor.typeHeader", defaultValue: "Action Type"), + selection: $mode + ) { + ForEach(ToolbarActionEditorMode.allCases) { mode in + Text(mode.title).tag(mode) + } } - .accessibilityIdentifier("CustomActionRunToggle") + .pickerStyle(.segmented) } header: { - Text(L10n.string("mobile.toolbar.editor.commandHeader", defaultValue: "Sends")) - } footer: { - Text(L10n.string( - "mobile.toolbar.editor.commandFooter", - defaultValue: "The text typed into the terminal when tapped. Turn on Run after typing to press Return automatically." - )) + Text(L10n.string("mobile.toolbar.editor.typeHeader", defaultValue: "Action Type")) + } + + switch mode { + case .text: + textSection + case .keyCombo: + keyComboSection + case .macro: + macroSection } } .navigationTitle(navigationTitle) @@ -97,6 +101,141 @@ struct CustomToolbarActionEditorView: View { } } + private var textSection: some View { + Section { + TextField( + L10n.string("mobile.toolbar.editor.commandPlaceholder", defaultValue: "claude --dangerously-skip-permissions"), + text: $commandText, + axis: .vertical + ) + .lineLimit(1...6) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .font(.system(.body, design: .monospaced)) + .accessibilityIdentifier("CustomActionCommandField") + + Toggle(isOn: $runAfterTyping) { + Text(L10n.string("mobile.toolbar.editor.runAfterTyping", defaultValue: "Run after typing")) + } + .accessibilityIdentifier("CustomActionRunToggle") + } header: { + Text(L10n.string("mobile.toolbar.editor.commandHeader", defaultValue: "Sends")) + } footer: { + Text(L10n.string( + "mobile.toolbar.editor.commandFooter", + defaultValue: "The text typed into the terminal when tapped. Turn on Run after typing to press Return automatically." + )) + } + } + + private var keyComboSection: some View { + Section { + ToolbarKeyComboFields(modifiers: $keyModifiers, key: $selectedKey) + .accessibilityIdentifier("CustomActionKeyComboFields") + + if keyComboPayload == nil { + Text(L10n.string( + "mobile.toolbar.editor.unsupportedCombo", + defaultValue: "This key combo is not supported yet." + )) + .font(.footnote) + .foregroundStyle(.secondary) + } + } header: { + Text(L10n.string("mobile.toolbar.editor.keyComboHeader", defaultValue: "Key Combo")) + } footer: { + Text(L10n.string( + "mobile.toolbar.editor.keyComboFooter", + defaultValue: "Sends one modified terminal key, such as Shift-Tab or Option-Left." + )) + } + } + + @ViewBuilder private var macroSection: some View { + // Iterate over element bindings (keyed by `ToolbarMacroStepDraft.id`) rather + // than index-backed `$macroSteps[index]`. A row can delete itself, and an + // index captured in a binding goes stale the moment the array shrinks — + // SwiftUI can then re-read it during the Form diff and crash with an + // index-out-of-range. Element bindings stay valid across removals, and we + // delete/number by id looked up at render or action time. + ForEach($macroSteps) { $step in + let index = macroSteps.firstIndex { $0.id == step.id } ?? 0 + Section { + ToolbarMacroStepEditor(step: $step) + + Button(role: .destructive) { + macroSteps.removeAll { $0.id == step.id } + } label: { + Label( + L10n.string("mobile.toolbar.editor.deleteStep", defaultValue: "Delete Step"), + systemImage: "trash" + ) + } + .accessibilityIdentifier("CustomActionDeleteStepButton-\(index + 1)") + } header: { + VStack(alignment: .leading, spacing: 2) { + if index == macroSteps.startIndex { + Text(L10n.string("mobile.toolbar.editor.macroHeader", defaultValue: "Macro Steps")) + } + Text(macroStepTitle(index: index + 1)) + } + } + } + + Section { + Button { + macroSteps.append(.textStep()) + } label: { + Label( + L10n.string("mobile.toolbar.editor.addTextStep", defaultValue: "Add Text Step"), + systemImage: "text.cursor" + ) + } + .accessibilityIdentifier("CustomActionAddTextStepButton") + + Button { + macroSteps.append(.keyComboStep()) + } label: { + Label( + L10n.string("mobile.toolbar.editor.addKeyStep", defaultValue: "Add Key Step"), + systemImage: "keyboard" + ) + } + .accessibilityIdentifier("CustomActionAddKeyStepButton") + } header: { + Text(L10n.string("mobile.toolbar.editor.addStepHeader", defaultValue: "Add Step")) + } footer: { + Text(L10n.string( + "mobile.toolbar.editor.macroFooter", + defaultValue: "Runs each step in order. Text steps type exactly what you enter; key steps send one key combo." + )) + } + } + + private var keyComboPayload: ToolbarActionPayload? { + let payload = ToolbarActionPayload.keyCombo(modifiers: keyModifiers, key: selectedKey) + guard payload.output != nil else { return nil } + return payload + } + + private var macroPayload: ToolbarActionPayload? { + let steps = macroSteps.compactMap(\.macroStep) + guard !steps.isEmpty, steps.count == macroSteps.count else { return nil } + return .macro(steps) + } + + private var payload: ToolbarActionPayload? { + switch mode { + case .text: + guard !commandText.isEmpty else { return nil } + return .text(runAfterTyping ? commandText + "\n" : commandText) + case .keyCombo: + return keyComboPayload + case .macro: + return macroPayload + } + } + private var navigationTitle: String { existing == nil ? L10n.string("mobile.toolbar.editor.addTitle", defaultValue: "Add Action") @@ -108,17 +247,21 @@ struct CustomToolbarActionEditorView: View { } private var isValid: Bool { - !trimmedTitle.isEmpty && !commandText.isEmpty + !trimmedTitle.isEmpty && payload != nil + } + + private func macroStepTitle(index: Int) -> String { + let format = L10n.string("mobile.toolbar.editor.stepHeaderFormat", defaultValue: "Step %d") + return String.localizedStringWithFormat(format, index) } private func save() { - guard isValid else { return } - let text = runAfterTyping ? commandText + "\n" : commandText + guard let payload, isValid else { return } let action = CustomToolbarAction( id: existing?.id ?? UUID(), title: trimmedTitle, symbolName: nil, - payload: .text(text) + payload: payload ) onSave(action) dismiss() @@ -126,14 +269,61 @@ struct CustomToolbarActionEditorView: View { private static func seed( from action: CustomToolbarAction? - ) -> (title: String, text: String, runAfterTyping: Bool) { - guard let action, case let .text(stored) = action.payload else { - return (action?.title ?? "", "", true) + ) -> ( + mode: ToolbarActionEditorMode, + title: String, + text: String, + runAfterTyping: Bool, + keyModifiers: TerminalKeyModifier, + selectedKey: TerminalSpecialKey, + macroSteps: [ToolbarMacroStepDraft] + ) { + guard let action else { + return ( + .text, + "", + "", + true, + [.shift], + .tab, + [.keyComboStep()] + ) } - if stored.hasSuffix("\n") { - return (action.title, String(stored.dropLast()), true) + + switch action.payload { + case let .text(stored): + let seed = ToolbarMacroStepDraft.textSeed(from: stored) + return ( + .text, + action.title, + seed.text, + seed.runAfterTyping, + [.shift], + .tab, + [.keyComboStep()] + ) + case let .keyCombo(modifiers, key): + return ( + .keyCombo, + action.title, + "", + true, + modifiers, + key, + [.keyComboStep()] + ) + case let .macro(steps): + let drafts = steps.map(ToolbarMacroStepDraft.init(step:)) + return ( + .macro, + action.title, + "", + true, + [.shift], + .tab, + drafts.isEmpty ? [.keyComboStep()] : drafts + ) } - return (action.title, stored, false) } } #endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalSpecialKey+ToolbarEditor.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalSpecialKey+ToolbarEditor.swift new file mode 100644 index 000000000000..4331bed1afe2 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalSpecialKey+ToolbarEditor.swift @@ -0,0 +1,47 @@ +#if os(iOS) +import CmuxMobileSupport +import CmuxMobileTerminalKit + +extension TerminalSpecialKey { + static let toolbarEditorOptions: [TerminalSpecialKey] = [ + .escape, + .tab, + .upArrow, + .downArrow, + .leftArrow, + .rightArrow, + .home, + .end, + .pageUp, + .pageDown, + .delete, + ] + + var toolbarEditorDisplayName: String { + switch self { + case .escape: + L10n.string("terminal.shortcut.name.escape", defaultValue: "Escape") + case .tab: + L10n.string("terminal.shortcut.name.tab", defaultValue: "Tab") + case .upArrow: + L10n.string("terminal.shortcut.name.upArrow", defaultValue: "Up Arrow") + case .downArrow: + L10n.string("terminal.shortcut.name.downArrow", defaultValue: "Down Arrow") + case .leftArrow: + L10n.string("terminal.shortcut.name.leftArrow", defaultValue: "Left Arrow") + case .rightArrow: + L10n.string("terminal.shortcut.name.rightArrow", defaultValue: "Right Arrow") + case .home: + L10n.string("terminal.shortcut.name.home", defaultValue: "Home") + case .end: + L10n.string("terminal.shortcut.name.end", defaultValue: "End") + case .pageUp: + L10n.string("terminal.shortcut.name.pageUp", defaultValue: "Page Up") + case .pageDown: + L10n.string("terminal.shortcut.name.pageDown", defaultValue: "Page Down") + case .delete: + L10n.string("terminal.shortcut.name.delete", defaultValue: "Delete") + } + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ToolbarActionEditorMode.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ToolbarActionEditorMode.swift new file mode 100644 index 000000000000..ec7b695cc08d --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ToolbarActionEditorMode.swift @@ -0,0 +1,22 @@ +#if os(iOS) +import CmuxMobileSupport + +enum ToolbarActionEditorMode: String, CaseIterable, Identifiable { + case text + case keyCombo + case macro + + var id: String { rawValue } + + var title: String { + switch self { + case .text: + L10n.string("mobile.toolbar.editor.type.text", defaultValue: "Text") + case .keyCombo: + L10n.string("mobile.toolbar.editor.type.keyCombo", defaultValue: "Key Combo") + case .macro: + L10n.string("mobile.toolbar.editor.type.macro", defaultValue: "Macro") + } + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ToolbarKeyComboFields.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ToolbarKeyComboFields.swift new file mode 100644 index 000000000000..d4fb9bb14ceb --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ToolbarKeyComboFields.swift @@ -0,0 +1,43 @@ +#if os(iOS) +import CmuxMobileSupport +import CmuxMobileTerminalKit +import SwiftUI + +struct ToolbarKeyComboFields: View { + @Binding var modifiers: TerminalKeyModifier + @Binding var key: TerminalSpecialKey + + var body: some View { + Toggle( + L10n.string("mobile.toolbar.editor.modifier.shift", defaultValue: "Shift"), + isOn: binding(for: .shift) + ) + Toggle( + L10n.string("mobile.toolbar.editor.modifier.control", defaultValue: "Control"), + isOn: binding(for: .control) + ) + Toggle( + L10n.string("mobile.toolbar.editor.modifier.option", defaultValue: "Option"), + isOn: binding(for: .alternate) + ) + Picker(L10n.string("mobile.toolbar.editor.keyPicker", defaultValue: "Key"), selection: $key) { + ForEach(TerminalSpecialKey.toolbarEditorOptions, id: \.self) { key in + Text(key.toolbarEditorDisplayName).tag(key) + } + } + } + + private func binding(for modifier: TerminalKeyModifier) -> Binding { + Binding( + get: { modifiers.contains(modifier) }, + set: { isOn in + if isOn { + modifiers.insert(modifier) + } else { + modifiers.remove(modifier) + } + } + ) + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ToolbarMacroStepDraft.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ToolbarMacroStepDraft.swift new file mode 100644 index 000000000000..0fe313870996 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ToolbarMacroStepDraft.swift @@ -0,0 +1,70 @@ +#if os(iOS) +import CmuxMobileTerminalKit +import Foundation + +struct ToolbarMacroStepDraft: Equatable, Identifiable { + let id: UUID + var kind: ToolbarMacroStepKind + var text: String + var runAfterTyping: Bool + var modifiers: TerminalKeyModifier + var key: TerminalSpecialKey + + init( + id: UUID = UUID(), + kind: ToolbarMacroStepKind, + text: String = "", + runAfterTyping: Bool = false, + modifiers: TerminalKeyModifier = [.shift], + key: TerminalSpecialKey = .tab + ) { + self.id = id + self.kind = kind + self.text = text + self.runAfterTyping = runAfterTyping + self.modifiers = modifiers + self.key = key + } + + init(step: ToolbarActionMacroStep) { + switch step { + case let .text(stored): + let seed = Self.textSeed(from: stored) + self.init(kind: .text, text: seed.text, runAfterTyping: seed.runAfterTyping) + case let .keyCombo(modifiers, key): + self.init(kind: .keyCombo, modifiers: modifiers, key: key) + } + } + + static func textStep() -> ToolbarMacroStepDraft { + ToolbarMacroStepDraft(kind: .text) + } + + static func keyComboStep() -> ToolbarMacroStepDraft { + ToolbarMacroStepDraft(kind: .keyCombo) + } + + var macroStep: ToolbarActionMacroStep? { + switch kind { + case .text: + guard !text.isEmpty || runAfterTyping else { return nil } + return .text(runAfterTyping ? text + "\n" : text) + case .keyCombo: + let step = ToolbarActionMacroStep.keyCombo(modifiers: modifiers, key: key) + guard step.output != nil else { return nil } + return step + } + } + + var hasSupportedOutput: Bool { + macroStep?.output != nil + } + + static func textSeed(from stored: String) -> (text: String, runAfterTyping: Bool) { + if stored.hasSuffix("\n") { + return (String(stored.dropLast()), true) + } + return (stored, false) + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ToolbarMacroStepEditor.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ToolbarMacroStepEditor.swift new file mode 100644 index 000000000000..23fd66bc978c --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ToolbarMacroStepEditor.swift @@ -0,0 +1,48 @@ +#if os(iOS) +import CmuxMobileSupport +import SwiftUI + +struct ToolbarMacroStepEditor: View { + @Binding var step: ToolbarMacroStepDraft + + var body: some View { + Picker( + L10n.string("mobile.toolbar.editor.stepType", defaultValue: "Step Type"), + selection: $step.kind + ) { + ForEach(ToolbarMacroStepKind.allCases) { kind in + Text(kind.title).tag(kind) + } + } + .pickerStyle(.segmented) + + switch step.kind { + case .text: + TextField( + L10n.string("mobile.toolbar.editor.textPlaceholder", defaultValue: "Text to type"), + text: $step.text, + axis: .vertical + ) + .lineLimit(1...4) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .font(.system(.body, design: .monospaced)) + + Toggle( + L10n.string("mobile.toolbar.editor.pressReturn", defaultValue: "Press Return"), + isOn: $step.runAfterTyping + ) + case .keyCombo: + ToolbarKeyComboFields(modifiers: $step.modifiers, key: $step.key) + if !step.hasSupportedOutput { + Text(L10n.string( + "mobile.toolbar.editor.unsupportedCombo", + defaultValue: "This key combo is not supported yet." + )) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ToolbarMacroStepKind.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ToolbarMacroStepKind.swift new file mode 100644 index 000000000000..51773c5b0109 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ToolbarMacroStepKind.swift @@ -0,0 +1,19 @@ +#if os(iOS) +import CmuxMobileSupport + +enum ToolbarMacroStepKind: String, CaseIterable, Identifiable { + case text + case keyCombo + + var id: String { rawValue } + + var title: String { + switch self { + case .text: + L10n.string("mobile.toolbar.editor.stepText", defaultValue: "Text") + case .keyCombo: + L10n.string("mobile.toolbar.editor.stepKeyCombo", defaultValue: "Key Combo") + } + } +} +#endif diff --git a/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/CustomToolbarAction.swift b/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/CustomToolbarAction.swift index 5afcc2ba5110..289a522f326a 100644 --- a/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/CustomToolbarAction.swift +++ b/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/CustomToolbarAction.swift @@ -48,18 +48,8 @@ public struct CustomToolbarAction: Codable, Equatable, Sendable, Identifiable { public var itemID: ToolbarItemID { .custom(id) } /// The bytes sent to the terminal when the button is tapped, or `nil` when - /// the payload resolves to nothing (empty text, or an unencodable key combo). - /// - /// For ``ToolbarActionPayload/text(_:)`` newlines are normalized to carriage - /// returns, matching the terminal input pipeline's Return handling. + /// the payload resolves to nothing. public var output: Data? { - switch payload { - case let .text(value): - let normalized = value.replacingOccurrences(of: "\n", with: "\r") - guard !normalized.isEmpty else { return nil } - return Data(normalized.utf8) - case let .keyCombo(modifiers, key): - return TerminalKeyEncoder.encode(specialKey: key, modifiers: modifiers) - } + payload.output } } diff --git a/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/ToolbarActionMacroStep.swift b/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/ToolbarActionMacroStep.swift new file mode 100644 index 000000000000..46de1ff4dcb6 --- /dev/null +++ b/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/ToolbarActionMacroStep.swift @@ -0,0 +1,29 @@ +public import Foundation + +/// One executable step inside a custom terminal toolbar macro. +/// +/// Steps are intentionally the same primitives as a one-shot custom action: +/// literal terminal text, or a modified special key encoded by +/// ``TerminalKeyEncoder``. ``ToolbarActionPayload/macro(_:)`` sends each step in +/// order and refuses to send a partial macro if any step cannot be encoded. +public enum ToolbarActionMacroStep: Codable, Equatable, Sendable { + /// Insert literal text. Newlines are normalized to carriage returns at send + /// time, matching terminal Return handling. + case text(String) + + /// Send a special key with the given modifiers. + case keyCombo(modifiers: TerminalKeyModifier, key: TerminalSpecialKey) + + /// The bytes sent for this step, or `nil` when the step is empty or has no + /// defined terminal encoding. + public var output: Data? { + switch self { + case let .text(value): + let normalized = value.replacingOccurrences(of: "\n", with: "\r") + guard !normalized.isEmpty else { return nil } + return Data(normalized.utf8) + case let .keyCombo(modifiers, key): + return TerminalKeyEncoder.encode(specialKey: key, modifiers: modifiers) + } + } +} diff --git a/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/ToolbarActionPayload.swift b/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/ToolbarActionPayload.swift index a56b45ff7b3c..166a5f231279 100644 --- a/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/ToolbarActionPayload.swift +++ b/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/ToolbarActionPayload.swift @@ -1,10 +1,13 @@ +public import Foundation + /// What a ``CustomToolbarAction`` sends to the terminal when tapped. /// -/// The two cases cover the requests a user-defined bar button needs to express: +/// The cases cover the requests a user-defined bar button needs to express: /// inserting a literal command or snippet (``text``) — which is how the shipped /// agent launchers like `claude --dangerously-skip-permissions` work — and /// firing a single modified special key such as Shift+Tab or Alt+Left -/// (``keyCombo``). Both resolve to bytes through ``CustomToolbarAction/output``. +/// (``keyCombo``). A ``macro`` runs several text/key steps in order, so one +/// toolbar button can drive a multi-key terminal workflow. public enum ToolbarActionPayload: Codable, Equatable, Sendable { /// Insert literal text. Newlines are normalized to carriage returns at send /// time (terminals expect `\r` for Return), so a trailing newline makes the @@ -15,4 +18,28 @@ public enum ToolbarActionPayload: Codable, Equatable, Sendable { /// ``TerminalKeyEncoder``. Only combinations the encoder defines produce /// output; others resolve to `nil`. case keyCombo(modifiers: TerminalKeyModifier, key: TerminalSpecialKey) + + /// Send several macro steps in sequence. If any step cannot be encoded, the + /// whole macro resolves to `nil` so a malformed custom button cannot send a + /// partial sequence. + case macro([ToolbarActionMacroStep]) + + /// The bytes sent for this payload, or `nil` when it resolves to nothing. + public var output: Data? { + switch self { + case let .text(value): + return ToolbarActionMacroStep.text(value).output + case let .keyCombo(modifiers, key): + return ToolbarActionMacroStep.keyCombo(modifiers: modifiers, key: key).output + case let .macro(steps): + guard !steps.isEmpty else { return nil } + var sequence = Data() + for step in steps { + guard let output = step.output else { return nil } + sequence.append(output) + } + guard !sequence.isEmpty else { return nil } + return sequence + } + } } diff --git a/Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/ToolbarCustomizationTests.swift b/Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/ToolbarCustomizationTests.swift index e244c8d47e4a..71f1d9fe7d88 100644 --- a/Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/ToolbarCustomizationTests.swift +++ b/Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/ToolbarCustomizationTests.swift @@ -276,11 +276,43 @@ struct CustomToolbarActionTests { #expect(action.output == nil) } - @Test("Codable round-trips both payload kinds and identity") + @Test("macro payload concatenates text and key-combo steps") + func macroOutput() { + let action = CustomToolbarAction( + title: "Rotate", + payload: .macro([ + .keyCombo(modifiers: [.shift], key: .tab), + .keyCombo(modifiers: [.shift], key: .tab), + .text("status\n"), + ]) + ) + + #expect(action.output == Data([0x1B, 0x5B, 0x5A, 0x1B, 0x5B, 0x5A]) + Data("status\r".utf8)) + } + + @Test("macro payload rejects partial output when a step is unencodable") + func macroRejectsPartialOutput() { + let action = CustomToolbarAction( + title: "Bad", + payload: .macro([ + .text("prefix"), + .keyCombo(modifiers: [.control], key: .upArrow), + ]) + ) + + #expect(action.output == nil) + #expect(CustomToolbarAction(title: "Empty", payload: .macro([])).output == nil) + } + + @Test("Codable round-trips all payload kinds and identity") func codable() throws { let actions = [ CustomToolbarAction(title: "Claude", symbolName: "sparkles", payload: .text("claude\n")), CustomToolbarAction(title: "⇧Tab", payload: .keyCombo(modifiers: [.shift], key: .tab)), + CustomToolbarAction(title: "Macro", payload: .macro([ + .keyCombo(modifiers: [.alternate], key: .leftArrow), + .text("suffix"), + ])), ] let data = try JSONEncoder().encode(actions) let decoded = try JSONDecoder().decode([CustomToolbarAction].self, from: data) diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index ef80c1446033..c95dcb56599e 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -6342,6 +6342,57 @@ } } }, + "mobile.toolbar.editor.addKeyStep": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add Key Step" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キー手順を追加" + } + } + } + }, + "mobile.toolbar.editor.addStepHeader": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add Step" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "手順を追加" + } + } + } + }, + "mobile.toolbar.editor.addTextStep": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add Text Step" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "テキスト手順を追加" + } + } + } + }, "mobile.toolbar.editor.commandFooter": { "extractionState": "manual", "localizations": { @@ -6393,6 +6444,23 @@ } } }, + "mobile.toolbar.editor.deleteStep": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete Step" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "手順を削除" + } + } + } + }, "mobile.toolbar.editor.editTitle": { "extractionState": "manual", "localizations": { @@ -6410,6 +6478,159 @@ } } }, + "mobile.toolbar.editor.keyComboFooter": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sends one modified terminal key, such as Shift-Tab or Option-Left." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Shift-Tab や Option-Left など、修飾キー付きのターミナルキーを1つ送信します。" + } + } + } + }, + "mobile.toolbar.editor.keyComboHeader": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Key Combo" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キーコンボ" + } + } + } + }, + "mobile.toolbar.editor.keyPicker": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Key" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キー" + } + } + } + }, + "mobile.toolbar.editor.macroFooter": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Runs each step in order. Text steps type exactly what you enter; key steps send one key combo." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "各手順を順番に実行します。テキスト手順は入力した内容をそのまま入力し、キー手順はキーコンボを1つ送信します。" + } + } + } + }, + "mobile.toolbar.editor.macroHeader": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Macro Steps" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マクロ手順" + } + } + } + }, + "mobile.toolbar.editor.modifier.control": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Control" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Control" + } + } + } + }, + "mobile.toolbar.editor.modifier.option": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Option" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Option" + } + } + } + }, + "mobile.toolbar.editor.modifier.shift": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Shift" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Shift" + } + } + } + }, + "mobile.toolbar.editor.pressReturn": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Press Return" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Returnを押す" + } + } + } + }, "mobile.toolbar.editor.runAfterTyping": { "extractionState": "manual", "localizations": { @@ -6427,6 +6648,91 @@ } } }, + "mobile.toolbar.editor.stepHeaderFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Step %d" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "手順 %d" + } + } + } + }, + "mobile.toolbar.editor.stepKeyCombo": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Key Combo" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キーコンボ" + } + } + } + }, + "mobile.toolbar.editor.stepText": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Text" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "テキスト" + } + } + } + }, + "mobile.toolbar.editor.stepType": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Step Type" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "手順の種類" + } + } + } + }, + "mobile.toolbar.editor.textPlaceholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Text to type" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "入力するテキスト" + } + } + } + }, "mobile.toolbar.editor.titleFooter": { "extractionState": "manual", "localizations": { @@ -6478,6 +6784,91 @@ } } }, + "mobile.toolbar.editor.type.keyCombo": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Key Combo" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キーコンボ" + } + } + } + }, + "mobile.toolbar.editor.type.macro": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Macro" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マクロ" + } + } + } + }, + "mobile.toolbar.editor.type.text": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Text" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "テキスト" + } + } + } + }, + "mobile.toolbar.editor.typeHeader": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Action Type" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アクションの種類" + } + } + } + }, + "mobile.toolbar.editor.unsupportedCombo": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This key combo is not supported yet." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このキーコンボはまだサポートされていません。" + } + } + } + }, "mobile.workspace.agentChat": { "extractionState": "manual", "localizations": { @@ -7719,6 +8110,23 @@ } } }, + "terminal.shortcut.name.delete": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Delete" + } + } + } + }, "terminal.shortcut.name.end": { "extractionState": "manual", "localizations": { diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/TerminalAccessoryConfigurationTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/TerminalAccessoryConfigurationTests.swift index cb04c8a51745..ef7ac116d2d8 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/TerminalAccessoryConfigurationTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/TerminalAccessoryConfigurationTests.swift @@ -236,6 +236,30 @@ struct TerminalAccessoryConfigurationTests { #expect(config.isEnabled(id(.zoomIn))) } + @Test("custom macro action persists, stays enabled, and sends all steps") + func customMacroActionPersists() throws { + let defaults = freshDefaults() + let custom = CustomToolbarAction( + title: "Rotate", + payload: .macro([ + .keyCombo(modifiers: [.shift], key: .tab), + .keyCombo(modifiers: [.shift], key: .tab), + .text("status\n"), + ]) + ) + + let config = TerminalAccessoryConfiguration(defaults: defaults) + config.addCustomAction(custom) + + let reloaded = TerminalAccessoryConfiguration(defaults: defaults) + let restored = try #require(reloaded.customActions.first { $0.id == custom.id }) + + #expect(restored == custom) + #expect(reloaded.isEnabled(custom.itemID)) + #expect(reloaded.enabledItems.contains { $0.id == custom.itemID }) + #expect(restored.output == Data([0x1B, 0x5B, 0x5A, 0x1B, 0x5B, 0x5A]) + Data("status\r".utf8)) + } + // MARK: - Gating test #3: existing v3 layout gains ⇧ @Test("an existing v3 layout without ⇧ gains it force-enabled right after ⌘")