* Sidebar status as text + detect git HEAD changes instantly - Replace sidebar status pills with plain text + show more/less toggle for a cleaner, more readable sidebar layout - Watch .git/HEAD mtime in zsh precmd to detect branch changes from aliases (gco), tools (gh pr checkout), etc. without waiting for the 3s polling interval - Fix NSImage shared instance mutation in DraggableFolderNSView by copying before resizing to prevent layout side-effects - Fix set_status --tab flag being swallowed by -- stop token via new parseOptionsNoStop parser - Update sidebar test to cover alias-based branch switching * Append status text to notification body automatically When creating notifications, include the tab's current status entries in the notification body so users see context (e.g. git branch, ports) alongside the notification message. * Add screenshot to README
1681 lines
62 KiB
Swift
1681 lines
62 KiB
Swift
import AppKit
|
|
import Carbon.HIToolbox
|
|
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 var socketPath = "/tmp/cmuxterm.sock"
|
|
private var serverSocket: Int32 = -1
|
|
private var isRunning = false
|
|
private var clientHandlers: [Int32: Thread] = [:]
|
|
private weak var tabManager: TabManager?
|
|
private var accessMode: SocketControlMode = .full
|
|
|
|
private init() {}
|
|
|
|
func start(tabManager: TabManager, socketPath: String, accessMode: SocketControlMode) {
|
|
self.tabManager = tabManager
|
|
self.accessMode = accessMode
|
|
|
|
if isRunning {
|
|
if self.socketPath == socketPath {
|
|
self.accessMode = accessMode
|
|
return
|
|
}
|
|
stop()
|
|
}
|
|
|
|
self.socketPath = socketPath
|
|
|
|
// 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)
|
|
var pending = ""
|
|
|
|
while isRunning {
|
|
let bytesRead = read(socket, &buffer, buffer.count - 1)
|
|
guard bytesRead > 0 else { break }
|
|
|
|
let chunk = String(bytes: buffer[0..<bytesRead], encoding: .utf8) ?? ""
|
|
pending.append(chunk)
|
|
|
|
while let newlineIndex = pending.firstIndex(of: "\n") {
|
|
let line = String(pending[..<newlineIndex])
|
|
pending = String(pending[pending.index(after: newlineIndex)...])
|
|
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { continue }
|
|
|
|
let response = processCommand(trimmed)
|
|
let payload = response + "\n"
|
|
payload.withCString { ptr in
|
|
_ = write(socket, ptr, strlen(ptr))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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] : ""
|
|
if !isCommandAllowed(cmd) {
|
|
return "ERROR: Command disabled by socket access mode"
|
|
}
|
|
|
|
switch cmd {
|
|
case "ping":
|
|
return "PONG"
|
|
|
|
case "list_tabs":
|
|
return listTabs()
|
|
|
|
case "new_tab":
|
|
return newTab()
|
|
|
|
case "new_split":
|
|
return newSplit(args)
|
|
|
|
case "list_surfaces":
|
|
return listSurfaces(args)
|
|
|
|
case "focus_surface":
|
|
return focusSurface(args)
|
|
|
|
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 "send_surface":
|
|
return sendInputToSurface(args)
|
|
|
|
case "send_key_surface":
|
|
return sendKeyToSurface(args)
|
|
|
|
case "notify":
|
|
return notifyCurrent(args)
|
|
|
|
case "notify_surface":
|
|
return notifySurface(args)
|
|
|
|
case "notify_target":
|
|
return notifyTarget(args)
|
|
|
|
case "list_notifications":
|
|
return listNotifications()
|
|
|
|
case "clear_notifications":
|
|
return clearNotifications()
|
|
|
|
case "set_app_focus":
|
|
return setAppFocusOverride(args)
|
|
|
|
case "simulate_app_active":
|
|
return simulateAppDidBecomeActive()
|
|
|
|
#if DEBUG
|
|
case "focus_notification":
|
|
return focusFromNotification(args)
|
|
|
|
case "flash_count":
|
|
return flashCount(args)
|
|
|
|
case "reset_flash_counts":
|
|
return resetFlashCounts()
|
|
#endif
|
|
|
|
case "set_status":
|
|
return setStatus(args)
|
|
|
|
case "clear_status":
|
|
return clearStatus(args)
|
|
|
|
case "list_status":
|
|
return listStatus(args)
|
|
|
|
case "log":
|
|
return appendLog(args)
|
|
|
|
case "clear_log":
|
|
return clearLog(args)
|
|
|
|
case "list_log":
|
|
return listLog(args)
|
|
|
|
case "set_progress":
|
|
return setProgress(args)
|
|
|
|
case "clear_progress":
|
|
return clearProgress(args)
|
|
|
|
case "report_git_branch":
|
|
return reportGitBranch(args)
|
|
|
|
case "clear_git_branch":
|
|
return clearGitBranch(args)
|
|
|
|
case "report_ports":
|
|
return reportPorts(args)
|
|
|
|
case "clear_ports":
|
|
return clearPorts(args)
|
|
|
|
case "report_pwd":
|
|
return reportPwd(args)
|
|
|
|
case "sidebar_state":
|
|
return sidebarState(args)
|
|
|
|
case "reset_sidebar":
|
|
return resetSidebar(args)
|
|
|
|
case "help":
|
|
return helpText()
|
|
|
|
default:
|
|
return "ERROR: Unknown command '\(cmd)'. Use 'help' for available commands."
|
|
}
|
|
}
|
|
|
|
private func helpText() -> String {
|
|
var text = """
|
|
Available commands:
|
|
ping - Check if server is running
|
|
list_tabs - List all tabs with IDs
|
|
new_tab - Create a new tab
|
|
new_split <direction> [panel] - Split surface (left/right/up/down), optionally specify panel
|
|
list_surfaces [tab] - List surfaces for tab (current tab if omitted)
|
|
focus_surface <id|idx> - Focus surface by ID or index (current 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)
|
|
send_surface <id|idx> <text> - Send text to a surface in current tab
|
|
send_key_surface <id|idx> <key> - Send special key to a surface in current tab
|
|
notify <title>|<subtitle>|<body> - Create a notification for the focused surface
|
|
notify_surface <id|idx> <title>|<subtitle>|<body> - Create a notification for a surface
|
|
notify_target <tabId> <panelId> <title>|<subtitle>|<body> - Notify a specific panel
|
|
list_notifications - List all notifications
|
|
clear_notifications - Clear all notifications
|
|
set_app_focus <active|inactive|clear> - Override app focus state
|
|
simulate_app_active - Trigger app active handler
|
|
set_status <key> <value> [--icon=X] [--color=#hex] [--tab=X] - Set a status entry
|
|
clear_status <key> [--tab=X] - Remove a status entry
|
|
list_status [--tab=X] - List all status entries
|
|
log [--level=X] [--source=X] [--tab=X] -- <message> - Append a log entry
|
|
clear_log [--tab=X] - Clear log entries
|
|
list_log [--limit=N] [--tab=X] - List log entries
|
|
set_progress <0.0-1.0> [--label=X] [--tab=X] - Set progress bar
|
|
clear_progress [--tab=X] - Clear progress bar
|
|
report_git_branch <branch> [--status=dirty] [--tab=X] - Report git branch
|
|
report_ports <port1> [port2...] [--tab=X] [--panel=Y] - Report listening ports
|
|
report_pwd <path> [--tab=X] [--panel=Y] - Report current working directory
|
|
clear_ports [--tab=X] [--panel=Y] - Clear listening ports
|
|
sidebar_state [--tab=X] - Dump all sidebar metadata
|
|
reset_sidebar [--tab=X] - Clear all sidebar metadata
|
|
help - Show this help
|
|
"""
|
|
#if DEBUG
|
|
text += """
|
|
|
|
focus_notification <tab|idx> [surface|idx] - Focus via notification flow
|
|
flash_count <id|idx> - Read flash count for a surface
|
|
reset_flash_counts - Reset flash counters
|
|
"""
|
|
#endif
|
|
return text
|
|
}
|
|
|
|
private func isCommandAllowed(_ command: String) -> Bool {
|
|
switch accessMode {
|
|
case .full:
|
|
return true
|
|
case .notifications:
|
|
let allowed: Set<String> = [
|
|
"ping",
|
|
"help",
|
|
"notify",
|
|
"notify_surface",
|
|
"notify_target",
|
|
"list_notifications",
|
|
"clear_notifications",
|
|
"set_status",
|
|
"clear_status",
|
|
"list_status",
|
|
"log",
|
|
"clear_log",
|
|
"list_log",
|
|
"set_progress",
|
|
"clear_progress",
|
|
"report_git_branch",
|
|
"clear_git_branch",
|
|
"report_ports",
|
|
"clear_ports",
|
|
"report_pwd",
|
|
"sidebar_state",
|
|
"reset_sidebar"
|
|
]
|
|
return allowed.contains(command)
|
|
case .off:
|
|
return false
|
|
}
|
|
}
|
|
|
|
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 newSplit(_ args: String) -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
|
|
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let parts = trimmed.split(separator: " ", maxSplits: 1).map(String.init)
|
|
guard !parts.isEmpty else {
|
|
return "ERROR: Invalid direction. Use left, right, up, or down."
|
|
}
|
|
|
|
let directionArg = parts[0]
|
|
let panelArg = parts.count > 1 ? parts[1] : ""
|
|
|
|
guard let direction = parseSplitDirection(directionArg) else {
|
|
return "ERROR: Invalid direction. Use left, right, up, or down."
|
|
}
|
|
|
|
var result = "ERROR: Failed to create split"
|
|
DispatchQueue.main.sync {
|
|
guard let tabId = tabManager.selectedTabId,
|
|
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
|
|
return
|
|
}
|
|
|
|
// If panel arg provided, resolve it; otherwise use focused surface
|
|
let surfaceId: UUID?
|
|
if !panelArg.isEmpty {
|
|
surfaceId = resolveSurfaceId(from: panelArg, tab: tab)
|
|
if surfaceId == nil {
|
|
result = "ERROR: Panel not found"
|
|
return
|
|
}
|
|
} else {
|
|
surfaceId = tab.focusedSurfaceId
|
|
}
|
|
|
|
guard let targetSurface = surfaceId else {
|
|
result = "ERROR: No surface to split"
|
|
return
|
|
}
|
|
|
|
if let newPanelId = tabManager.newSplit(tabId: tabId, surfaceId: targetSurface, direction: direction) {
|
|
result = "OK \(newPanelId.uuidString)"
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func listSurfaces(_ tabArg: String) -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
var result = ""
|
|
DispatchQueue.main.sync {
|
|
guard let tab = resolveTab(from: tabArg, tabManager: tabManager) else {
|
|
result = "ERROR: Tab not found"
|
|
return
|
|
}
|
|
let surfaces = tab.splitTree.root?.leaves() ?? []
|
|
let focusedId = tab.focusedSurfaceId
|
|
let lines = surfaces.enumerated().map { index, surface in
|
|
let selected = surface.id == focusedId ? "*" : " "
|
|
return "\(selected) \(index): \(surface.id.uuidString)"
|
|
}
|
|
result = lines.isEmpty ? "No surfaces" : lines.joined(separator: "\n")
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func focusSurface(_ arg: String) -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return "ERROR: Missing surface id or index" }
|
|
|
|
var success = false
|
|
DispatchQueue.main.sync {
|
|
guard let tabId = tabManager.selectedTabId,
|
|
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
|
|
return
|
|
}
|
|
|
|
if let uuid = UUID(uuidString: trimmed),
|
|
tab.surface(for: uuid) != nil {
|
|
tabManager.focusSurface(tabId: tab.id, surfaceId: uuid)
|
|
success = true
|
|
return
|
|
}
|
|
|
|
if let index = Int(trimmed), index >= 0 {
|
|
let surfaces = tab.splitTree.root?.leaves() ?? []
|
|
guard index < surfaces.count else { return }
|
|
tabManager.focusSurface(tabId: tab.id, surfaceId: surfaces[index].id)
|
|
success = true
|
|
}
|
|
}
|
|
|
|
return success ? "OK" : "ERROR: Surface not found"
|
|
}
|
|
|
|
private func notifyCurrent(_ args: String) -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
|
|
var result = "OK"
|
|
DispatchQueue.main.sync {
|
|
guard let tabId = tabManager.selectedTabId else {
|
|
result = "ERROR: No tab selected"
|
|
return
|
|
}
|
|
guard let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
|
|
result = "ERROR: Tab not found"
|
|
return
|
|
}
|
|
let surfaceId = tabManager.focusedSurfaceId(for: tabId)
|
|
let (title, subtitle, body) = parseNotificationPayload(args)
|
|
let bodyWithStatus = appendStatusTextIfPresent(body: body, tab: tab)
|
|
TerminalNotificationStore.shared.addNotification(
|
|
tabId: tabId,
|
|
surfaceId: surfaceId,
|
|
title: title,
|
|
subtitle: subtitle,
|
|
body: bodyWithStatus
|
|
)
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func notifySurface(_ args: String) -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return "ERROR: Missing surface id or index" }
|
|
|
|
let parts = trimmed.split(separator: " ", maxSplits: 1).map(String.init)
|
|
let surfaceArg = parts[0]
|
|
let payload = parts.count > 1 ? parts[1] : ""
|
|
|
|
var result = "OK"
|
|
DispatchQueue.main.sync {
|
|
guard let tabId = tabManager.selectedTabId,
|
|
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
|
|
result = "ERROR: No tab selected"
|
|
return
|
|
}
|
|
guard let surfaceId = resolveSurfaceId(from: surfaceArg, tab: tab) else {
|
|
result = "ERROR: Surface not found"
|
|
return
|
|
}
|
|
let (title, subtitle, body) = parseNotificationPayload(payload)
|
|
let bodyWithStatus = appendStatusTextIfPresent(body: body, tab: tab)
|
|
TerminalNotificationStore.shared.addNotification(
|
|
tabId: tabId,
|
|
surfaceId: surfaceId,
|
|
title: title,
|
|
subtitle: subtitle,
|
|
body: bodyWithStatus
|
|
)
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func notifyTarget(_ args: String) -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return "ERROR: Usage: notify_target <tabId> <panelId> <title>|<subtitle>|<body>" }
|
|
|
|
let parts = trimmed.split(separator: " ", maxSplits: 2).map(String.init)
|
|
guard parts.count >= 2 else { return "ERROR: Usage: notify_target <tabId> <panelId> <title>|<subtitle>|<body>" }
|
|
|
|
let tabArg = parts[0]
|
|
let panelArg = parts[1]
|
|
let payload = parts.count > 2 ? parts[2] : ""
|
|
|
|
var result = "OK"
|
|
DispatchQueue.main.sync {
|
|
guard let tab = resolveTab(from: tabArg, tabManager: tabManager) else {
|
|
result = "ERROR: Tab not found"
|
|
return
|
|
}
|
|
guard let panelId = UUID(uuidString: panelArg),
|
|
tab.surface(for: panelId) != nil else {
|
|
result = "ERROR: Panel not found"
|
|
return
|
|
}
|
|
let (title, subtitle, body) = parseNotificationPayload(payload)
|
|
let bodyWithStatus = appendStatusTextIfPresent(body: body, tab: tab)
|
|
TerminalNotificationStore.shared.addNotification(
|
|
tabId: tab.id,
|
|
surfaceId: panelId,
|
|
title: title,
|
|
subtitle: subtitle,
|
|
body: bodyWithStatus
|
|
)
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func appendStatusTextIfPresent(body: String, tab: Tab) -> String {
|
|
let statusText = statusTextForNotification(tab: tab)
|
|
guard !statusText.isEmpty else { return body }
|
|
let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if trimmedBody.isEmpty {
|
|
return statusText
|
|
}
|
|
return body + "\n\n" + statusText
|
|
}
|
|
|
|
private func statusTextForNotification(tab: Tab) -> String {
|
|
let entries = tab.statusEntries.values.sorted(by: { (lhs, rhs) in
|
|
if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp }
|
|
return lhs.key < rhs.key
|
|
})
|
|
|
|
let lines = entries.compactMap { entry -> String? in
|
|
let value = entry.value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !value.isEmpty { return value }
|
|
let key = entry.key.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return key.isEmpty ? nil : key
|
|
}
|
|
return lines.joined(separator: "\n")
|
|
}
|
|
|
|
private func listNotifications() -> String {
|
|
var result = ""
|
|
DispatchQueue.main.sync {
|
|
let lines = TerminalNotificationStore.shared.notifications.enumerated().map { index, notification in
|
|
let surfaceText = notification.surfaceId?.uuidString ?? "none"
|
|
let readText = notification.isRead ? "read" : "unread"
|
|
return "\(index):\(notification.id.uuidString)|\(notification.tabId.uuidString)|\(surfaceText)|\(readText)|\(notification.title)|\(notification.subtitle)|\(notification.body)"
|
|
}
|
|
result = lines.joined(separator: "\n")
|
|
}
|
|
return result.isEmpty ? "No notifications" : result
|
|
}
|
|
|
|
private func clearNotifications() -> String {
|
|
DispatchQueue.main.sync {
|
|
TerminalNotificationStore.shared.clearAll()
|
|
}
|
|
return "OK"
|
|
}
|
|
|
|
private func setAppFocusOverride(_ arg: String) -> String {
|
|
let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
switch trimmed {
|
|
case "active", "1", "true":
|
|
AppFocusState.overrideIsFocused = true
|
|
return "OK"
|
|
case "inactive", "0", "false":
|
|
AppFocusState.overrideIsFocused = false
|
|
return "OK"
|
|
case "clear", "none", "":
|
|
AppFocusState.overrideIsFocused = nil
|
|
return "OK"
|
|
default:
|
|
return "ERROR: Expected active, inactive, or clear"
|
|
}
|
|
}
|
|
|
|
private func simulateAppDidBecomeActive() -> String {
|
|
DispatchQueue.main.sync {
|
|
AppDelegate.shared?.applicationDidBecomeActive(
|
|
Notification(name: NSApplication.didBecomeActiveNotification)
|
|
)
|
|
}
|
|
return "OK"
|
|
}
|
|
|
|
#if DEBUG
|
|
private func focusFromNotification(_ args: String) -> String {
|
|
guard let tabManager else { return "ERROR: TabManager not available" }
|
|
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let parts = trimmed.split(separator: " ", maxSplits: 1).map(String.init)
|
|
let tabArg = parts.first ?? ""
|
|
let surfaceArg = parts.count > 1 ? parts[1] : ""
|
|
|
|
var result = "OK"
|
|
DispatchQueue.main.sync {
|
|
guard let tab = resolveTab(from: tabArg, tabManager: tabManager) else {
|
|
result = "ERROR: Tab not found"
|
|
return
|
|
}
|
|
let surfaceId = surfaceArg.isEmpty ? nil : resolveSurfaceId(from: surfaceArg, tab: tab)
|
|
if !surfaceArg.isEmpty && surfaceId == nil {
|
|
result = "ERROR: Surface not found"
|
|
return
|
|
}
|
|
tabManager.focusTabFromNotification(tab.id, surfaceId: surfaceId)
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func flashCount(_ args: String) -> String {
|
|
guard let tabManager else { return "ERROR: TabManager not available" }
|
|
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return "ERROR: Missing surface id or index" }
|
|
|
|
var result = "ERROR: Surface not found"
|
|
DispatchQueue.main.sync {
|
|
guard let tabId = tabManager.selectedTabId,
|
|
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
|
|
result = "ERROR: No tab selected"
|
|
return
|
|
}
|
|
guard let surfaceId = resolveSurfaceId(from: trimmed, tab: tab) else {
|
|
result = "ERROR: Surface not found"
|
|
return
|
|
}
|
|
let count = GhosttySurfaceScrollView.flashCount(for: surfaceId)
|
|
result = "OK \(count)"
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func resetFlashCounts() -> String {
|
|
DispatchQueue.main.sync {
|
|
GhosttySurfaceScrollView.resetFlashCounts()
|
|
}
|
|
return "OK"
|
|
}
|
|
#endif
|
|
|
|
private func parseSplitDirection(_ value: String) -> SplitTree<TerminalSurface>.NewDirection? {
|
|
switch value.lowercased() {
|
|
case "left", "l":
|
|
return .left
|
|
case "right", "r":
|
|
return .right
|
|
case "up", "u":
|
|
return .up
|
|
case "down", "d":
|
|
return .down
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private func resolveTab(from arg: String, tabManager: TabManager) -> Tab? {
|
|
let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if trimmed.isEmpty {
|
|
guard let selected = tabManager.selectedTabId else { return nil }
|
|
return tabManager.tabs.first(where: { $0.id == selected })
|
|
}
|
|
|
|
if let uuid = UUID(uuidString: trimmed) {
|
|
return tabManager.tabs.first(where: { $0.id == uuid })
|
|
}
|
|
|
|
if let index = Int(trimmed), index >= 0, index < tabManager.tabs.count {
|
|
return tabManager.tabs[index]
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private func resolveSurface(from arg: String, tabManager: TabManager) -> ghostty_surface_t? {
|
|
guard let tabId = tabManager.selectedTabId,
|
|
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
|
|
return nil
|
|
}
|
|
|
|
if let uuid = UUID(uuidString: arg),
|
|
let surface = tab.surface(for: uuid)?.surface {
|
|
return surface
|
|
}
|
|
|
|
if let index = Int(arg), index >= 0 {
|
|
let surfaces = tab.splitTree.root?.leaves() ?? []
|
|
guard index < surfaces.count else { return nil }
|
|
return surfaces[index].surface
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private func resolveSurfaceId(from arg: String, tab: Tab) -> UUID? {
|
|
if let uuid = UUID(uuidString: arg), tab.surface(for: uuid) != nil {
|
|
return uuid
|
|
}
|
|
|
|
if let index = Int(arg), index >= 0 {
|
|
let surfaces = tab.splitTree.root?.leaves() ?? []
|
|
guard index < surfaces.count else { return nil }
|
|
return surfaces[index].id
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private func parseNotificationPayload(_ args: String) -> (String, String, String) {
|
|
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return ("Notification", "", "") }
|
|
let parts = trimmed.split(separator: "|", maxSplits: 2).map(String.init)
|
|
let title = parts[0].trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let subtitle = parts.count > 2 ? parts[1].trimmingCharacters(in: .whitespacesAndNewlines) : ""
|
|
let body = parts.count > 2
|
|
? parts[2].trimmingCharacters(in: .whitespacesAndNewlines)
|
|
: (parts.count > 1 ? parts[1].trimmingCharacters(in: .whitespacesAndNewlines) : "")
|
|
return (title.isEmpty ? "Notification" : title, subtitle, body)
|
|
}
|
|
|
|
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 sendKeyEvent(
|
|
surface: ghostty_surface_t,
|
|
keycode: UInt32,
|
|
mods: ghostty_input_mods_e = GHOSTTY_MODS_NONE,
|
|
text: String? = nil
|
|
) {
|
|
var keyEvent = ghostty_input_key_s()
|
|
keyEvent.action = GHOSTTY_ACTION_PRESS
|
|
keyEvent.keycode = keycode
|
|
keyEvent.mods = mods
|
|
keyEvent.consumed_mods = GHOSTTY_MODS_NONE
|
|
keyEvent.unshifted_codepoint = 0
|
|
keyEvent.composing = false
|
|
if let text {
|
|
text.withCString { ptr in
|
|
keyEvent.text = ptr
|
|
_ = ghostty_surface_key(surface, keyEvent)
|
|
}
|
|
} else {
|
|
keyEvent.text = nil
|
|
_ = ghostty_surface_key(surface, keyEvent)
|
|
}
|
|
}
|
|
|
|
private func sendTextEvent(surface: ghostty_surface_t, text: String) {
|
|
sendKeyEvent(surface: surface, keycode: 0, text: text)
|
|
}
|
|
|
|
private func handleControlScalar(_ scalar: UnicodeScalar, surface: ghostty_surface_t) -> Bool {
|
|
switch scalar.value {
|
|
case 0x0A, 0x0D:
|
|
sendKeyEvent(surface: surface, keycode: UInt32(kVK_Return))
|
|
return true
|
|
case 0x09:
|
|
sendKeyEvent(surface: surface, keycode: UInt32(kVK_Tab))
|
|
return true
|
|
case 0x1B:
|
|
sendKeyEvent(surface: surface, keycode: UInt32(kVK_Escape))
|
|
return true
|
|
case 0x7F:
|
|
sendKeyEvent(surface: surface, keycode: UInt32(kVK_Delete))
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
private func keycodeForLetter(_ letter: Character) -> UInt32? {
|
|
switch String(letter).lowercased() {
|
|
case "a": return UInt32(kVK_ANSI_A)
|
|
case "b": return UInt32(kVK_ANSI_B)
|
|
case "c": return UInt32(kVK_ANSI_C)
|
|
case "d": return UInt32(kVK_ANSI_D)
|
|
case "e": return UInt32(kVK_ANSI_E)
|
|
case "f": return UInt32(kVK_ANSI_F)
|
|
case "g": return UInt32(kVK_ANSI_G)
|
|
case "h": return UInt32(kVK_ANSI_H)
|
|
case "i": return UInt32(kVK_ANSI_I)
|
|
case "j": return UInt32(kVK_ANSI_J)
|
|
case "k": return UInt32(kVK_ANSI_K)
|
|
case "l": return UInt32(kVK_ANSI_L)
|
|
case "m": return UInt32(kVK_ANSI_M)
|
|
case "n": return UInt32(kVK_ANSI_N)
|
|
case "o": return UInt32(kVK_ANSI_O)
|
|
case "p": return UInt32(kVK_ANSI_P)
|
|
case "q": return UInt32(kVK_ANSI_Q)
|
|
case "r": return UInt32(kVK_ANSI_R)
|
|
case "s": return UInt32(kVK_ANSI_S)
|
|
case "t": return UInt32(kVK_ANSI_T)
|
|
case "u": return UInt32(kVK_ANSI_U)
|
|
case "v": return UInt32(kVK_ANSI_V)
|
|
case "w": return UInt32(kVK_ANSI_W)
|
|
case "x": return UInt32(kVK_ANSI_X)
|
|
case "y": return UInt32(kVK_ANSI_Y)
|
|
case "z": return UInt32(kVK_ANSI_Z)
|
|
default: return nil
|
|
}
|
|
}
|
|
|
|
private func sendNamedKey(_ surface: ghostty_surface_t, keyName: String) -> Bool {
|
|
switch keyName.lowercased() {
|
|
case "ctrl-c", "ctrl+c", "sigint":
|
|
sendKeyEvent(surface: surface, keycode: UInt32(kVK_ANSI_C), mods: GHOSTTY_MODS_CTRL)
|
|
return true
|
|
case "ctrl-d", "ctrl+d", "eof":
|
|
sendKeyEvent(surface: surface, keycode: UInt32(kVK_ANSI_D), mods: GHOSTTY_MODS_CTRL)
|
|
return true
|
|
case "ctrl-z", "ctrl+z", "sigtstp":
|
|
sendKeyEvent(surface: surface, keycode: UInt32(kVK_ANSI_Z), mods: GHOSTTY_MODS_CTRL)
|
|
return true
|
|
case "ctrl-\\", "ctrl+\\", "sigquit":
|
|
sendKeyEvent(surface: surface, keycode: UInt32(kVK_ANSI_Backslash), mods: GHOSTTY_MODS_CTRL)
|
|
return true
|
|
case "enter", "return":
|
|
sendKeyEvent(surface: surface, keycode: UInt32(kVK_Return))
|
|
return true
|
|
case "tab":
|
|
sendKeyEvent(surface: surface, keycode: UInt32(kVK_Tab))
|
|
return true
|
|
case "escape", "esc":
|
|
sendKeyEvent(surface: surface, keycode: UInt32(kVK_Escape))
|
|
return true
|
|
case "backspace":
|
|
sendKeyEvent(surface: surface, keycode: UInt32(kVK_Delete))
|
|
return true
|
|
default:
|
|
if keyName.lowercased().hasPrefix("ctrl-") || keyName.lowercased().hasPrefix("ctrl+") {
|
|
let letter = keyName.dropFirst(5)
|
|
if letter.count == 1, let char = letter.first, let keycode = keycodeForLetter(char) {
|
|
sendKeyEvent(surface: surface, keycode: keycode, mods: GHOSTTY_MODS_CTRL)
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
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.focusedSurface?.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")
|
|
|
|
for char in unescaped {
|
|
if char.unicodeScalars.count == 1,
|
|
let scalar = char.unicodeScalars.first,
|
|
handleControlScalar(scalar, surface: surface) {
|
|
continue
|
|
}
|
|
sendTextEvent(surface: surface, text: String(char))
|
|
}
|
|
success = true
|
|
}
|
|
return success ? "OK" : "ERROR: Failed to send input"
|
|
}
|
|
|
|
private func sendInputToSurface(_ args: String) -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
let parts = args.split(separator: " ", maxSplits: 1).map(String.init)
|
|
guard parts.count == 2 else { return "ERROR: Usage: send_surface <id|idx> <text>" }
|
|
|
|
let target = parts[0]
|
|
let text = parts[1]
|
|
|
|
var success = false
|
|
DispatchQueue.main.sync {
|
|
guard let surface = resolveSurface(from: target, tabManager: tabManager) else { return }
|
|
|
|
let unescaped = text
|
|
.replacingOccurrences(of: "\\n", with: "\r")
|
|
.replacingOccurrences(of: "\\r", with: "\r")
|
|
.replacingOccurrences(of: "\\t", with: "\t")
|
|
|
|
for char in unescaped {
|
|
if char.unicodeScalars.count == 1,
|
|
let scalar = char.unicodeScalars.first,
|
|
handleControlScalar(scalar, surface: surface) {
|
|
continue
|
|
}
|
|
sendTextEvent(surface: surface, text: String(char))
|
|
}
|
|
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.focusedSurface?.surface else {
|
|
return
|
|
}
|
|
|
|
success = sendNamedKey(surface, keyName: keyName)
|
|
}
|
|
return success ? "OK" : "ERROR: Unknown key '\(keyName)'"
|
|
}
|
|
|
|
private func sendKeyToSurface(_ args: String) -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
let parts = args.split(separator: " ", maxSplits: 1).map(String.init)
|
|
guard parts.count == 2 else { return "ERROR: Usage: send_key_surface <id|idx> <key>" }
|
|
|
|
let target = parts[0]
|
|
let keyName = parts[1]
|
|
|
|
var success = false
|
|
DispatchQueue.main.sync {
|
|
guard let surface = resolveSurface(from: target, tabManager: tabManager) else { return }
|
|
success = sendNamedKey(surface, keyName: keyName)
|
|
}
|
|
|
|
return success ? "OK" : "ERROR: Unknown key '\(keyName)'"
|
|
}
|
|
|
|
// MARK: - Option Parsing
|
|
|
|
private func tokenizeArgs(_ args: String) -> [String] {
|
|
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return [] }
|
|
|
|
// Tokenize respecting quoted strings. Support basic backslash escapes inside quotes
|
|
// (e.g. \" within "...") so shell integrations can safely escape embedded quotes.
|
|
var tokens: [String] = []
|
|
var current = ""
|
|
var inQuote = false
|
|
var quoteChar: Character = "'"
|
|
let chars = Array(trimmed)
|
|
var cursor = 0
|
|
while cursor < chars.count {
|
|
let char = chars[cursor]
|
|
if inQuote {
|
|
if char == "\\" {
|
|
if cursor + 1 < chars.count {
|
|
let next = chars[cursor + 1]
|
|
if next == quoteChar || next == "\\" {
|
|
current.append(next)
|
|
cursor += 2
|
|
continue
|
|
}
|
|
}
|
|
current.append(char)
|
|
cursor += 1
|
|
continue
|
|
}
|
|
|
|
if char == quoteChar {
|
|
inQuote = false
|
|
cursor += 1
|
|
continue
|
|
}
|
|
|
|
current.append(char)
|
|
cursor += 1
|
|
continue
|
|
}
|
|
|
|
if char == "'" || char == "\"" {
|
|
inQuote = true
|
|
quoteChar = char
|
|
cursor += 1
|
|
continue
|
|
}
|
|
|
|
if char.isWhitespace {
|
|
if !current.isEmpty {
|
|
tokens.append(current)
|
|
current = ""
|
|
}
|
|
cursor += 1
|
|
continue
|
|
}
|
|
|
|
current.append(char)
|
|
cursor += 1
|
|
}
|
|
if !current.isEmpty {
|
|
tokens.append(current)
|
|
}
|
|
return tokens
|
|
}
|
|
|
|
private func parseOptions(_ args: String) -> (positional: [String], options: [String: String]) {
|
|
let tokens = tokenizeArgs(args)
|
|
guard !tokens.isEmpty else { return ([], [:]) }
|
|
|
|
var positional: [String] = []
|
|
var options: [String: String] = [:]
|
|
var stopParsingOptions = false
|
|
var i = 0
|
|
while i < tokens.count {
|
|
let token = tokens[i]
|
|
if stopParsingOptions {
|
|
positional.append(token)
|
|
} else if token == "--" {
|
|
stopParsingOptions = true
|
|
} else if token.hasPrefix("--") {
|
|
if let eqIndex = token.firstIndex(of: "=") {
|
|
let key = String(token[token.index(token.startIndex, offsetBy: 2)..<eqIndex])
|
|
let value = String(token[token.index(after: eqIndex)...])
|
|
options[key] = value
|
|
} else {
|
|
let key = String(token.dropFirst(2))
|
|
if i + 1 < tokens.count && !tokens[i + 1].hasPrefix("--") {
|
|
options[key] = tokens[i + 1]
|
|
i += 1
|
|
} else {
|
|
options[key] = ""
|
|
}
|
|
}
|
|
} else {
|
|
positional.append(token)
|
|
}
|
|
i += 1
|
|
}
|
|
return (positional, options)
|
|
}
|
|
|
|
private func parseOptionsNoStop(_ args: String) -> (positional: [String], options: [String: String]) {
|
|
// Like parseOptions, but continues parsing `--key` options even after a `--` token.
|
|
// Used for commands where we never want UI-facing content to accidentally include flags.
|
|
let tokens = tokenizeArgs(args)
|
|
guard !tokens.isEmpty else { return ([], [:]) }
|
|
|
|
var positional: [String] = []
|
|
var options: [String: String] = [:]
|
|
var i = 0
|
|
while i < tokens.count {
|
|
let token = tokens[i]
|
|
if token == "--" {
|
|
i += 1
|
|
continue
|
|
}
|
|
if token.hasPrefix("--") {
|
|
if let eqIndex = token.firstIndex(of: "=") {
|
|
let key = String(token[token.index(token.startIndex, offsetBy: 2)..<eqIndex])
|
|
let value = String(token[token.index(after: eqIndex)...])
|
|
options[key] = value
|
|
} else {
|
|
let key = String(token.dropFirst(2))
|
|
if i + 1 < tokens.count && !tokens[i + 1].hasPrefix("--") {
|
|
options[key] = tokens[i + 1]
|
|
i += 1
|
|
} else {
|
|
options[key] = ""
|
|
}
|
|
}
|
|
} else {
|
|
positional.append(token)
|
|
}
|
|
i += 1
|
|
}
|
|
return (positional, options)
|
|
}
|
|
|
|
// MARK: - Sidebar Commands
|
|
|
|
private func resolveTabForReport(_ args: String) -> Tab? {
|
|
guard let tabManager else { return nil }
|
|
let parsed = parseOptions(args)
|
|
if let tabArg = parsed.options["tab"], !tabArg.isEmpty {
|
|
return resolveTab(from: tabArg, tabManager: tabManager)
|
|
}
|
|
guard let selectedId = tabManager.selectedTabId else { return nil }
|
|
return tabManager.tabs.first(where: { $0.id == selectedId })
|
|
}
|
|
|
|
private func setStatus(_ args: String) -> String {
|
|
guard tabManager != nil else { return "ERROR: TabManager not available" }
|
|
// Parse options even if the caller used `--` before/inside the value.
|
|
// This avoids leaking flags like `--tab` into the stored (and rendered) status text.
|
|
let parsed = parseOptionsNoStop(args)
|
|
guard parsed.positional.count >= 2 else {
|
|
return "ERROR: Missing status key or value — usage: set_status <key> <value> [--icon=X] [--color=#hex] [--tab=X]"
|
|
}
|
|
let key = parsed.positional[0]
|
|
let value = parsed.positional[1...].joined(separator: " ")
|
|
let icon = parsed.options["icon"]
|
|
let color = parsed.options["color"]
|
|
|
|
var result = "OK"
|
|
DispatchQueue.main.sync {
|
|
guard let tabManager else { result = "ERROR: TabManager not available"; return }
|
|
let tab: Tab?
|
|
if let tabArg = parsed.options["tab"], !tabArg.isEmpty {
|
|
tab = resolveTab(from: tabArg, tabManager: tabManager)
|
|
} else if let selectedId = tabManager.selectedTabId {
|
|
tab = tabManager.tabs.first(where: { $0.id == selectedId })
|
|
} else {
|
|
tab = nil
|
|
}
|
|
guard let tab else {
|
|
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
|
|
return
|
|
}
|
|
tab.statusEntries[key] = SidebarStatusEntry(
|
|
key: key, value: value, icon: icon, color: color, timestamp: Date()
|
|
)
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func clearStatus(_ args: String) -> String {
|
|
guard tabManager != nil else { return "ERROR: TabManager not available" }
|
|
let parsed = parseOptions(args)
|
|
guard let key = parsed.positional.first, parsed.positional.count == 1 else {
|
|
return "ERROR: Missing status key — usage: clear_status <key> [--tab=X]"
|
|
}
|
|
|
|
var result = "OK"
|
|
DispatchQueue.main.sync {
|
|
guard let tab = resolveTabForReport(args) else {
|
|
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
|
|
return
|
|
}
|
|
if tab.statusEntries.removeValue(forKey: key) == nil {
|
|
result = "OK (key not found)"
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func listStatus(_ args: String) -> String {
|
|
guard tabManager != nil else { return "ERROR: TabManager not available" }
|
|
|
|
var result = ""
|
|
DispatchQueue.main.sync {
|
|
guard let tab = resolveTabForReport(args) else {
|
|
result = "ERROR: Tab not found"
|
|
return
|
|
}
|
|
if tab.statusEntries.isEmpty {
|
|
result = "No status entries"
|
|
return
|
|
}
|
|
let lines = tab.statusEntries.values.sorted(by: { $0.key < $1.key }).map { entry in
|
|
var line = "\(entry.key)=\(entry.value)"
|
|
if let icon = entry.icon { line += " icon=\(icon)" }
|
|
if let color = entry.color { line += " color=\(color)" }
|
|
return line
|
|
}
|
|
result = lines.joined(separator: "\n")
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func appendLog(_ args: String) -> String {
|
|
guard tabManager != nil else { return "ERROR: TabManager not available" }
|
|
let parsed = parseOptions(args)
|
|
guard !parsed.positional.isEmpty else {
|
|
return "ERROR: Missing message — usage: log [--level=X] [--source=X] [--tab=X] -- <message>"
|
|
}
|
|
let message = parsed.positional.joined(separator: " ")
|
|
let levelStr = parsed.options["level"] ?? "info"
|
|
guard let level = SidebarLogLevel(rawValue: levelStr) else {
|
|
return "ERROR: Unknown log level '\(levelStr)' — use: info, progress, success, warning, error"
|
|
}
|
|
let source = parsed.options["source"]
|
|
|
|
var result = "OK"
|
|
DispatchQueue.main.sync {
|
|
guard let tab = resolveTabForReport(args) else {
|
|
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
|
|
return
|
|
}
|
|
let entry = SidebarLogEntry(message: message, level: level, source: source, timestamp: Date())
|
|
tab.logEntries.append(entry)
|
|
let defaultLimit = Tab.maxLogEntries
|
|
let configuredLimit = UserDefaults.standard.object(forKey: "sidebarMaxLogEntries") as? Int ?? defaultLimit
|
|
let limit = max(1, min(500, configuredLimit))
|
|
if tab.logEntries.count > limit {
|
|
tab.logEntries.removeFirst(tab.logEntries.count - limit)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func clearLog(_ args: String) -> String {
|
|
guard tabManager != nil else { return "ERROR: TabManager not available" }
|
|
|
|
var result = "OK"
|
|
DispatchQueue.main.sync {
|
|
guard let tab = resolveTabForReport(args) else {
|
|
result = "ERROR: Tab not found"
|
|
return
|
|
}
|
|
tab.logEntries.removeAll()
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func listLog(_ args: String) -> String {
|
|
guard tabManager != nil else { return "ERROR: TabManager not available" }
|
|
let parsed = parseOptions(args)
|
|
var limit: Int?
|
|
if let limitStr = parsed.options["limit"] {
|
|
if limitStr.isEmpty {
|
|
return "ERROR: Missing limit value — usage: list_log [--limit=N] [--tab=X]"
|
|
}
|
|
guard let parsedLimit = Int(limitStr) else {
|
|
return "ERROR: Invalid limit '\(limitStr)' — must be >= 0"
|
|
}
|
|
guard parsedLimit >= 0 else {
|
|
return "ERROR: Invalid limit '\(parsedLimit)' — must be >= 0"
|
|
}
|
|
limit = parsedLimit
|
|
}
|
|
|
|
var result = ""
|
|
DispatchQueue.main.sync {
|
|
guard let tab = resolveTabForReport(args) else {
|
|
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
|
|
return
|
|
}
|
|
if tab.logEntries.isEmpty {
|
|
result = "No log entries"
|
|
return
|
|
}
|
|
let entries: [SidebarLogEntry]
|
|
if let limit = limit {
|
|
entries = Array(tab.logEntries.suffix(limit))
|
|
} else {
|
|
entries = tab.logEntries
|
|
}
|
|
if entries.isEmpty {
|
|
result = "No log entries"
|
|
return
|
|
}
|
|
let lines = entries.map { entry in
|
|
var line = "[\(entry.level.rawValue)] \(entry.message)"
|
|
if let source = entry.source { line += " (source=\(source))" }
|
|
return line
|
|
}
|
|
result = lines.joined(separator: "\n")
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func setProgress(_ args: String) -> String {
|
|
guard tabManager != nil else { return "ERROR: TabManager not available" }
|
|
let parsed = parseOptions(args)
|
|
guard let valueStr = parsed.positional.first else {
|
|
return "ERROR: Missing progress value — usage: set_progress <0.0-1.0> [--label=X] [--tab=X]"
|
|
}
|
|
guard let value = Double(valueStr), value >= 0.0, value <= 1.0 else {
|
|
return "ERROR: Invalid progress value '\(valueStr)' — must be 0.0 to 1.0"
|
|
}
|
|
let label = parsed.options["label"]
|
|
|
|
var result = "OK"
|
|
DispatchQueue.main.sync {
|
|
guard let tab = resolveTabForReport(args) else {
|
|
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
|
|
return
|
|
}
|
|
tab.progress = (value: value, label: label)
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func clearProgress(_ args: String) -> String {
|
|
guard tabManager != nil else { return "ERROR: TabManager not available" }
|
|
|
|
var result = "OK"
|
|
DispatchQueue.main.sync {
|
|
guard let tab = resolveTabForReport(args) else {
|
|
result = "ERROR: Tab not found"
|
|
return
|
|
}
|
|
tab.progress = nil
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func reportGitBranch(_ args: String) -> String {
|
|
guard let tabManager else { return "ERROR: TabManager not available" }
|
|
let parsed = parseOptions(args)
|
|
guard let branch = parsed.positional.first else {
|
|
return "ERROR: Missing branch name — usage: report_git_branch <branch> [--status=dirty] [--tab=X]"
|
|
}
|
|
let isDirty = parsed.options["status"]?.lowercased() == "dirty"
|
|
|
|
var result = "OK"
|
|
DispatchQueue.main.sync {
|
|
guard let tab = resolveTabForReport(args) ?? {
|
|
guard let selectedId = tabManager.selectedTabId else { return nil }
|
|
return tabManager.tabs.first(where: { $0.id == selectedId })
|
|
}() else {
|
|
result = "ERROR: Tab not found"
|
|
return
|
|
}
|
|
tab.gitBranch = (branch: branch, isDirty: isDirty)
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func clearGitBranch(_ args: String) -> String {
|
|
guard let tabManager else { return "ERROR: TabManager not available" }
|
|
|
|
var result = "OK"
|
|
DispatchQueue.main.sync {
|
|
guard let tab = resolveTabForReport(args) ?? {
|
|
guard let selectedId = tabManager.selectedTabId else { return nil }
|
|
return tabManager.tabs.first(where: { $0.id == selectedId })
|
|
}() else {
|
|
result = "ERROR: Tab not found"
|
|
return
|
|
}
|
|
tab.gitBranch = nil
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func reportPorts(_ args: String) -> String {
|
|
guard let tabManager else { return "ERROR: TabManager not available" }
|
|
let parsed = parseOptions(args)
|
|
guard !parsed.positional.isEmpty else {
|
|
return "ERROR: Missing ports — usage: report_ports <port1> [port2...] [--tab=X] [--panel=Y]"
|
|
}
|
|
var ports: [Int] = []
|
|
for portStr in parsed.positional {
|
|
guard let port = Int(portStr), port > 0, port <= 65535 else {
|
|
return "ERROR: Invalid port '\(portStr)' — must be 1-65535"
|
|
}
|
|
ports.append(port)
|
|
}
|
|
|
|
var result = "OK"
|
|
DispatchQueue.main.sync {
|
|
guard let tab = resolveTabForReport(args) ?? {
|
|
guard let selectedId = tabManager.selectedTabId else { return nil }
|
|
return tabManager.tabs.first(where: { $0.id == selectedId })
|
|
}() else {
|
|
result = "ERROR: Tab not found"
|
|
return
|
|
}
|
|
|
|
// Support both --panel and --surface as synonyms.
|
|
let panelArg = parsed.options["panel"] ?? parsed.options["surface"]
|
|
let surfaceId: UUID
|
|
if let panelArg {
|
|
if panelArg.isEmpty {
|
|
result = "ERROR: Missing panel id — usage: report_ports <port1> [port2...] [--tab=X] [--panel=Y]"
|
|
return
|
|
}
|
|
guard let parsedId = UUID(uuidString: panelArg) else {
|
|
result = "ERROR: Invalid panel id '\(panelArg)'"
|
|
return
|
|
}
|
|
surfaceId = parsedId
|
|
} else {
|
|
guard let focused = tab.focusedSurfaceId else {
|
|
result = "ERROR: Missing panel id (no focused surface)"
|
|
return
|
|
}
|
|
surfaceId = focused
|
|
}
|
|
|
|
let validSurfaceIds = Set((tab.splitTree.root?.leaves() ?? []).map { $0.id })
|
|
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
|
|
guard validSurfaceIds.contains(surfaceId) else {
|
|
result = "ERROR: Panel not found '\(surfaceId.uuidString)'"
|
|
return
|
|
}
|
|
|
|
tab.surfaceListeningPorts[surfaceId] = ports
|
|
tab.recomputeListeningPorts()
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func reportPwd(_ args: String) -> String {
|
|
guard let tabManager else { return "ERROR: TabManager not available" }
|
|
let parsed = parseOptions(args)
|
|
guard !parsed.positional.isEmpty else {
|
|
return "ERROR: Missing path — usage: report_pwd <path> [--tab=X] [--panel=Y]"
|
|
}
|
|
|
|
let directory = parsed.positional.joined(separator: " ")
|
|
var result = "OK"
|
|
DispatchQueue.main.sync {
|
|
guard let tab = resolveTabForReport(args) else {
|
|
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
|
|
return
|
|
}
|
|
|
|
// Support both --panel and --surface as synonyms.
|
|
let panelArg = parsed.options["panel"] ?? parsed.options["surface"]
|
|
let surfaceId: UUID
|
|
if let panelArg {
|
|
if panelArg.isEmpty {
|
|
result = "ERROR: Missing panel id — usage: report_pwd <path> [--tab=X] [--panel=Y]"
|
|
return
|
|
}
|
|
guard let parsedId = UUID(uuidString: panelArg) else {
|
|
result = "ERROR: Invalid panel id '\(panelArg)'"
|
|
return
|
|
}
|
|
surfaceId = parsedId
|
|
} else {
|
|
guard let focused = tab.focusedSurfaceId else {
|
|
result = "ERROR: Missing panel id (no focused surface)"
|
|
return
|
|
}
|
|
surfaceId = focused
|
|
}
|
|
|
|
let validSurfaceIds = Set((tab.splitTree.root?.leaves() ?? []).map { $0.id })
|
|
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
|
|
guard validSurfaceIds.contains(surfaceId) else {
|
|
result = "ERROR: Panel not found '\(surfaceId.uuidString)'"
|
|
return
|
|
}
|
|
|
|
tabManager.updateSurfaceDirectory(tabId: tab.id, surfaceId: surfaceId, directory: directory)
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func clearPorts(_ args: String) -> String {
|
|
guard let tabManager else { return "ERROR: TabManager not available" }
|
|
|
|
let parsed = parseOptions(args)
|
|
var result = "OK"
|
|
DispatchQueue.main.sync {
|
|
guard let tab = resolveTabForReport(args) ?? {
|
|
guard let selectedId = tabManager.selectedTabId else { return nil }
|
|
return tabManager.tabs.first(where: { $0.id == selectedId })
|
|
}() else {
|
|
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
|
|
return
|
|
}
|
|
|
|
let validSurfaceIds = Set((tab.splitTree.root?.leaves() ?? []).map { $0.id })
|
|
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
|
|
|
|
// If a panel is specified, clear only that surface's ports. Otherwise clear all.
|
|
let panelArg = parsed.options["panel"] ?? parsed.options["surface"]
|
|
if let panelArg {
|
|
if panelArg.isEmpty {
|
|
result = "ERROR: Missing panel id — usage: clear_ports [--tab=X] [--panel=Y]"
|
|
return
|
|
}
|
|
guard let surfaceId = UUID(uuidString: panelArg) else {
|
|
result = "ERROR: Invalid panel id '\(panelArg)'"
|
|
return
|
|
}
|
|
guard validSurfaceIds.contains(surfaceId) else {
|
|
result = "ERROR: Panel not found '\(surfaceId.uuidString)'"
|
|
return
|
|
}
|
|
tab.surfaceListeningPorts.removeValue(forKey: surfaceId)
|
|
} else {
|
|
tab.surfaceListeningPorts.removeAll()
|
|
}
|
|
|
|
tab.recomputeListeningPorts()
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func sidebarState(_ args: String) -> String {
|
|
guard let tabManager else { return "ERROR: TabManager not available" }
|
|
|
|
var result = ""
|
|
DispatchQueue.main.sync {
|
|
guard let tab = resolveTabForReport(args) ?? {
|
|
guard let selectedId = tabManager.selectedTabId else { return nil }
|
|
return tabManager.tabs.first(where: { $0.id == selectedId })
|
|
}() else {
|
|
result = "ERROR: Tab not found"
|
|
return
|
|
}
|
|
|
|
var lines: [String] = []
|
|
lines.append("tab=\(tab.id.uuidString)")
|
|
lines.append("cwd=\(tab.currentDirectory)")
|
|
if let focused = tab.focusedSurfaceId,
|
|
let focusedDir = tab.surfaceDirectories[focused] {
|
|
lines.append("focused_cwd=\(focusedDir)")
|
|
lines.append("focused_panel=\(focused.uuidString)")
|
|
} else {
|
|
lines.append("focused_cwd=unknown")
|
|
lines.append("focused_panel=unknown")
|
|
}
|
|
|
|
// Git branch
|
|
if let git = tab.gitBranch {
|
|
lines.append("git_branch=\(git.branch)\(git.isDirty ? " dirty" : " clean")")
|
|
} else {
|
|
lines.append("git_branch=none")
|
|
}
|
|
|
|
// Ports
|
|
if tab.listeningPorts.isEmpty {
|
|
lines.append("ports=none")
|
|
} else {
|
|
lines.append("ports=\(tab.listeningPorts.map(String.init).joined(separator: ","))")
|
|
}
|
|
|
|
// Progress
|
|
if let progress = tab.progress {
|
|
let label = progress.label ?? ""
|
|
lines.append("progress=\(String(format: "%.2f", progress.value)) \(label)".trimmingCharacters(in: .whitespaces))
|
|
} else {
|
|
lines.append("progress=none")
|
|
}
|
|
|
|
// Status entries
|
|
lines.append("status_count=\(tab.statusEntries.count)")
|
|
for entry in tab.statusEntries.values.sorted(by: { $0.key < $1.key }) {
|
|
var line = " \(entry.key)=\(entry.value)"
|
|
if let icon = entry.icon { line += " icon=\(icon)" }
|
|
if let color = entry.color { line += " color=\(color)" }
|
|
lines.append(line)
|
|
}
|
|
|
|
// Log entries
|
|
lines.append("log_count=\(tab.logEntries.count)")
|
|
for entry in tab.logEntries.suffix(5) {
|
|
lines.append(" [\(entry.level.rawValue)] \(entry.message)")
|
|
}
|
|
|
|
result = lines.joined(separator: "\n")
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func resetSidebar(_ args: String) -> String {
|
|
guard let tabManager else { return "ERROR: TabManager not available" }
|
|
|
|
var result = "OK"
|
|
DispatchQueue.main.sync {
|
|
guard let tab = resolveTabForReport(args) ?? {
|
|
guard let selectedId = tabManager.selectedTabId else { return nil }
|
|
return tabManager.tabs.first(where: { $0.id == selectedId })
|
|
}() else {
|
|
result = "ERROR: Tab not found"
|
|
return
|
|
}
|
|
tab.statusEntries.removeAll()
|
|
tab.logEntries.removeAll()
|
|
tab.progress = nil
|
|
tab.gitBranch = nil
|
|
tab.surfaceListeningPorts.removeAll()
|
|
tab.listeningPorts.removeAll()
|
|
}
|
|
return result
|
|
}
|
|
|
|
deinit {
|
|
stop()
|
|
}
|
|
}
|