From 10bd8c526786bba20d908a77a34b733407ef23e7 Mon Sep 17 00:00:00 2001 From: Thales Matheus <1417625@sga.pucminas.br> Date: Sun, 11 Jan 2026 14:42:36 -0600 Subject: [PATCH 1/2] Add Esc, Del and Ctrl and Alt modifier support to terminal toolbar Introduces Escape, Delete, and Control and Alt modifier buttons to the terminal keyboard toolbar, allowing users to send modified key sequences. Updates the Swift and JavaScript code to track modifier state, apply modifiers to terminal input, and synchronize state between the UI and the terminal. Modifier states are reset after use, and notifications are used to keep the UI in sync. --- CodeApp/Managers/TerminalInstance.swift | 27 ++++ CodeApp/Views/TerminalKeyboardToolbar.swift | 116 +++++++++++++++- Dependencies/terminal.bundle/index.html | 140 ++++++++++++++++++++ 3 files changed, 276 insertions(+), 7 deletions(-) diff --git a/CodeApp/Managers/TerminalInstance.swift b/CodeApp/Managers/TerminalInstance.swift index 015318310..25fde8095 100644 --- a/CodeApp/Managers/TerminalInstance.swift +++ b/CodeApp/Managers/TerminalInstance.swift @@ -233,6 +233,20 @@ class TerminalInstance: NSObject, WKScriptMessageHandler, WKNavigationDelegate { if let input = result["Input"] as? String { ts.write(data: "\(input)".data(using: .utf8)!) } + case "ControlReset": + let generation = result["Generation"] as? Int ?? 0 + NotificationCenter.default.post( + name: .terminalControlReset, + object: self, + userInfo: ["generation": generation] + ) + case "AltReset": + let generation = result["Generation"] as? Int ?? 0 + NotificationCenter.default.post( + name: .terminalAltReset, + object: self, + userInfo: ["generation": generation] + ) default: return } @@ -499,4 +513,17 @@ extension TerminalInstance { func moveCursor(codeSequence: String) { executeScript("term.input(String.fromCharCode(0x1b)+'\(codeSequence)')") } + + func setControlActive(_ active: Bool, generation: Int) { + executeScript("setControlActive(\(active), \(generation))") + } + + func setAltActive(_ active: Bool, generation: Int) { + executeScript("setAltActive(\(active), \(generation))") + } +} + +extension Notification.Name { + static let terminalControlReset = Notification.Name("terminalControlReset") + static let terminalAltReset = Notification.Name("terminalAltReset") } diff --git a/CodeApp/Views/TerminalKeyboardToolbar.swift b/CodeApp/Views/TerminalKeyboardToolbar.swift index 8cd6327ba..b528d685c 100644 --- a/CodeApp/Views/TerminalKeyboardToolbar.swift +++ b/CodeApp/Views/TerminalKeyboardToolbar.swift @@ -12,6 +12,27 @@ struct TerminalKeyboardToolBar: View { @EnvironmentObject var App: MainApp @Environment(\.horizontalSizeClass) var horizontalSizeClass @State var pasteBoardHasContent = false + @State var controlActive = false + @State var controlGeneration = 0 + @State var altActive = false + @State var altGeneration = 0 + + private func resetModifierStates() { + controlActive = false + App.terminalInstance.setControlActive(false, generation: controlGeneration) + altActive = false + App.terminalInstance.setAltActive(false, generation: altGeneration) + } + + private func typeAndResetModifiers(text: String) { + App.terminalInstance.type(text: text) + resetModifierStates() + } + + private func moveCursorAndResetModifiers(codeSequence: String) { + App.terminalInstance.moveCursor(codeSequence: codeSequence) + resetModifierStates() + } var body: some View { HStack(spacing: horizontalSizeClass == .compact ? 8 : 14) { @@ -20,7 +41,7 @@ struct TerminalKeyboardToolBar: View { Button( action: { if let string = UIPasteboard.general.string { - App.terminalInstance.type(text: string) + typeAndResetModifiers(text: string) } }, label: { @@ -29,11 +50,65 @@ struct TerminalKeyboardToolBar: View { } Button( action: { - App.terminalInstance.type(text: "\t") + typeAndResetModifiers(text: "\u{1b}") + }, + label: { + Text("Esc") + } + ) + .accessibilityLabel("Escape") + Button( + action: { + typeAndResetModifiers(text: "\t") }, label: { Text("↹") }) + Button( + action: { + controlActive.toggle() + controlGeneration += 1 + App.terminalInstance.setControlActive( + controlActive, generation: controlGeneration) + }, + label: { + Text("Ctrl") + .padding(.horizontal, 4) + .background( + controlActive ? Color.accentColor.opacity(0.3) : Color.clear + ) + .cornerRadius(4) + } + ) + .accessibilityLabel("Control") + .accessibilityValue(controlActive ? "Active" : "Inactive") + Button( + action: { + altActive.toggle() + altGeneration += 1 + App.terminalInstance.setAltActive( + altActive, generation: altGeneration) + }, + label: { + Text("Alt") + .padding(.horizontal, 4) + .background( + altActive ? Color.accentColor.opacity(0.3) : Color.clear + ) + .cornerRadius(4) + } + ) + .accessibilityLabel("Alt") + .accessibilityValue(altActive ? "Active" : "Inactive") + Button( + action: { + typeAndResetModifiers(text: "\u{1b}[3~") + }, + label: { + Text("Del") + } + ) + .accessibilityLabel("Delete") } Spacer() @@ -41,34 +116,35 @@ struct TerminalKeyboardToolBar: View { Group { Button( action: { - App.terminalInstance.moveCursor(codeSequence: "[A") + moveCursorAndResetModifiers(codeSequence: "[A") }, label: { Image(systemName: "arrow.up") }) Button( action: { - App.terminalInstance.moveCursor(codeSequence: "[B") + moveCursorAndResetModifiers(codeSequence: "[B") }, label: { Image(systemName: "arrow.down") }) Button( action: { - App.terminalInstance.moveCursor(codeSequence: "[D") + moveCursorAndResetModifiers(codeSequence: "[D") }, label: { Image(systemName: "arrow.left") }) Button( action: { - App.terminalInstance.moveCursor(codeSequence: "[C") + moveCursorAndResetModifiers(codeSequence: "[C") }, label: { Image(systemName: "arrow.right") }) Button( action: { + resetModifierStates() App.terminalInstance.blur() }, label: { @@ -84,12 +160,38 @@ struct TerminalKeyboardToolBar: View { .ignoresSafeArea() .onReceive( NotificationCenter.default.publisher(for: UIPasteboard.changedNotification), - perform: { val in + perform: { _ in if UIPasteboard.general.hasStrings { pasteBoardHasContent = true } else { pasteBoardHasContent = false } + } + ) + .onReceive( + NotificationCenter.default.publisher( + for: .terminalControlReset, + object: App.terminalInstance + ), + perform: { notification in + if let generation = notification.userInfo?["generation"] as? Int, + generation == controlGeneration + { + controlActive = false + } + } + ) + .onReceive( + NotificationCenter.default.publisher( + for: .terminalAltReset, + object: App.terminalInstance + ), + perform: { notification in + if let generation = notification.userInfo?["generation"] as? Int, + generation == altGeneration + { + altActive = false + } }) } } diff --git a/Dependencies/terminal.bundle/index.html b/Dependencies/terminal.bundle/index.html index 494ca9024..2e1d915ba 100644 --- a/Dependencies/terminal.bundle/index.html +++ b/Dependencies/terminal.bundle/index.html @@ -154,6 +154,145 @@ localEcho.addAutocompleteHandler(autocompleteCommonCommands); localEcho.addAutocompleteHandler(autocompleteCommonFiles); + var controlActive = false; + var controlGeneration = 0; + var altActive = false; + var altGeneration = 0; + + function setControlActive(active, generation) { + controlActive = active; + controlGeneration = generation; + } + + function setAltActive(active, generation) { + altActive = active; + altGeneration = generation; + } + + function shouldApplyModifierToCsi(final, params) { + if (final >= "A" && final <= "D") { + return true; + } + if (final === "F" || final === "H") { + return true; + } + if (final === "~") { + var primaryParam = params.split(";")[0]; + var keycode = parseInt(primaryParam, 10); + if (isNaN(keycode)) { + return false; + } + return keycode !== 200 && keycode !== 201; + } + return false; + } + + function applyModifierToEscapeSequence(data, wasControlActive, wasAltActive) { + var modifier = 1 + (wasAltActive ? 2 : 0) + (wasControlActive ? 4 : 0); + + // CSI sequences (ESC [ ...). + var csiMatch = data.match(/^\x1b\[([0-9;]*)([@-~])$/); + if (csiMatch) { + var params = csiMatch[1]; + var final = csiMatch[2]; + + if (!shouldApplyModifierToCsi(final, params)) { + return data; + } + + var parts = params.length ? params.split(";") : []; + if (parts.length === 0) { + parts = ["1", String(modifier)]; + } else if (parts.length === 1) { + parts.push(String(modifier)); + } else { + var lastIndex = parts.length - 1; + var existing = parseInt(parts[lastIndex], 10); + if (!isNaN(existing)) { + var modBits = Math.max(existing, 1) - 1; + if (wasAltActive) { + modBits |= 2; + } + if (wasControlActive) { + modBits |= 4; + } + parts[lastIndex] = String(modBits + 1); + } else { + parts.push(String(modifier)); + } + } + + return "\x1b[" + parts.join(";") + final; + } + + // SS3 sequences (ESC O ...). + var ss3Match = data.match(/^\x1bO([A-Za-z])$/); + if (ss3Match) { + var final = ss3Match[1]; + if ( + (final >= "A" && final <= "D") || + final === "F" || + final === "H" || + (final >= "P" && final <= "S") + ) { + return "\x1b[1;" + String(modifier) + final; + } + } + + return data; + } + + function applyModifierStates(data) { + // Capture which modifiers are active before resetting + var wasControlActive = controlActive; + var wasAltActive = altActive; + + if (!wasControlActive && !wasAltActive) { + return data; + } + + // Reset all active modifiers and notify Swift + if (wasControlActive) { + controlActive = false; + window.webkit.messageHandlers.toggleMessageHandler2.postMessage({ + Event: "ControlReset", + Generation: controlGeneration, + }); + } + if (wasAltActive) { + altActive = false; + window.webkit.messageHandlers.toggleMessageHandler2.postMessage({ + Event: "AltReset", + Generation: altGeneration, + }); + } + + var result = data; + + if (data.length === 1) { + // Apply Control: convert to control character (A-Z, @, [, \, ], ^, _) + if (wasControlActive) { + const code = result.toUpperCase().charCodeAt(0); + if (code >= 0x40 && code <= 0x5f) { + result = String.fromCharCode(code & 0x1f); + } + } + + // Apply Alt: prepend ESC (meta key behavior) + if (wasAltActive) { + result = "\x1b" + result; + } + } else if (data.charCodeAt(0) === 0x1b) { + result = applyModifierToEscapeSequence( + data, + wasControlActive, + wasAltActive + ); + } + + return result; + } + function startInteractive() { localEcho.detach(); } @@ -163,6 +302,7 @@ } term.onData((data) => { + data = applyModifierStates(data); window.webkit.messageHandlers.toggleMessageHandler2.postMessage({ Event: "Data", Input: data, From 70e4aa794cec7c9e88f26a417c3d79714436b9ea Mon Sep 17 00:00:00 2001 From: Thales Matheus <1417625@sga.pucminas.br> Date: Sun, 11 Jan 2026 14:59:59 -0600 Subject: [PATCH 2/2] Prevent modification of bracketed paste sequences Added a check to exclude ESC[200~ and ESC[201~ keycodes from being modified, preserving the integrity of the bracketed paste protocol in the terminal. --- Dependencies/terminal.bundle/index.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Dependencies/terminal.bundle/index.html b/Dependencies/terminal.bundle/index.html index 2e1d915ba..cadc19f25 100644 --- a/Dependencies/terminal.bundle/index.html +++ b/Dependencies/terminal.bundle/index.html @@ -182,6 +182,8 @@ if (isNaN(keycode)) { return false; } + // Exclude bracketed paste mode sequences (ESC[200~ and ESC[201~). + // Modifying these would corrupt the paste protocol. return keycode !== 200 && keycode !== 201; } return false;