Key changes: - Fix keyboard input handling for control characters in GhosttyTerminalView - Set consumed_mods correctly (exclude Ctrl/Cmd from consumed mods) - Send unmodified character text for Ctrl+key combinations - Add unshifted_codepoint support for proper key encoding - Add TerminalController with Unix socket API (/tmp/ghosttytabs.sock) - Commands: send, send_key, list_tabs, new_tab, close_tab, select_tab - Supports ctrl-c, ctrl-d, ctrl-z, enter, tab, escape, etc. - Add Python test client and automated test suite - tests/ghosttytabs.py - Python client library - tests/test_ctrl_socket.py - Main Ctrl+C/D test suite (4 tests) - tests/test_signals_auto.py - Standalone PTY signal tests - Update CLAUDE.md with socket API documentation and testing guide - Update .gitignore for Python cache files This fixes Ctrl+C/D not working in apps like claude-code, btop, opencode while continuing to work in simpler apps like htop.
370 lines
12 KiB
Swift
370 lines
12 KiB
Swift
import Foundation
|
|
|
|
/// Unix socket-based controller for programmatic terminal control
|
|
/// Allows automated testing and external control of terminal tabs
|
|
class TerminalController {
|
|
static let shared = TerminalController()
|
|
|
|
private let socketPath = "/tmp/ghosttytabs.sock"
|
|
private var serverSocket: Int32 = -1
|
|
private var isRunning = false
|
|
private var clientHandlers: [Int32: Thread] = [:]
|
|
private weak var tabManager: TabManager?
|
|
|
|
private init() {}
|
|
|
|
func start(tabManager: TabManager) {
|
|
self.tabManager = tabManager
|
|
|
|
// Remove existing socket file
|
|
unlink(socketPath)
|
|
|
|
// Create socket
|
|
serverSocket = socket(AF_UNIX, SOCK_STREAM, 0)
|
|
guard serverSocket >= 0 else {
|
|
print("TerminalController: Failed to create socket")
|
|
return
|
|
}
|
|
|
|
// Bind to path
|
|
var addr = sockaddr_un()
|
|
addr.sun_family = sa_family_t(AF_UNIX)
|
|
socketPath.withCString { ptr in
|
|
withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in
|
|
let pathBuf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self)
|
|
strcpy(pathBuf, ptr)
|
|
}
|
|
}
|
|
|
|
let bindResult = withUnsafePointer(to: &addr) { ptr in
|
|
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
|
|
bind(serverSocket, sockaddrPtr, socklen_t(MemoryLayout<sockaddr_un>.size))
|
|
}
|
|
}
|
|
|
|
guard bindResult >= 0 else {
|
|
print("TerminalController: Failed to bind socket")
|
|
close(serverSocket)
|
|
return
|
|
}
|
|
|
|
// Listen
|
|
guard listen(serverSocket, 5) >= 0 else {
|
|
print("TerminalController: Failed to listen on socket")
|
|
close(serverSocket)
|
|
return
|
|
}
|
|
|
|
isRunning = true
|
|
print("TerminalController: Listening on \(socketPath)")
|
|
|
|
// Accept connections in background thread
|
|
Thread.detachNewThread { [weak self] in
|
|
self?.acceptLoop()
|
|
}
|
|
}
|
|
|
|
func stop() {
|
|
isRunning = false
|
|
if serverSocket >= 0 {
|
|
close(serverSocket)
|
|
serverSocket = -1
|
|
}
|
|
unlink(socketPath)
|
|
}
|
|
|
|
private func acceptLoop() {
|
|
while isRunning {
|
|
var clientAddr = sockaddr_un()
|
|
var clientAddrLen = socklen_t(MemoryLayout<sockaddr_un>.size)
|
|
|
|
let clientSocket = withUnsafeMutablePointer(to: &clientAddr) { ptr in
|
|
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
|
|
accept(serverSocket, sockaddrPtr, &clientAddrLen)
|
|
}
|
|
}
|
|
|
|
guard clientSocket >= 0 else {
|
|
if isRunning {
|
|
print("TerminalController: Accept failed")
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Handle client in new thread
|
|
Thread.detachNewThread { [weak self] in
|
|
self?.handleClient(clientSocket)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handleClient(_ socket: Int32) {
|
|
defer { close(socket) }
|
|
|
|
var buffer = [UInt8](repeating: 0, count: 4096)
|
|
|
|
while isRunning {
|
|
let bytesRead = read(socket, &buffer, buffer.count - 1)
|
|
guard bytesRead > 0 else { break }
|
|
|
|
buffer[bytesRead] = 0
|
|
let command = String(cString: buffer)
|
|
let response = processCommand(command.trimmingCharacters(in: .whitespacesAndNewlines))
|
|
|
|
response.withCString { ptr in
|
|
_ = write(socket, ptr, strlen(ptr))
|
|
}
|
|
"\n".withCString { ptr in
|
|
_ = write(socket, ptr, 1)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func processCommand(_ command: String) -> String {
|
|
let parts = command.split(separator: " ", maxSplits: 1).map(String.init)
|
|
guard !parts.isEmpty else { return "ERROR: Empty command" }
|
|
|
|
let cmd = parts[0].lowercased()
|
|
let args = parts.count > 1 ? parts[1] : ""
|
|
|
|
switch cmd {
|
|
case "ping":
|
|
return "PONG"
|
|
|
|
case "list_tabs":
|
|
return listTabs()
|
|
|
|
case "new_tab":
|
|
return newTab()
|
|
|
|
case "close_tab":
|
|
return closeTab(args)
|
|
|
|
case "select_tab":
|
|
return selectTab(args)
|
|
|
|
case "current_tab":
|
|
return currentTab()
|
|
|
|
case "send":
|
|
return sendInput(args)
|
|
|
|
case "send_key":
|
|
return sendKey(args)
|
|
|
|
case "help":
|
|
return helpText()
|
|
|
|
default:
|
|
return "ERROR: Unknown command '\(cmd)'. Use 'help' for available commands."
|
|
}
|
|
}
|
|
|
|
private func helpText() -> String {
|
|
return """
|
|
Available commands:
|
|
ping - Check if server is running
|
|
list_tabs - List all tabs with IDs
|
|
new_tab - Create a new tab
|
|
close_tab <id> - Close tab by ID
|
|
select_tab <id|index> - Select tab by ID or index (0-based)
|
|
current_tab - Get current tab ID
|
|
send <text> - Send text to current tab
|
|
send_key <key> - Send special key (ctrl-c, ctrl-d, enter, tab, escape)
|
|
help - Show this help
|
|
"""
|
|
}
|
|
|
|
private func listTabs() -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
|
|
var result: String = ""
|
|
DispatchQueue.main.sync {
|
|
let tabs = tabManager.tabs.enumerated().map { (index, tab) in
|
|
let selected = tab.id == tabManager.selectedTabId ? "*" : " "
|
|
return "\(selected) \(index): \(tab.id.uuidString) \(tab.title)"
|
|
}
|
|
result = tabs.joined(separator: "\n")
|
|
}
|
|
return result.isEmpty ? "No tabs" : result
|
|
}
|
|
|
|
private func newTab() -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
|
|
var newTabId: UUID?
|
|
DispatchQueue.main.sync {
|
|
tabManager.addTab()
|
|
newTabId = tabManager.selectedTabId
|
|
}
|
|
return "OK \(newTabId?.uuidString ?? "unknown")"
|
|
}
|
|
|
|
private func closeTab(_ tabId: String) -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
guard let uuid = UUID(uuidString: tabId) else { return "ERROR: Invalid tab ID" }
|
|
|
|
var success = false
|
|
DispatchQueue.main.sync {
|
|
if let tab = tabManager.tabs.first(where: { $0.id == uuid }) {
|
|
tabManager.closeTab(tab)
|
|
success = true
|
|
}
|
|
}
|
|
return success ? "OK" : "ERROR: Tab not found"
|
|
}
|
|
|
|
private func selectTab(_ arg: String) -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
|
|
var success = false
|
|
DispatchQueue.main.sync {
|
|
// Try as UUID first
|
|
if let uuid = UUID(uuidString: arg) {
|
|
if let tab = tabManager.tabs.first(where: { $0.id == uuid }) {
|
|
tabManager.selectTab(tab)
|
|
success = true
|
|
}
|
|
}
|
|
// Try as index
|
|
else if let index = Int(arg), index >= 0, index < tabManager.tabs.count {
|
|
tabManager.selectTab(at: index)
|
|
success = true
|
|
}
|
|
}
|
|
return success ? "OK" : "ERROR: Tab not found"
|
|
}
|
|
|
|
private func currentTab() -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
|
|
var result: String = ""
|
|
DispatchQueue.main.sync {
|
|
if let id = tabManager.selectedTabId {
|
|
result = id.uuidString
|
|
}
|
|
}
|
|
return result.isEmpty ? "ERROR: No tab selected" : result
|
|
}
|
|
|
|
private func sendInput(_ text: String) -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
|
|
var success = false
|
|
DispatchQueue.main.sync {
|
|
guard let selectedId = tabManager.selectedTabId,
|
|
let tab = tabManager.tabs.first(where: { $0.id == selectedId }),
|
|
let surface = tab.terminalSurface.surface else {
|
|
return
|
|
}
|
|
|
|
// Unescape common escape sequences
|
|
// Note: \n is converted to \r for terminal (Enter key sends \r)
|
|
let unescaped = text
|
|
.replacingOccurrences(of: "\\n", with: "\r")
|
|
.replacingOccurrences(of: "\\r", with: "\r")
|
|
.replacingOccurrences(of: "\\t", with: "\t")
|
|
|
|
// Send each character as a key event (like typing)
|
|
for char in unescaped {
|
|
String(char).withCString { ptr in
|
|
var keyEvent = ghostty_input_key_s()
|
|
keyEvent.action = GHOSTTY_ACTION_PRESS
|
|
keyEvent.keycode = 0
|
|
keyEvent.mods = GHOSTTY_MODS_NONE
|
|
keyEvent.consumed_mods = GHOSTTY_MODS_NONE
|
|
keyEvent.text = ptr
|
|
keyEvent.composing = false
|
|
_ = ghostty_surface_key(surface, keyEvent)
|
|
}
|
|
}
|
|
success = true
|
|
}
|
|
return success ? "OK" : "ERROR: Failed to send input"
|
|
}
|
|
|
|
private func sendKey(_ keyName: String) -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
|
|
var success = false
|
|
DispatchQueue.main.sync {
|
|
guard let selectedId = tabManager.selectedTabId,
|
|
let tab = tabManager.tabs.first(where: { $0.id == selectedId }),
|
|
let surface = tab.terminalSurface.surface else {
|
|
return
|
|
}
|
|
|
|
// Helper to send a key event with text
|
|
func sendKeyEvent(text: String, mods: ghostty_input_mods_e = GHOSTTY_MODS_NONE) {
|
|
text.withCString { ptr in
|
|
var keyEvent = ghostty_input_key_s()
|
|
keyEvent.action = GHOSTTY_ACTION_PRESS
|
|
keyEvent.keycode = 0
|
|
keyEvent.mods = mods
|
|
keyEvent.consumed_mods = GHOSTTY_MODS_NONE
|
|
keyEvent.text = ptr
|
|
keyEvent.composing = false
|
|
_ = ghostty_surface_key(surface, keyEvent)
|
|
}
|
|
}
|
|
|
|
switch keyName.lowercased() {
|
|
case "ctrl-c", "ctrl+c", "sigint":
|
|
// Send Ctrl+C - the control character 0x03 (ETX)
|
|
// Note: We send the raw control character, which the terminal
|
|
// interprets as an interrupt signal
|
|
sendKeyEvent(text: "\u{03}")
|
|
success = true
|
|
|
|
case "ctrl-d", "ctrl+d", "eof":
|
|
// Send Ctrl+D - the control character 0x04 (EOT)
|
|
sendKeyEvent(text: "\u{04}")
|
|
success = true
|
|
|
|
case "ctrl-z", "ctrl+z", "sigtstp":
|
|
// Send Ctrl+Z - the control character 0x1A (SUB)
|
|
sendKeyEvent(text: "\u{1A}")
|
|
success = true
|
|
|
|
case "ctrl-\\", "ctrl+\\", "sigquit":
|
|
// Send Ctrl+\ - the control character 0x1C (FS)
|
|
sendKeyEvent(text: "\u{1C}")
|
|
success = true
|
|
|
|
case "enter", "return":
|
|
sendKeyEvent(text: "\r")
|
|
success = true
|
|
|
|
case "tab":
|
|
sendKeyEvent(text: "\t")
|
|
success = true
|
|
|
|
case "escape", "esc":
|
|
sendKeyEvent(text: "\u{1B}")
|
|
success = true
|
|
|
|
case "backspace":
|
|
sendKeyEvent(text: "\u{7F}")
|
|
success = true
|
|
|
|
default:
|
|
// Check for ctrl-<letter> pattern
|
|
if keyName.lowercased().hasPrefix("ctrl-") || keyName.lowercased().hasPrefix("ctrl+") {
|
|
let letter = keyName.dropFirst(5).lowercased()
|
|
if letter.count == 1, let char = letter.first, char.isLetter {
|
|
// Convert letter to control character (a=1, b=2, ..., z=26)
|
|
let ctrlCode = UInt8(char.asciiValue! - Character("a").asciiValue! + 1)
|
|
let ctrlChar = String(UnicodeScalar(ctrlCode))
|
|
sendKeyEvent(text: ctrlChar)
|
|
success = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return success ? "OK" : "ERROR: Unknown key '\(keyName)'"
|
|
}
|
|
|
|
deinit {
|
|
stop()
|
|
}
|
|
}
|