From 5cf06b7df775e097e49fcf6a716e8b8827931d04 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 01:02:46 -0600 Subject: [PATCH 1/3] feat: implement Terminal views with WebSocket streaming and input bar - TerminalView subscribes to WebSocket terminal events for a specific agent - Initial log fetch via REST (GET /api/agents/:id/logs, last 200 lines) - Auto-scroll to bottom on new output via ScrollViewReader - Monospace font with black background / green text - Kill button in toolbar with confirmation dialog - TerminalInputBar with monospaced text field + send button - Sends input via WebSocket terminal:input command - Chains onto existing onMessage handler to avoid overwriting AppState's handler Closes #84 --- .../Views/Terminal/TerminalInputBar.swift | 27 ++++ .../Views/Terminal/TerminalView.swift | 118 ++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 ios/PPGMobile/PPGMobile/Views/Terminal/TerminalInputBar.swift create mode 100644 ios/PPGMobile/PPGMobile/Views/Terminal/TerminalView.swift diff --git a/ios/PPGMobile/PPGMobile/Views/Terminal/TerminalInputBar.swift b/ios/PPGMobile/PPGMobile/Views/Terminal/TerminalInputBar.swift new file mode 100644 index 0000000..3cd1e39 --- /dev/null +++ b/ios/PPGMobile/PPGMobile/Views/Terminal/TerminalInputBar.swift @@ -0,0 +1,27 @@ +import SwiftUI + +/// Bottom input bar for sending text to a terminal pane via WebSocket. +struct TerminalInputBar: View { + @Binding var text: String + let onSend: () -> Void + + var body: some View { + HStack(spacing: 8) { + TextField("Send to terminal...", text: $text) + .textFieldStyle(.roundedBorder) + .font(.system(.body, design: .monospaced)) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .onSubmit(onSend) + + Button(action: onSend) { + Image(systemName: "arrow.up.circle.fill") + .font(.title2) + } + .disabled(text.isEmpty) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(.bar) + } +} diff --git a/ios/PPGMobile/PPGMobile/Views/Terminal/TerminalView.swift b/ios/PPGMobile/PPGMobile/Views/Terminal/TerminalView.swift new file mode 100644 index 0000000..279cb3f --- /dev/null +++ b/ios/PPGMobile/PPGMobile/Views/Terminal/TerminalView.swift @@ -0,0 +1,118 @@ +import SwiftUI + +/// Terminal output view that subscribes to WebSocket terminal streaming. +/// Displays raw text output from tmux capture-pane with ANSI stripped server-side. +struct TerminalView: View { + let agentId: String + let agentName: String + + @Environment(AppState.self) private var appState + @State private var terminalOutput = "" + @State private var inputText = "" + @State private var isSubscribed = false + @State private var showKillConfirm = false + @State private var previousOnMessage: ((ServerMessage) -> Void)? + + var body: some View { + VStack(spacing: 0) { + ScrollViewReader { proxy in + ScrollView { + Text(terminalOutput.isEmpty ? "Connecting to terminal..." : terminalOutput) + .font(.system(.caption, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + .id("terminal-bottom") + } + .background(Color.black) + .foregroundStyle(.green) + .onChange(of: terminalOutput) { _, _ in + withAnimation { + proxy.scrollTo("terminal-bottom", anchor: .bottom) + } + } + } + + TerminalInputBar(text: $inputText) { + guard !inputText.isEmpty else { return } + appState.wsManager.sendTerminalInput(agentId: agentId, text: inputText) + inputText = "" + } + } + .navigationTitle(agentName) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Kill", systemImage: "xmark.circle") { + showKillConfirm = true + } + .tint(.red) + .disabled(agentIsTerminal) + } + } + .confirmationDialog("Kill Agent", isPresented: $showKillConfirm) { + Button("Kill Agent", role: .destructive) { + Task { await appState.killAgent(agentId) } + } + Button("Cancel", role: .cancel) {} + } + .onAppear { subscribe() } + .onDisappear { unsubscribe() } + } + + private var agentIsTerminal: Bool { + guard let manifest = appState.manifest else { return true } + for worktree in manifest.worktrees.values { + if let agent = worktree.agents[agentId] { + return agent.status.isTerminal + } + } + return true + } + + private func subscribe() { + guard !isSubscribed else { return } + isSubscribed = true + + // Fetch initial log content + Task { + if let client = appState.client { + do { + let logs = try await client.fetchLogs(agentId: agentId, lines: 200) + terminalOutput = logs.output + } catch { + terminalOutput = "Failed to load logs: \(error.localizedDescription)" + } + } + } + + // Subscribe to live updates via WebSocket + appState.wsManager.subscribeTerminal(agentId: agentId) + + // Chain onto existing message handler to avoid overwriting AppState's handler + previousOnMessage = appState.wsManager.onMessage + let existingHandler = previousOnMessage + appState.wsManager.onMessage = { message in + // Forward to existing handler (AppState) + existingHandler?(message) + + // Handle terminal output for this agent + if message.type == "terminal:output" && message.agentId == agentId { + Task { @MainActor in + if let data = message.data { + terminalOutput += data + } + } + } + } + } + + private func unsubscribe() { + guard isSubscribed else { return } + isSubscribed = false + appState.wsManager.unsubscribeTerminal(agentId: agentId) + + // Restore the previous message handler + appState.wsManager.onMessage = previousOnMessage + previousOnMessage = nil + } +} From b155441ff8b0392eabce621f6aa43ce9df7607d8 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 07:53:57 -0600 Subject: [PATCH 2/3] fix: address code review findings for Terminal views - Extract TerminalViewModel (@Observable @MainActor class) to eliminate @State closure that won't compile (#1) - Use [weak self] captures and @MainActor dispatch for thread safety (#6) - Fix auto-scroll: add invisible bottom anchor below text content so ScrollViewReader scrolls to actual bottom, not text top (#4) - Add .defaultScrollAnchor(.bottom) for initial scroll position - Cap output buffer at 50K chars with newline-boundary trimming (#5) - Bump font from .caption to .footnote for terminal readability (#8) - Add contextual status messages: not connected / loading / waiting (#9) - Enable .textSelection on terminal output - Reorder TerminalInputBar modifiers to place text-input-specific modifiers before .textFieldStyle (#3) - Use .task for async subscribe, .onDisappear for sync cleanup --- .../Views/Terminal/TerminalInputBar.swift | 4 +- .../Views/Terminal/TerminalView.swift | 145 ++++++++++++------ 2 files changed, 102 insertions(+), 47 deletions(-) diff --git a/ios/PPGMobile/PPGMobile/Views/Terminal/TerminalInputBar.swift b/ios/PPGMobile/PPGMobile/Views/Terminal/TerminalInputBar.swift index 3cd1e39..87cabee 100644 --- a/ios/PPGMobile/PPGMobile/Views/Terminal/TerminalInputBar.swift +++ b/ios/PPGMobile/PPGMobile/Views/Terminal/TerminalInputBar.swift @@ -8,10 +8,10 @@ struct TerminalInputBar: View { var body: some View { HStack(spacing: 8) { TextField("Send to terminal...", text: $text) - .textFieldStyle(.roundedBorder) .font(.system(.body, design: .monospaced)) - .autocorrectionDisabled() .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .textFieldStyle(.roundedBorder) .onSubmit(onSend) Button(action: onSend) { diff --git a/ios/PPGMobile/PPGMobile/Views/Terminal/TerminalView.swift b/ios/PPGMobile/PPGMobile/Views/Terminal/TerminalView.swift index 279cb3f..f44196a 100644 --- a/ios/PPGMobile/PPGMobile/Views/Terminal/TerminalView.swift +++ b/ios/PPGMobile/PPGMobile/Views/Terminal/TerminalView.swift @@ -7,30 +7,13 @@ struct TerminalView: View { let agentName: String @Environment(AppState.self) private var appState - @State private var terminalOutput = "" + @State private var viewModel = TerminalViewModel() @State private var inputText = "" - @State private var isSubscribed = false @State private var showKillConfirm = false - @State private var previousOnMessage: ((ServerMessage) -> Void)? var body: some View { VStack(spacing: 0) { - ScrollViewReader { proxy in - ScrollView { - Text(terminalOutput.isEmpty ? "Connecting to terminal..." : terminalOutput) - .font(.system(.caption, design: .monospaced)) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(8) - .id("terminal-bottom") - } - .background(Color.black) - .foregroundStyle(.green) - .onChange(of: terminalOutput) { _, _ in - withAnimation { - proxy.scrollTo("terminal-bottom", anchor: .bottom) - } - } - } + terminalContent TerminalInputBar(text: $inputText) { guard !inputText.isEmpty else { return } @@ -55,8 +38,52 @@ struct TerminalView: View { } Button("Cancel", role: .cancel) {} } - .onAppear { subscribe() } - .onDisappear { unsubscribe() } + .task { await viewModel.subscribe(agentId: agentId, appState: appState) } + .onDisappear { viewModel.unsubscribe(agentId: agentId, wsManager: appState.wsManager) } + } + + @ViewBuilder + private var terminalContent: some View { + ScrollViewReader { proxy in + ScrollView { + VStack(spacing: 0) { + if viewModel.output.isEmpty { + Text(statusMessage) + .font(.system(.footnote, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + } else { + Text(viewModel.output) + .font(.system(.footnote, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + .textSelection(.enabled) + } + Color.clear + .frame(height: 1) + .id("terminal-bottom") + } + } + .defaultScrollAnchor(.bottom) + .background(Color.black) + .foregroundStyle(.green) + .onChange(of: viewModel.output) { _, _ in + withAnimation { + proxy.scrollTo("terminal-bottom", anchor: .bottom) + } + } + } + } + + private var statusMessage: String { + if appState.activeConnection == nil { + return "Not connected to server" + } + if viewModel.isSubscribed { + return "Waiting for output..." + } + return "Loading terminal output..." } private var agentIsTerminal: Bool { @@ -68,51 +95,79 @@ struct TerminalView: View { } return true } +} + +// MARK: - View Model + +/// Manages terminal subscription lifecycle, output buffering, and message handler chaining. +/// Uses @Observable instead of @State closures to avoid type inference issues. +@Observable +@MainActor +final class TerminalViewModel { + var output = "" + var hasError = false + private(set) var isSubscribed = false - private func subscribe() { + private static let maxOutputLength = 50_000 + private var previousOnMessage: ((ServerMessage) -> Void)? + + func subscribe(agentId: String, appState: AppState) async { guard !isSubscribed else { return } isSubscribed = true - // Fetch initial log content - Task { - if let client = appState.client { - do { - let logs = try await client.fetchLogs(agentId: agentId, lines: 200) - terminalOutput = logs.output - } catch { - terminalOutput = "Failed to load logs: \(error.localizedDescription)" - } + // Fetch initial log content via REST + if let client = appState.client { + do { + let logs = try await client.fetchLogs(agentId: agentId, lines: 200) + output = logs.output + trimOutput() + } catch { + output = "Failed to load logs: \(error.localizedDescription)" + hasError = true } } - // Subscribe to live updates via WebSocket - appState.wsManager.subscribeTerminal(agentId: agentId) + // Subscribe to live WebSocket updates + let wsManager = appState.wsManager + wsManager.subscribeTerminal(agentId: agentId) - // Chain onto existing message handler to avoid overwriting AppState's handler - previousOnMessage = appState.wsManager.onMessage + // Chain onto existing message handler so AppState's manifest/status handling + // continues to work. The previous handler is restored in unsubscribe(). + previousOnMessage = wsManager.onMessage let existingHandler = previousOnMessage - appState.wsManager.onMessage = { message in - // Forward to existing handler (AppState) + wsManager.onMessage = { [weak self] message in + // Forward all messages to existing handler (AppState) existingHandler?(message) - // Handle terminal output for this agent + // Append terminal output for this specific agent if message.type == "terminal:output" && message.agentId == agentId { - Task { @MainActor in + Task { @MainActor [weak self] in + guard let self else { return } if let data = message.data { - terminalOutput += data + self.output += data + self.trimOutput() } } } } } - private func unsubscribe() { + func unsubscribe(agentId: String, wsManager: WebSocketManager) { guard isSubscribed else { return } isSubscribed = false - appState.wsManager.unsubscribeTerminal(agentId: agentId) - - // Restore the previous message handler - appState.wsManager.onMessage = previousOnMessage + wsManager.unsubscribeTerminal(agentId: agentId) + wsManager.onMessage = previousOnMessage previousOnMessage = nil } + + /// Keep output within bounds, trimming at a newline boundary when possible. + private func trimOutput() { + guard output.count > Self.maxOutputLength else { return } + let startIndex = output.index(output.endIndex, offsetBy: -Self.maxOutputLength) + if let newlineIndex = output[startIndex...].firstIndex(of: "\n") { + output = String(output[output.index(after: newlineIndex)...]) + } else { + output = String(output[startIndex...]) + } + } } From 72b12246c769d6edfbac2a346efa6e0bcadefaf0 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 08:36:11 -0600 Subject: [PATCH 3/3] Fix terminal WebSocket handler multiplexing --- .../Views/Terminal/TerminalView.swift | 106 ++++++++++++++---- 1 file changed, 84 insertions(+), 22 deletions(-) diff --git a/ios/PPGMobile/PPGMobile/Views/Terminal/TerminalView.swift b/ios/PPGMobile/PPGMobile/Views/Terminal/TerminalView.swift index f44196a..8427556 100644 --- a/ios/PPGMobile/PPGMobile/Views/Terminal/TerminalView.swift +++ b/ios/PPGMobile/PPGMobile/Views/Terminal/TerminalView.swift @@ -1,3 +1,4 @@ +import Foundation import SwiftUI /// Terminal output view that subscribes to WebSocket terminal streaming. @@ -109,7 +110,7 @@ final class TerminalViewModel { private(set) var isSubscribed = false private static let maxOutputLength = 50_000 - private var previousOnMessage: ((ServerMessage) -> Void)? + private var subscriptionID: UUID? func subscribe(agentId: String, appState: AppState) async { guard !isSubscribed else { return } @@ -129,35 +130,27 @@ final class TerminalViewModel { // Subscribe to live WebSocket updates let wsManager = appState.wsManager - wsManager.subscribeTerminal(agentId: agentId) - - // Chain onto existing message handler so AppState's manifest/status handling - // continues to work. The previous handler is restored in unsubscribe(). - previousOnMessage = wsManager.onMessage - let existingHandler = previousOnMessage - wsManager.onMessage = { [weak self] message in - // Forward all messages to existing handler (AppState) - existingHandler?(message) - - // Append terminal output for this specific agent - if message.type == "terminal:output" && message.agentId == agentId { - Task { @MainActor [weak self] in - guard let self else { return } - if let data = message.data { - self.output += data - self.trimOutput() - } - } + subscriptionID = TerminalMessageRouter.shared.addSubscriber(wsManager: wsManager) { [weak self] message in + guard message.type == "terminal:output", message.agentId == agentId, let data = message.data else { + return + } + Task { @MainActor [weak self] in + guard let self else { return } + self.output += data + self.trimOutput() } } + wsManager.subscribeTerminal(agentId: agentId) } func unsubscribe(agentId: String, wsManager: WebSocketManager) { guard isSubscribed else { return } isSubscribed = false wsManager.unsubscribeTerminal(agentId: agentId) - wsManager.onMessage = previousOnMessage - previousOnMessage = nil + if let subscriptionID { + TerminalMessageRouter.shared.removeSubscriber(wsManager: wsManager, subscriberID: subscriptionID) + self.subscriptionID = nil + } } /// Keep output within bounds, trimming at a newline boundary when possible. @@ -171,3 +164,72 @@ final class TerminalViewModel { } } } + +private struct TerminalRouterState { + var previousOnMessage: ((ServerMessage) -> Void)? + var subscribers: [UUID: (ServerMessage) -> Void] +} + +/// Multiplexes WebSocket messages so multiple terminal views can subscribe safely. +private final class TerminalMessageRouter { + static let shared = TerminalMessageRouter() + + private let lock = NSLock() + private var states: [ObjectIdentifier: TerminalRouterState] = [:] + + private init() {} + + func addSubscriber( + wsManager: WebSocketManager, + subscriber: @escaping (ServerMessage) -> Void + ) -> UUID { + let managerID = ObjectIdentifier(wsManager) + let subscriberID = UUID() + + lock.lock() + if states[managerID] == nil { + let previousOnMessage = wsManager.onMessage + states[managerID] = TerminalRouterState(previousOnMessage: previousOnMessage, subscribers: [:]) + wsManager.onMessage = { [weak self] message in + self?.dispatch(message: message, managerID: managerID) + } + } + states[managerID]?.subscribers[subscriberID] = subscriber + lock.unlock() + + return subscriberID + } + + func removeSubscriber(wsManager: WebSocketManager, subscriberID: UUID) { + let managerID = ObjectIdentifier(wsManager) + + lock.lock() + guard var state = states[managerID] else { + lock.unlock() + return + } + + state.subscribers.removeValue(forKey: subscriberID) + if state.subscribers.isEmpty { + states.removeValue(forKey: managerID) + lock.unlock() + wsManager.onMessage = state.previousOnMessage + return + } + + states[managerID] = state + lock.unlock() + } + + private func dispatch(message: ServerMessage, managerID: ObjectIdentifier) { + lock.lock() + let state = states[managerID] + let subscribers = state?.subscribers.values.map { $0 } ?? [] + lock.unlock() + + state?.previousOnMessage?(message) + for subscriber in subscribers { + subscriber(message) + } + } +}