Skip to content

Commit 0f89382

Browse files
committed
feat(terminal): add terminal emulator with bridge integration
Implement local and remote terminal session management via the bridge connection manager. This adds client methods to start, resize, send input to, and close terminal sessions, along with handlers for output and exit notifications. Also includes UI updates for the terminal emulator and fixes a typo in the FirebaseAPIKey property list entry.
1 parent 5a287e8 commit 0f89382

24 files changed

Lines changed: 1352 additions & 12 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -740,3 +740,4 @@ Axon/Resources/MLXModels/google_gemma-4-E2B-it-MLX/config.json
740740
Axon/Resources/MLXModels/google_gemma-4-E2B-it-MLX/generation_config.json
741741
Axon/Resources/MLXModels/google_gemma-4-E2B-it-MLX/special_tokens_map.json
742742
Axon/Resources/MLXModels/google_gemma-4-E2B-it-MLX/README.md
743+
bridge_test_tmp.txt

Axon/DesignSystem/Chat/ChatVisualTokens.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,15 @@ import SwiftUI
1010
enum ChatVisualTokens {
1111
// MARK: - Message Layout
1212

13+
#if os(macOS)
14+
static let chatRailMaxWidth: CGFloat = 940
15+
static let chatRailHorizontalPadding: CGFloat = 24
16+
static let messageMaxReadableWidth: CGFloat = 940
17+
#else
18+
static let chatRailMaxWidth: CGFloat = .infinity
19+
static let chatRailHorizontalPadding: CGFloat = 0
1320
static let messageMaxReadableWidth: CGFloat = 520
21+
#endif
1422
static let messageOuterHorizontalPadding: CGFloat = 12
1523
static let messageBubbleHorizontalPadding: CGFloat = 14
1624
static let messageBubbleVerticalPadding: CGFloat = 10

Axon/Info.plist

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
<string>com.axon.sync.memories</string>
1515
<string>com.axon.sync.background</string>
1616
</array>
17-
<key>FIrebaseAPIKey</key>
17+
<key>FirebaseAPIKey</key>
1818
<string>$(FIREBASE_API_KEY)</string>
1919
<key>FirebaseAppID</key>
2020
<string>$(FIREBASE_APP_ID)</string>

Axon/Services/Bridge/BridgeClient.swift

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,23 @@ class BridgeClient: ObservableObject {
248248
return try JSONDecoder().decode(TerminalRunResult.self, from: resultData)
249249
}
250250

251+
func startTerminalSession(_ params: TerminalSessionStartParams) async throws -> TerminalSessionStartResult {
252+
let result = try await executeMethod(method: .terminalSessionStart, params: try params.bridgeAnyCodable())
253+
return try result.decodeBridgeValue(TerminalSessionStartResult.self)
254+
}
255+
256+
func sendTerminalInput(_ params: TerminalSessionInputParams) async throws {
257+
_ = try await executeMethod(method: .terminalSessionInput, params: try params.bridgeAnyCodable())
258+
}
259+
260+
func resizeTerminalSession(_ params: TerminalSessionResizeParams) async throws {
261+
_ = try await executeMethod(method: .terminalSessionResize, params: try params.bridgeAnyCodable())
262+
}
263+
264+
func closeTerminalSession(_ params: TerminalSessionCloseParams) async throws {
265+
_ = try await executeMethod(method: .terminalSessionClose, params: try params.bridgeAnyCodable())
266+
}
267+
251268
/// Get workspace info from VS Code
252269
func getWorkspaceInfo() async throws -> WorkspaceInfoResult {
253270
let result = try await executeMethod(method: .workspaceInfo, params: .null)
@@ -486,7 +503,26 @@ class BridgeClient: ObservableObject {
486503

487504
private func handleIncomingNotification(_ notification: BridgeNotification) {
488505
print("[BridgeClient] Received notification: \(notification.method)")
489-
// Handle notifications from VS Code if needed
506+
handleTerminalNotification(notification)
507+
}
508+
509+
private func handleTerminalNotification(_ notification: BridgeNotification) {
510+
guard let params = notification.params else { return }
511+
512+
do {
513+
switch notification.method {
514+
case TerminalBridgeMethod.output:
515+
let output = try params.decodeBridgeValue(TerminalSessionOutputNotification.self)
516+
NotificationCenter.default.post(name: .terminalSessionOutput, object: output)
517+
case TerminalBridgeMethod.exited:
518+
let exited = try params.decodeBridgeValue(TerminalSessionExitedNotification.self)
519+
NotificationCenter.default.post(name: .terminalSessionExited, object: exited)
520+
default:
521+
break
522+
}
523+
} catch {
524+
print("[BridgeClient] Failed to decode terminal notification: \(error)")
525+
}
490526
}
491527

492528
// MARK: - Reconnection

Axon/Services/Bridge/BridgeConnectionManager.swift

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,54 @@ class BridgeConnectionManager: ObservableObject {
389389
}
390390
}
391391

392+
func startTerminalSession(_ params: TerminalSessionStartParams, sessionId: String? = nil) async throws -> TerminalSessionStartResult {
393+
switch mode {
394+
case .local:
395+
return try await BridgeServer.shared.startTerminalSession(params)
396+
case .remote:
397+
guard let client = bridgeClient else {
398+
throw BridgeError(code: .notConnected, message: "Not connected")
399+
}
400+
return try await client.startTerminalSession(params)
401+
}
402+
}
403+
404+
func sendTerminalInput(_ params: TerminalSessionInputParams, sessionId: String? = nil) async throws {
405+
switch mode {
406+
case .local:
407+
try await BridgeServer.shared.sendTerminalInput(params)
408+
case .remote:
409+
guard let client = bridgeClient else {
410+
throw BridgeError(code: .notConnected, message: "Not connected")
411+
}
412+
try await client.sendTerminalInput(params)
413+
}
414+
}
415+
416+
func resizeTerminalSession(_ params: TerminalSessionResizeParams, sessionId: String? = nil) async throws {
417+
switch mode {
418+
case .local:
419+
try await BridgeServer.shared.resizeTerminalSession(params)
420+
case .remote:
421+
guard let client = bridgeClient else {
422+
throw BridgeError(code: .notConnected, message: "Not connected")
423+
}
424+
try await client.resizeTerminalSession(params)
425+
}
426+
}
427+
428+
func closeTerminalSession(_ params: TerminalSessionCloseParams, sessionId: String? = nil) async throws {
429+
switch mode {
430+
case .local:
431+
try await BridgeServer.shared.closeTerminalSession(params)
432+
case .remote:
433+
guard let client = bridgeClient else {
434+
throw BridgeError(code: .notConnected, message: "Not connected")
435+
}
436+
try await client.closeTerminalSession(params)
437+
}
438+
}
439+
392440
/// Get workspace info from VS Code
393441
func getWorkspaceInfo(sessionId: String? = nil) async throws -> WorkspaceInfoResult {
394442
switch mode {

Axon/Services/Bridge/BridgeProtocol.swift

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -772,6 +772,10 @@ enum BridgeMethod: String, CaseIterable {
772772

773773
// Terminal operations
774774
case terminalRun = "terminal/run"
775+
case terminalSessionStart = "terminal/sessionStart"
776+
case terminalSessionInput = "terminal/sessionInput"
777+
case terminalSessionResize = "terminal/sessionResize"
778+
case terminalSessionClose = "terminal/sessionClose"
775779

776780
// Workspace operations
777781
case workspaceInfo = "workspace/info"
@@ -808,6 +812,7 @@ enum BridgeMethod: String, CaseIterable {
808812
var requiresApproval: Bool {
809813
switch self {
810814
case .fileWrite, .terminalRun,
815+
.terminalSessionStart, .terminalSessionInput, .terminalSessionResize, .terminalSessionClose,
811816
.clipboardWrite, .appLaunch, .shellExecute:
812817
return true
813818
case .hello,
@@ -846,7 +851,9 @@ enum BridgeMethod: String, CaseIterable {
846851
case .guestQueryMemories, .guestGetContext, .guestChatWithContext, .guestDisconnect, .hello:
847852
return true
848853
case .getPairingInfo, .chatListConversations, .chatGetMessages,
849-
.fileRead, .fileWrite, .fileList, .terminalRun, .workspaceInfo,
854+
.fileRead, .fileWrite, .fileList,
855+
.terminalRun, .terminalSessionStart, .terminalSessionInput, .terminalSessionResize, .terminalSessionClose,
856+
.workspaceInfo,
850857
.systemInfo, .systemProcesses, .systemDiskUsage,
851858
.clipboardRead, .clipboardWrite, .notificationSend,
852859
.spotlightSearch, .fileFind, .fileMetadata,
@@ -876,6 +883,14 @@ enum BridgeMethod: String, CaseIterable {
876883
return "List directory contents"
877884
case .terminalRun:
878885
return "Execute terminal command"
886+
case .terminalSessionStart:
887+
return "Start interactive terminal session"
888+
case .terminalSessionInput:
889+
return "Send input to interactive terminal session"
890+
case .terminalSessionResize:
891+
return "Resize interactive terminal session"
892+
case .terminalSessionClose:
893+
return "Close interactive terminal session"
879894
case .workspaceInfo:
880895
return "Get workspace information"
881896
case .guestQueryMemories:

Axon/Services/Bridge/BridgeServer.swift

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,38 @@ class BridgeServer: ObservableObject {
383383
return try JSONDecoder().decode(TerminalRunResult.self, from: resultData)
384384
}
385385

386+
func startTerminalSession(_ params: TerminalSessionStartParams) async throws -> TerminalSessionStartResult {
387+
let result = try await sendRequest(method: BridgeMethod.terminalSessionStart.rawValue, params: try params.bridgeAnyCodable())
388+
if let error = result.error {
389+
throw error
390+
}
391+
guard let payload = result.result else {
392+
throw BridgeError(code: .terminalError, message: "Missing terminal session start result")
393+
}
394+
return try payload.decodeBridgeValue(TerminalSessionStartResult.self)
395+
}
396+
397+
func sendTerminalInput(_ params: TerminalSessionInputParams) async throws {
398+
let result = try await sendRequest(method: BridgeMethod.terminalSessionInput.rawValue, params: try params.bridgeAnyCodable())
399+
if let error = result.error {
400+
throw error
401+
}
402+
}
403+
404+
func resizeTerminalSession(_ params: TerminalSessionResizeParams) async throws {
405+
let result = try await sendRequest(method: BridgeMethod.terminalSessionResize.rawValue, params: try params.bridgeAnyCodable())
406+
if let error = result.error {
407+
throw error
408+
}
409+
}
410+
411+
func closeTerminalSession(_ params: TerminalSessionCloseParams) async throws {
412+
let result = try await sendRequest(method: BridgeMethod.terminalSessionClose.rawValue, params: try params.bridgeAnyCodable())
413+
if let error = result.error {
414+
throw error
415+
}
416+
}
417+
386418
/// Get workspace info from VS Code
387419
func getWorkspaceInfo() async throws -> WorkspaceInfoResult {
388420
let result = try await executeMethod(method: .workspaceInfo, params: .null)
@@ -864,7 +896,26 @@ class BridgeServer: ObservableObject {
864896

865897
private func handleIncomingNotification(_ notification: BridgeNotification, connectionId: String) {
866898
print("[BridgeServer] Received notification: \(notification.method) (connectionId: \(connectionId.prefix(8)))")
867-
// Handle notifications from VS Code if needed (e.g., file changed events)
899+
handleTerminalNotification(notification)
900+
}
901+
902+
private func handleTerminalNotification(_ notification: BridgeNotification) {
903+
guard let params = notification.params else { return }
904+
905+
do {
906+
switch notification.method {
907+
case TerminalBridgeMethod.output:
908+
let output = try params.decodeBridgeValue(TerminalSessionOutputNotification.self)
909+
NotificationCenter.default.post(name: .terminalSessionOutput, object: output)
910+
case TerminalBridgeMethod.exited:
911+
let exited = try params.decodeBridgeValue(TerminalSessionExitedNotification.self)
912+
NotificationCenter.default.post(name: .terminalSessionExited, object: exited)
913+
default:
914+
break
915+
}
916+
} catch {
917+
print("[BridgeServer] Failed to decode terminal notification: \(error)")
918+
}
868919
}
869920

870921
private func handleHello(_ request: BridgeRequest, on connection: NWConnection, connectionId: String) async {

Axon/Services/Bridge/BridgeSettings.swift

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,18 @@ struct BridgeSettings: Codable, Equatable, Sendable {
9595
/// Terminal command timeout (seconds)
9696
var terminalTimeout: Int = 60
9797

98+
/// Prefer the active VS Code workspace as the terminal working directory.
99+
var preferBridgeWorkspaceForTerminal: Bool = true
100+
101+
/// User-configured fallback directory for the bottom terminal drawer.
102+
var terminalDefaultDirectory: String = ""
103+
104+
/// Persisted terminal drawer open state.
105+
var terminalDrawerOpen: Bool = false
106+
107+
/// Persisted terminal drawer height.
108+
var terminalDrawerHeight: Double = 280
109+
98110
/// Optional pairing token required to accept VS Code connections.
99111
///
100112
/// When set (non-empty), Axon will reject hello handshakes that do not present
@@ -533,6 +545,22 @@ class BridgeSettingsStorage: ObservableObject {
533545
settings.terminalTimeout = max(5, seconds)
534546
}
535547

548+
func setPreferBridgeWorkspaceForTerminal(_ enabled: Bool) {
549+
settings.preferBridgeWorkspaceForTerminal = enabled
550+
}
551+
552+
func setTerminalDefaultDirectory(_ path: String) {
553+
settings.terminalDefaultDirectory = path.trimmingCharacters(in: .whitespacesAndNewlines)
554+
}
555+
556+
func setTerminalDrawerOpen(_ open: Bool) {
557+
settings.terminalDrawerOpen = open
558+
}
559+
560+
func setTerminalDrawerHeight(_ height: Double) {
561+
settings.terminalDrawerHeight = min(max(height, 180), 640)
562+
}
563+
536564
func setBlockedPatterns(_ patterns: [String]) {
537565
settings.blockedPatterns = patterns
538566
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
//
2+
// BridgeTerminalTransport.swift
3+
// Axon
4+
//
5+
// Remote terminal transport that streams through the VS Code bridge.
6+
//
7+
8+
import Foundation
9+
import Combine
10+
11+
@MainActor
12+
final class BridgeTerminalTransport: TerminalTransport {
13+
private let connectionManager: BridgeConnectionManager
14+
private let outputSubject = PassthroughSubject<String, Never>()
15+
private let exitSubject = PassthroughSubject<Int?, Never>()
16+
private var cancellables = Set<AnyCancellable>()
17+
private var sessionId: String?
18+
19+
var outputPublisher: AnyPublisher<String, Never> {
20+
outputSubject.eraseToAnyPublisher()
21+
}
22+
23+
var exitPublisher: AnyPublisher<Int?, Never> {
24+
exitSubject.eraseToAnyPublisher()
25+
}
26+
27+
init(connectionManager: BridgeConnectionManager) {
28+
self.connectionManager = connectionManager
29+
30+
NotificationCenter.default.publisher(for: .terminalSessionOutput)
31+
.compactMap { $0.object as? TerminalSessionOutputNotification }
32+
.sink { [weak self] notification in
33+
guard let self, notification.sessionId == self.sessionId else { return }
34+
self.outputSubject.send(notification.data)
35+
}
36+
.store(in: &cancellables)
37+
38+
NotificationCenter.default.publisher(for: .terminalSessionExited)
39+
.compactMap { $0.object as? TerminalSessionExitedNotification }
40+
.sink { [weak self] notification in
41+
guard let self, notification.sessionId == self.sessionId else { return }
42+
self.exitSubject.send(notification.exitCode)
43+
}
44+
.store(in: &cancellables)
45+
}
46+
47+
func start(cwd: String, cols: Int, rows: Int) async throws -> TerminalSessionStartResult {
48+
guard connectionManager.isConnected else {
49+
throw TerminalSessionError.bridgeNotConnected
50+
}
51+
52+
let params = TerminalSessionStartParams(cwd: cwd, cols: cols, rows: rows, shell: nil)
53+
let result = try await connectionManager.startTerminalSession(params)
54+
sessionId = result.sessionId
55+
return result
56+
}
57+
58+
func sendInput(_ data: String) async throws {
59+
guard let sessionId else {
60+
throw TerminalSessionError.sessionNotStarted
61+
}
62+
try await connectionManager.sendTerminalInput(
63+
TerminalSessionInputParams(sessionId: sessionId, data: data)
64+
)
65+
}
66+
67+
func resize(cols: Int, rows: Int) async throws {
68+
guard let sessionId else {
69+
throw TerminalSessionError.sessionNotStarted
70+
}
71+
try await connectionManager.resizeTerminalSession(
72+
TerminalSessionResizeParams(sessionId: sessionId, cols: cols, rows: rows)
73+
)
74+
}
75+
76+
func close() async {
77+
guard let sessionId else { return }
78+
try? await connectionManager.closeTerminalSession(TerminalSessionCloseParams(sessionId: sessionId))
79+
self.sessionId = nil
80+
}
81+
}

0 commit comments

Comments
 (0)