-
Notifications
You must be signed in to change notification settings - Fork 1.8k
Expand file tree
/
Copy pathTerminalSession.swift
More file actions
237 lines (201 loc) · 8.14 KB
/
TerminalSession.swift
File metadata and controls
237 lines (201 loc) · 8.14 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
import Foundation
import SystemUtils
import Logger
import Combine
/**
* Manages shell processes for terminal emulation
*/
class ShellProcessManager {
private var process: Process?
private var outputPipe: Pipe?
private var inputPipe: Pipe?
private var isRunning = false
var onOutputReceived: ((String) -> Void)?
private let shellIntegrationScript = """
# Shell integration for tracking command execution and exit codes
__terminal_command_start() {
printf "\\033]133;C\\007" # Command started
}
__terminal_command_finished() {
local EXIT="$?"
printf "\\033]133;D;%d\\007" "$EXIT" # Command finished with exit code
return $EXIT
}
# Set up precmd and preexec hooks
autoload -Uz add-zsh-hook
add-zsh-hook precmd __terminal_command_finished
add-zsh-hook preexec __terminal_command_start
# print the initial prompt to output
echo -n
"""
/**
* Starts a shell process
*/
func startShell(inDirectory directory: String = NSHomeDirectory()) {
guard !isRunning else { return }
process = Process()
outputPipe = Pipe()
inputPipe = Pipe()
// Configure the process
process?.executableURL = URL(fileURLWithPath: "/bin/zsh")
process?.arguments = ["-i", "-l"]
// Create temporary file for shell integration
let tempDir = FileManager.default.temporaryDirectory
let copilotZshPath = tempDir.appendingPathComponent("xcode-copilot-zsh")
var zshdir = tempDir
if !FileManager.default.fileExists(atPath: copilotZshPath.path) {
do {
try FileManager.default.createDirectory(at: copilotZshPath, withIntermediateDirectories: true, attributes: nil)
zshdir = copilotZshPath
} catch {
Logger.client.info("Error creating zsh directory: \(error.localizedDescription)")
}
} else {
zshdir = copilotZshPath
}
let integrationFile = zshdir.appendingPathComponent("shell_integration.zsh")
try? shellIntegrationScript.write(to: integrationFile, atomically: true, encoding: .utf8)
var environment = ProcessInfo.processInfo.environment
// Fetch login shell environment to get correct PATH
if let shellEnv = SystemUtils.shared.getLoginShellEnvironment(shellPath: "/bin/zsh") {
for (key, value) in shellEnv {
environment[key] = value
}
}
// Append common bin paths to PATH
environment["PATH"] = SystemUtils.shared.appendCommonBinPaths(path: environment["PATH"] ?? "")
let userZdotdir = environment["ZDOTDIR"] ?? NSHomeDirectory()
environment["ZDOTDIR"] = zshdir.path
environment["USER_ZDOTDIR"] = userZdotdir
environment["SHELL_INTEGRATION"] = integrationFile.path
process?.environment = environment
// Source shell integration in zsh startup
let zshrcContent = "source \"$SHELL_INTEGRATION\"\n"
try? zshrcContent.write(to: zshdir.appendingPathComponent(".zshrc"), atomically: true, encoding: .utf8)
process?.standardOutput = outputPipe
process?.standardError = outputPipe
process?.standardInput = inputPipe
process?.currentDirectoryURL = URL(fileURLWithPath: directory)
// Handle output from the process
outputPipe?.fileHandleForReading.readabilityHandler = { [weak self] fileHandle in
let data = fileHandle.availableData
if !data.isEmpty, let output = String(data: data, encoding: .utf8) {
DispatchQueue.main.async {
self?.onOutputReceived?(output)
}
}
}
do {
try process?.run()
isRunning = true
} catch {
onOutputReceived?("Failed to start shell: \(error.localizedDescription)\r\n")
Logger.client.error("Failed to start shell: \(error.localizedDescription)")
}
}
/**
* Sends a command to the shell process
* @param command The command to send
*/
func sendCommand(_ command: String) {
guard isRunning, let inputPipe = inputPipe else { return }
if let data = (command).data(using: .utf8) {
try? inputPipe.fileHandleForWriting.write(contentsOf: data)
}
}
func stopCommand() {
// Send SIGINT (Ctrl+C) to the running process
guard let process = process else { return }
process.interrupt() // Sends SIGINT to the process
}
/**
* Terminates the shell process
*/
func terminateShell() {
guard isRunning else { return }
outputPipe?.fileHandleForReading.readabilityHandler = nil
process?.terminate()
isRunning = false
}
deinit {
terminateShell()
}
}
public struct CommandExecutionResult {
public let success: Bool
public let output: String
}
public class TerminalSession: ObservableObject {
@Published public var terminalOutput = ""
private var shellManager = ShellProcessManager()
private var hasPendingCommand = false
private var pendingCommandResult = ""
// Add command completion handler
private var onCommandCompleted: ((CommandExecutionResult) -> Void)?
init() {
// Set up the shell process manager to handle shell output
shellManager.onOutputReceived = { [weak self] output in
self?.handleShellOutput(output)
}
}
public func executeCommand(currentDirectory: String, command: String, completion: @escaping (CommandExecutionResult) -> Void) {
onCommandCompleted = completion
pendingCommandResult = ""
// Start shell in the requested directory
self.shellManager.startShell(inDirectory: currentDirectory.isEmpty ? NSHomeDirectory() : currentDirectory)
// Wait for shell prompt to appear before sending command
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
self?.terminalOutput += "\(command)\n"
self?.shellManager.sendCommand(command + "\n")
self?.hasPendingCommand = true
}
}
/**
* Handles input from the terminal view
* @param input Input received from terminal
*/
public func handleTerminalInput(_ input: String) {
DispatchQueue.main.async { [weak self] in
if input.contains("\u{03}") { // CTRL+C
let newInput = input.replacingOccurrences(of: "\u{03}", with: "\n")
self?.terminalOutput += newInput
self?.shellManager.stopCommand()
self?.shellManager.sendCommand("\n")
return
}
// Echo the input to the terminal
self?.terminalOutput += input
self?.shellManager.sendCommand(input)
}
}
public func getCommandOutput() -> String {
return self.pendingCommandResult
}
/**
* Handles output from the shell process
* @param output Output from shell process
*/
private func handleShellOutput(_ output: String) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.terminalOutput += output
// Look for shell integration escape sequences
if output.contains("\u{1B}]133;D;0\u{07}") && self.hasPendingCommand {
// Command succeeded
self.onCommandCompleted?(CommandExecutionResult(success: true, output: self.pendingCommandResult))
self.hasPendingCommand = false
} else if output.contains("\u{1B}]133;D;") && self.hasPendingCommand {
// Command failed
self.onCommandCompleted?(CommandExecutionResult(success: false, output: self.pendingCommandResult))
self.hasPendingCommand = false
} else if output.contains("\u{1B}]133;C\u{07}") {
// Command start
} else if self.hasPendingCommand {
self.pendingCommandResult += output
}
}
}
public func cleanup() {
shellManager.terminateShell()
}
}