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/4] 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/4] 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; From ea7a2b93b322a1f698aee85d374671bbb8cc914b Mon Sep 17 00:00:00 2001 From: Thales Matheus <1417625@sga.pucminas.br> Date: Fri, 16 Jan 2026 15:55:35 -0600 Subject: [PATCH 3/4] Add support for ControlReset and AltReset events in terminal TerminalInstance now handles 'ControlReset' and 'AltReset' events, posting notifications with generation info. Also refactored cursor movement to use inputWithModifiers, with corresponding helper added in terminal.bundle/index.html. --- CodeApp/Managers/TerminalInstance.swift | 39 +++++++++++++++++++++++-- Dependencies/terminal.bundle/index.html | 4 +++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/CodeApp/Managers/TerminalInstance.swift b/CodeApp/Managers/TerminalInstance.swift index 25fde8095..4a3be6664 100644 --- a/CodeApp/Managers/TerminalInstance.swift +++ b/CodeApp/Managers/TerminalInstance.swift @@ -253,8 +253,27 @@ class TerminalInstance: NSObject, WKScriptMessageHandler, WKNavigationDelegate { return } - if self.executor?.state == .interactive && event == "Data" { - self.executor?.sendInput(input: result["Input"] as! String) + if self.executor?.state == .interactive { + switch event { + case "Data": + self.executor?.sendInput(input: result["Input"] as! String) + 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: + break + } return } @@ -382,6 +401,20 @@ class TerminalInstance: NSObject, WKScriptMessageHandler, WKNavigationDelegate { executor?.kill() } } + 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: print("\(result) Event not handled") } @@ -511,7 +544,7 @@ extension TerminalInstance { } func moveCursor(codeSequence: String) { - executeScript("term.input(String.fromCharCode(0x1b)+'\(codeSequence)')") + executeScript("inputWithModifiers(String.fromCharCode(0x1b)+'\(codeSequence)')") } func setControlActive(_ active: Bool, generation: Int) { diff --git a/Dependencies/terminal.bundle/index.html b/Dependencies/terminal.bundle/index.html index cadc19f25..52e842eec 100644 --- a/Dependencies/terminal.bundle/index.html +++ b/Dependencies/terminal.bundle/index.html @@ -295,6 +295,10 @@ return result; } + function inputWithModifiers(data) { + term.input(applyModifierStates(data)); + } + function startInteractive() { localEcho.detach(); } From 5958c03de89ceea2806de6497768cab53978dd8d Mon Sep 17 00:00:00 2001 From: Thales Matheus <1417625@sga.pucminas.br> Date: Fri, 16 Jan 2026 16:51:10 -0600 Subject: [PATCH 4/4] Use inputWithModifiers for terminal input Replaces the call to term.input with inputWithModifiers in the type(text:) method to handle input with modifier keys. This may improve support for complex input scenarios in the terminal. Add lock functionality for Ctrl and Alt modifiers in terminal Introduces the ability to lock the Control and Alt modifier keys in the terminal keyboard toolbar via double-tap, with visual feedback and state management. Updates Swift and JavaScript code to support locked states, ensuring modifiers remain active until explicitly unlocked, and modifies reset logic to respect locked states. --- CodeApp/Managers/TerminalInstance.swift | 10 ++- CodeApp/Views/TerminalKeyboardToolbar.swift | 99 ++++++++++++++++++--- Dependencies/terminal.bundle/index.html | 18 +++- 3 files changed, 110 insertions(+), 17 deletions(-) diff --git a/CodeApp/Managers/TerminalInstance.swift b/CodeApp/Managers/TerminalInstance.swift index 4a3be6664..6c5819bd1 100644 --- a/CodeApp/Managers/TerminalInstance.swift +++ b/CodeApp/Managers/TerminalInstance.swift @@ -540,7 +540,7 @@ extension TerminalInstance: WKUIDelegate { extension TerminalInstance { func type(text: String) { guard let base64 = text.base64Encoded() else { return } - executeScript("term.input(base64ToString(`\(base64)`))") + executeScript("inputWithModifiers(base64ToString(`\(base64)`))") } func moveCursor(codeSequence: String) { @@ -551,9 +551,17 @@ extension TerminalInstance { executeScript("setControlActive(\(active), \(generation))") } + func setControlLocked(_ locked: Bool) { + executeScript("setControlLocked(\(locked))") + } + func setAltActive(_ active: Bool, generation: Int) { executeScript("setAltActive(\(active), \(generation))") } + + func setAltLocked(_ locked: Bool) { + executeScript("setAltLocked(\(locked))") + } } extension Notification.Name { diff --git a/CodeApp/Views/TerminalKeyboardToolbar.swift b/CodeApp/Views/TerminalKeyboardToolbar.swift index b528d685c..f6ba152d7 100644 --- a/CodeApp/Views/TerminalKeyboardToolbar.swift +++ b/CodeApp/Views/TerminalKeyboardToolbar.swift @@ -13,25 +13,97 @@ struct TerminalKeyboardToolBar: View { @Environment(\.horizontalSizeClass) var horizontalSizeClass @State var pasteBoardHasContent = false @State var controlActive = false + @State var controlLocked = false + @State var controlLastTapTime: Date? @State var controlGeneration = 0 @State var altActive = false + @State var altLocked = false + @State var altLastTapTime: Date? @State var altGeneration = 0 + private let doubleTapInterval: TimeInterval = 0.3 + private func resetModifierStates() { controlActive = false + controlLocked = false App.terminalInstance.setControlActive(false, generation: controlGeneration) + App.terminalInstance.setControlLocked(false) altActive = false + altLocked = false App.terminalInstance.setAltActive(false, generation: altGeneration) + App.terminalInstance.setAltLocked(false) + } + + private func resetUnlockedModifiers() { + // Reset modifiers only if they are not locked + if !controlLocked { + controlActive = false + App.terminalInstance.setControlActive(false, generation: controlGeneration) + } + if !altLocked { + altActive = false + App.terminalInstance.setAltActive(false, generation: altGeneration) + } + } + + private func handleControlTap() { + let now = Date() + let isDoubleTap = + controlLastTapTime.map { now.timeIntervalSince($0) < doubleTapInterval } ?? false + controlLastTapTime = now + + if controlLocked { + // Single tap while locked: unlock and deactivate + controlLocked = false + controlActive = false + controlGeneration += 1 + App.terminalInstance.setControlLocked(false) + App.terminalInstance.setControlActive(false, generation: controlGeneration) + } else if isDoubleTap && controlActive { + // Double tap while active: lock + controlLocked = true + App.terminalInstance.setControlLocked(true) + } else { + // Single tap: toggle active state + controlActive.toggle() + controlGeneration += 1 + App.terminalInstance.setControlActive(controlActive, generation: controlGeneration) + } + } + + private func handleAltTap() { + let now = Date() + let isDoubleTap = + altLastTapTime.map { now.timeIntervalSince($0) < doubleTapInterval } ?? false + altLastTapTime = now + + if altLocked { + // Single tap while locked: unlock and deactivate + altLocked = false + altActive = false + altGeneration += 1 + App.terminalInstance.setAltLocked(false) + App.terminalInstance.setAltActive(false, generation: altGeneration) + } else if isDoubleTap && altActive { + // Double tap while active: lock + altLocked = true + App.terminalInstance.setAltLocked(true) + } else { + // Single tap: toggle active state + altActive.toggle() + altGeneration += 1 + App.terminalInstance.setAltActive(altActive, generation: altGeneration) + } } private func typeAndResetModifiers(text: String) { App.terminalInstance.type(text: text) - resetModifierStates() + resetUnlockedModifiers() } private func moveCursorAndResetModifiers(codeSequence: String) { App.terminalInstance.moveCursor(codeSequence: codeSequence) - resetModifierStates() + resetUnlockedModifiers() } var body: some View { @@ -66,10 +138,7 @@ struct TerminalKeyboardToolBar: View { }) Button( action: { - controlActive.toggle() - controlGeneration += 1 - App.terminalInstance.setControlActive( - controlActive, generation: controlGeneration) + handleControlTap() }, label: { Text("Ctrl") @@ -78,16 +147,18 @@ struct TerminalKeyboardToolBar: View { controlActive ? Color.accentColor.opacity(0.3) : Color.clear ) .cornerRadius(4) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color.accentColor, lineWidth: controlLocked ? 2 : 0) + ) } ) .accessibilityLabel("Control") - .accessibilityValue(controlActive ? "Active" : "Inactive") + .accessibilityValue( + controlLocked ? "Locked" : (controlActive ? "Active" : "Inactive")) Button( action: { - altActive.toggle() - altGeneration += 1 - App.terminalInstance.setAltActive( - altActive, generation: altGeneration) + handleAltTap() }, label: { Text("Alt") @@ -96,10 +167,14 @@ struct TerminalKeyboardToolBar: View { altActive ? Color.accentColor.opacity(0.3) : Color.clear ) .cornerRadius(4) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color.accentColor, lineWidth: altLocked ? 2 : 0) + ) } ) .accessibilityLabel("Alt") - .accessibilityValue(altActive ? "Active" : "Inactive") + .accessibilityValue(altLocked ? "Locked" : (altActive ? "Active" : "Inactive")) Button( action: { typeAndResetModifiers(text: "\u{1b}[3~") diff --git a/Dependencies/terminal.bundle/index.html b/Dependencies/terminal.bundle/index.html index 52e842eec..7dfd65bef 100644 --- a/Dependencies/terminal.bundle/index.html +++ b/Dependencies/terminal.bundle/index.html @@ -155,8 +155,10 @@ localEcho.addAutocompleteHandler(autocompleteCommonFiles); var controlActive = false; + var controlLocked = false; var controlGeneration = 0; var altActive = false; + var altLocked = false; var altGeneration = 0; function setControlActive(active, generation) { @@ -164,11 +166,19 @@ controlGeneration = generation; } + function setControlLocked(locked) { + controlLocked = locked; + } + function setAltActive(active, generation) { altActive = active; altGeneration = generation; } + function setAltLocked(locked) { + altLocked = locked; + } + function shouldApplyModifierToCsi(final, params) { if (final >= "A" && final <= "D") { return true; @@ -245,7 +255,7 @@ } function applyModifierStates(data) { - // Capture which modifiers are active before resetting + // Capture which modifiers are active before potentially resetting var wasControlActive = controlActive; var wasAltActive = altActive; @@ -253,15 +263,15 @@ return data; } - // Reset all active modifiers and notify Swift - if (wasControlActive) { + // Reset modifiers only if not locked, and notify Swift + if (wasControlActive && !controlLocked) { controlActive = false; window.webkit.messageHandlers.toggleMessageHandler2.postMessage({ Event: "ControlReset", Generation: controlGeneration, }); } - if (wasAltActive) { + if (wasAltActive && !altLocked) { altActive = false; window.webkit.messageHandlers.toggleMessageHandler2.postMessage({ Event: "AltReset",