vibetunnel/ios/VibeTunnel/Views/Terminal/TerminalView.swift
Peter Steinberger baaaa5a033 fix: CI and linting issues across all platforms
- Fix code signing in Mac and iOS test workflows
- Fix all SwiftFormat and SwiftLint issues
- Fix ESLint issues in web code
- Remove force casts and unwrapping in Swift code
- Update build scripts to use correct file paths
2025-06-23 19:40:53 +02:00

907 lines
32 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Observation
import SwiftTerm
import SwiftUI
private let logger = Logger(category: "TerminalView")
/// Interactive terminal view for a session.
///
/// Displays a full terminal emulator using SwiftTerm with support for
/// input, output, recording, and font size adjustment.
struct TerminalView: View {
let session: Session
@Environment(\.dismiss)
var dismiss
@State private var viewModel: TerminalViewModel
@State private var fontSize: CGFloat = 14
@State private var showingFontSizeSheet = false
@State private var showingRecordingSheet = false
@State private var showingTerminalWidthSheet = false
@State private var showingTerminalThemeSheet = false
@State private var selectedTerminalWidth: Int?
@State private var selectedTheme = TerminalTheme.selected
@State private var keyboardHeight: CGFloat = 0
@State private var showScrollToBottom = false
@State private var showingFileBrowser = false
@State private var selectedRenderer = TerminalRenderer.selected
@State private var showingDebugMenu = false
@State private var showingExportSheet = false
@State private var exportedFileURL: URL?
@State private var showingWidthSelector = false
@State private var currentTerminalWidth: TerminalWidth = .unlimited
@State private var showingFullscreenInput = false
@State private var showingCtrlKeyGrid = false
@FocusState private var isInputFocused: Bool
init(session: Session) {
self.session = session
self._viewModel = State(initialValue: TerminalViewModel(session: session))
}
var body: some View {
NavigationStack {
mainContent
.navigationTitle(session.displayName)
.navigationBarTitleDisplayMode(.inline)
.toolbar(.visible, for: .bottomBar)
.toolbarBackground(.automatic, for: .bottomBar)
.toolbar {
navigationToolbarItems
bottomToolbarItems
recordingIndicator
}
}
.preferredColorScheme(.dark)
.focusable()
.onAppear {
viewModel.connect()
isInputFocused = true
}
.onDisappear {
viewModel.disconnect()
}
.sheet(isPresented: $showingFontSizeSheet) {
FontSizeSheet(fontSize: $fontSize)
}
.sheet(isPresented: $showingRecordingSheet) {
RecordingExportSheet(recorder: viewModel.castRecorder, sessionName: session.displayName)
}
.sheet(isPresented: $showingTerminalWidthSheet) {
TerminalWidthSheet(
selectedWidth: $selectedTerminalWidth,
isResizeBlockedByServer: viewModel.isResizeBlockedByServer
)
.onAppear {
selectedTerminalWidth = viewModel.terminalCols
}
}
.sheet(isPresented: $showingTerminalThemeSheet) {
TerminalThemeSheet(selectedTheme: $selectedTheme)
}
.sheet(isPresented: $showingFileBrowser) {
FileBrowserView(
initialPath: session.workingDir,
mode: .insertPath,
onSelect: { _ in
showingFileBrowser = false
},
onInsertPath: { [weak viewModel] path, _ in
// Insert the path into the terminal
viewModel?.sendInput(path)
showingFileBrowser = false
}
)
}
.sheet(isPresented: $showingFullscreenInput) {
FullscreenTextInput(isPresented: $showingFullscreenInput) { [weak viewModel] text in
viewModel?.sendInput(text)
}
}
.sheet(isPresented: $showingCtrlKeyGrid) {
CtrlKeyGrid(isPresented: $showingCtrlKeyGrid) { [weak viewModel] controlChar in
viewModel?.sendInput(controlChar)
}
}
.gesture(
DragGesture()
.onEnded { value in
if value.startLocation.x < 20 && value.translation.width > 50 {
dismiss()
HapticFeedback.impact(.light)
}
}
)
.task {
for await notification in NotificationCenter.default
.notifications(named: UIResponder.keyboardWillShowNotification)
{
if let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect {
withAnimation(Theme.Animation.standard) {
keyboardHeight = keyboardFrame.height
}
}
}
}
.task {
for await _ in NotificationCenter.default.notifications(named: UIResponder.keyboardWillHideNotification) {
withAnimation(Theme.Animation.standard) {
keyboardHeight = 0
}
}
}
.onChange(of: selectedTerminalWidth) { _, newValue in
if let width = newValue, width != viewModel.terminalCols {
let aspectRatio = Double(viewModel.terminalRows) / Double(viewModel.terminalCols)
let newHeight = Int(Double(width) * aspectRatio)
viewModel.resize(cols: width, rows: newHeight)
}
}
.onChange(of: currentTerminalWidth) { _, newWidth in
let targetWidth = newWidth.value == 0 ? nil : newWidth.value
if targetWidth != selectedTerminalWidth {
selectedTerminalWidth = targetWidth
viewModel.setMaxWidth(targetWidth ?? 0)
TerminalWidthManager.shared.defaultWidth = newWidth.value
}
}
.onChange(of: viewModel.isAtBottom) { _, newValue in
withAnimation(Theme.Animation.smooth) {
showScrollToBottom = !newValue
}
}
.onKeyPress(keys: ["o"]) { press in
if press.modifiers.contains(.command) && session.isRunning {
showingFileBrowser = true
return .handled
}
return .ignored
}
.sheet(isPresented: $showingExportSheet) {
if let url = exportedFileURL {
ShareSheet(items: [url])
.onDisappear {
// Clean up temporary file
try? FileManager.default.removeItem(at: url)
exportedFileURL = nil
}
}
}
}
// MARK: - Export Functions
private func exportTerminalBuffer() {
guard let bufferContent = viewModel.getBufferContent() else { return }
let fileName = "\(session.displayName)_\(Date().timeIntervalSince1970).txt"
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(fileName)
do {
try bufferContent.write(to: tempURL, atomically: true, encoding: .utf8)
exportedFileURL = tempURL
showingExportSheet = true
} catch {
logger.error("Failed to export terminal buffer: \(error)")
}
}
// MARK: - View Components
private var mainContent: some View {
ZStack {
selectedTheme.background
.ignoresSafeArea()
VStack(spacing: 0) {
if viewModel.isConnecting {
loadingView
} else if let error = viewModel.errorMessage {
errorView(error)
} else {
terminalContent
}
}
}
}
private var navigationToolbarItems: some ToolbarContent {
Group {
ToolbarItem(placement: .navigationBarLeading) {
Button("Close") {
dismiss()
}
.foregroundColor(Theme.Colors.primaryAccent)
}
ToolbarItemGroup(placement: .navigationBarTrailing) {
QuickFontSizeButtons(fontSize: $fontSize)
.fixedSize()
fileBrowserButton
widthSelectorButton
menuButton
}
}
}
private var bottomToolbarItems: some ToolbarContent {
ToolbarItemGroup(placement: .bottomBar) {
terminalSizeIndicator
Spacer()
}
}
private var recordingIndicator: some ToolbarContent {
ToolbarItem(placement: .navigationBarTrailing) {
if viewModel.castRecorder.isRecording {
recordingView
}
}
}
// MARK: - Toolbar Components
private var fileBrowserButton: some View {
Button(action: {
HapticFeedback.impact(.light)
showingFileBrowser = true
}, label: {
Image(systemName: "folder")
.font(.system(size: 16))
.foregroundColor(Theme.Colors.primaryAccent)
})
}
private var widthSelectorButton: some View {
Button(action: { showingWidthSelector = true }, label: {
HStack(spacing: 2) {
Image(systemName: "arrow.left.and.right")
.font(.system(size: 12))
Text(currentTerminalWidth.label)
.font(Theme.Typography.terminalSystem(size: 14))
.fontWeight(.medium)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Theme.Colors.cardBackground)
.cornerRadius(6)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Theme.Colors.primaryAccent.opacity(0.3), lineWidth: 1)
)
})
.foregroundColor(Theme.Colors.primaryAccent)
.popover(isPresented: $showingWidthSelector, arrowEdge: .top) {
WidthSelectorPopover(
currentWidth: $currentTerminalWidth,
isPresented: $showingWidthSelector
)
}
}
private var menuButton: some View {
Menu {
terminalMenuItems
} label: {
Image(systemName: "ellipsis.circle")
.foregroundColor(Theme.Colors.primaryAccent)
}
}
@ViewBuilder
private var terminalMenuItems: some View {
Button(action: { viewModel.clearTerminal() }, label: {
Label("Clear", systemImage: "clear")
})
Button(action: { showingFullscreenInput = true }, label: {
Label("Compose Command", systemImage: "text.viewfinder")
})
Button(action: { showingCtrlKeyGrid = true }, label: {
Label("Ctrl Shortcuts", systemImage: "command.square")
})
Divider()
Menu {
Button(action: {
fontSize = max(8, fontSize - 1)
HapticFeedback.impact(.light)
}, label: {
Label("Decrease", systemImage: "minus")
})
.disabled(fontSize <= 8)
Button(action: {
fontSize = min(32, fontSize + 1)
HapticFeedback.impact(.light)
}, label: {
Label("Increase", systemImage: "plus")
})
.disabled(fontSize >= 32)
Button(action: {
fontSize = 14
HapticFeedback.impact(.light)
}, label: {
Label("Reset to Default", systemImage: "arrow.counterclockwise")
})
.disabled(fontSize == 14)
Divider()
Button(action: { showingFontSizeSheet = true }, label: {
Label("More Options...", systemImage: "slider.horizontal.3")
})
} label: {
Label("Font Size (\(Int(fontSize))pt)", systemImage: "textformat.size")
}
Button(action: { showingTerminalWidthSheet = true }, label: {
Label("Terminal Width", systemImage: "arrow.left.and.right")
})
Button(action: { viewModel.toggleFitToWidth() }, label: {
Label(
viewModel.fitToWidth ? "Fixed Width" : "Fit to Width",
systemImage: viewModel.fitToWidth ? "arrow.left.and.right.square" : "arrow.left.and.right.square.fill"
)
})
Button(action: { showingTerminalThemeSheet = true }, label: {
Label("Theme", systemImage: "paintbrush")
})
Button(action: { viewModel.copyBuffer() }, label: {
Label("Copy All", systemImage: "square.on.square")
})
Button(action: { exportTerminalBuffer() }, label: {
Label("Export as Text", systemImage: "square.and.arrow.up")
})
Divider()
recordingMenuItems
Divider()
debugMenuItems
}
@ViewBuilder
private var recordingMenuItems: some View {
if viewModel.castRecorder.isRecording {
Button(action: {
viewModel.stopRecording()
showingRecordingSheet = true
}, label: {
Label("Stop Recording", systemImage: "stop.circle.fill")
.foregroundColor(.red)
})
} else {
Button(action: { viewModel.startRecording() }, label: {
Label("Start Recording", systemImage: "record.circle")
})
}
Button(action: { showingRecordingSheet = true }, label: {
Label("Export Recording", systemImage: "square.and.arrow.up")
})
.disabled(viewModel.castRecorder.events.isEmpty)
}
@ViewBuilder
private var debugMenuItems: some View {
Menu {
ForEach(TerminalRenderer.allCases, id: \.self) { renderer in
Button(action: {
selectedRenderer = renderer
TerminalRenderer.selected = renderer
viewModel.terminalViewId = UUID() // Force recreate terminal view
}, label: {
HStack {
Text(renderer.displayName)
if renderer == selectedRenderer {
Image(systemName: "checkmark")
}
}
})
}
} label: {
Label("Terminal Renderer", systemImage: "gearshape.2")
}
}
@ViewBuilder
private var terminalSizeIndicator: some View {
if viewModel.terminalCols > 0 && viewModel.terminalRows > 0 {
Text("\(viewModel.terminalCols)×\(viewModel.terminalRows)")
.font(Theme.Typography.terminalSystem(size: 11))
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
}
}
private var recordingView: some View {
HStack(spacing: 4) {
Circle()
.fill(Theme.Colors.errorAccent)
.frame(width: 8, height: 8)
.overlay(
Circle()
.fill(Theme.Colors.errorAccent.opacity(0.3))
.frame(width: 16, height: 16)
.scaleEffect(viewModel.recordingPulse ? 1.5 : 1.0)
.animation(
.easeInOut(duration: 1.0).repeatForever(autoreverses: true),
value: viewModel.recordingPulse
)
)
Text("REC")
.font(.system(size: 12, weight: .bold))
.foregroundColor(Theme.Colors.errorAccent)
}
.onAppear {
viewModel.recordingPulse = true
}
}
private var loadingView: some View {
VStack(spacing: Theme.Spacing.large) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors.primaryAccent))
.scaleEffect(1.5)
Text("Connecting to session...")
.font(Theme.Typography.terminalSystem(size: 14))
.foregroundColor(Theme.Colors.terminalForeground)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func errorView(_ error: String) -> some View {
VStack(spacing: Theme.Spacing.large) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 48))
.foregroundColor(Theme.Colors.errorAccent)
Text("Connection Error")
.font(.headline)
.foregroundColor(Theme.Colors.terminalForeground)
Text(error)
.font(Theme.Typography.terminalSystem(size: 12))
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
.multilineTextAlignment(.center)
.padding(.horizontal)
Button("Retry") {
viewModel.connect()
}
.terminalButton()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var terminalContent: some View {
VStack(spacing: 0) {
// Terminal view based on selected renderer
Group {
switch selectedRenderer {
case .swiftTerm:
TerminalHostingView(
session: session,
fontSize: $fontSize,
theme: selectedTheme,
onInput: { text in
viewModel.sendInput(text)
},
onResize: { cols, rows in
viewModel.terminalCols = cols
viewModel.terminalRows = rows
viewModel.resize(cols: cols, rows: rows)
},
viewModel: viewModel
)
case .xterm:
XtermWebView(
session: session,
fontSize: $fontSize,
theme: selectedTheme,
onInput: { text in
viewModel.sendInput(text)
},
onResize: { cols, rows in
viewModel.terminalCols = cols
viewModel.terminalRows = rows
viewModel.resize(cols: cols, rows: rows)
},
viewModel: viewModel
)
}
}
.id(viewModel.terminalViewId)
.background(selectedTheme.background)
.focused($isInputFocused)
.overlay(
ScrollToBottomButton(
isVisible: showScrollToBottom
) {
viewModel.scrollToBottom()
showScrollToBottom = false
}
.padding(.bottom, Theme.Spacing.large)
.padding(.leading, Theme.Spacing.large),
alignment: .bottomLeading
)
// Keyboard toolbar
if keyboardHeight > 0 {
TerminalToolbar(
onSpecialKey: { key in
viewModel.sendInput(key.rawValue)
},
onDismissKeyboard: {
isInputFocused = false
},
onRawInput: { input in
viewModel.sendInput(input)
}
)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
}
}
/// View model for terminal session management.
@MainActor
@Observable
class TerminalViewModel {
var isConnecting = true
var isConnected = false
var errorMessage: String?
var terminalViewId = UUID()
var terminalCols: Int = 0
var terminalRows: Int = 0
var isAutoScrollEnabled = true
var recordingPulse = false
var isResizeBlockedByServer = false
var isAtBottom = true
var fitToWidth = false
let session: Session
let castRecorder: CastRecorder
var bufferWebSocketClient: BufferWebSocketClient?
private var connectionStatusTask: Task<Void, Never>?
private var connectionErrorTask: Task<Void, Never>?
weak var terminalCoordinator: AnyObject? // Can be TerminalHostingView.Coordinator
init(session: Session) {
self.session = session
self.castRecorder = CastRecorder(sessionId: session.id, width: 80, height: 24)
setupTerminal()
}
private func setupTerminal() {
// Terminal setup now handled by SimpleTerminalView
}
func startRecording() {
castRecorder.startRecording()
}
func stopRecording() {
castRecorder.stopRecording()
}
func connect() {
isConnecting = true
errorMessage = nil
// Create WebSocket client if needed
if bufferWebSocketClient == nil {
bufferWebSocketClient = BufferWebSocketClient()
}
// Connect to WebSocket
bufferWebSocketClient?.connect()
// Load initial snapshot after a brief delay to ensure terminal is ready
Task { @MainActor in
// Wait for terminal view to be initialized
try? await Task.sleep(nanoseconds: 200_000_000) // 0.2s
await loadSnapshot()
}
// Subscribe to terminal events
bufferWebSocketClient?.subscribe(to: session.id) { [weak self] event in
Task { @MainActor in
self?.handleWebSocketEvent(event)
}
}
// Monitor connection status
connectionStatusTask?.cancel()
connectionStatusTask = Task { [weak self] in
guard let client = self?.bufferWebSocketClient else { return }
while !Task.isCancelled {
let connected = client.isConnected
await MainActor.run {
self?.isConnecting = false
self?.isConnected = connected
if !connected {
self?.errorMessage = "WebSocket disconnected"
} else {
self?.errorMessage = nil
}
}
try? await Task.sleep(nanoseconds: 500_000_000) // Check every 0.5 seconds
}
}
// Monitor connection errors
connectionErrorTask?.cancel()
connectionErrorTask = Task { [weak self] in
guard let client = self?.bufferWebSocketClient else { return }
while !Task.isCancelled {
if let error = client.connectionError {
await MainActor.run {
self?.errorMessage = error.localizedDescription
self?.isConnecting = false
}
}
try? await Task.sleep(nanoseconds: 500_000_000) // Check every 0.5 seconds
}
}
}
@MainActor
private func loadSnapshot() async {
do {
let snapshot = try await APIClient.shared.getSessionSnapshot(sessionId: session.id)
// Process the snapshot events
if let header = snapshot.header {
// Initialize terminal with dimensions from header
terminalCols = header.width
terminalRows = header.height
logger.debug("Snapshot header: \(header.width)x\(header.height)")
}
// Feed all output events to the terminal
for event in snapshot.events {
if event.type == .output {
// Feed the actual terminal output data
if let coordinator = terminalCoordinator as? TerminalHostingView.Coordinator {
coordinator.feedData(event.data)
}
}
}
} catch {
logger.error("Failed to load terminal snapshot: \(error)")
}
}
func disconnect() {
connectionStatusTask?.cancel()
connectionErrorTask?.cancel()
bufferWebSocketClient?.unsubscribe(from: session.id)
bufferWebSocketClient?.disconnect()
bufferWebSocketClient = nil
isConnected = false
}
@MainActor
private func handleWebSocketEvent(_ event: TerminalWebSocketEvent) {
switch event {
case .header(let width, let height):
// Initial terminal setup
logger.info("Terminal initialized: \(width)x\(height)")
terminalCols = width
terminalRows = height
// The terminal will be resized when created
case .output(_, let data):
// Feed output data directly to the terminal
if let coordinator = terminalCoordinator as? TerminalHostingView.Coordinator {
coordinator.feedData(data)
} else {
// Queue the data to be fed once coordinator is ready
logger.warning("Terminal coordinator not ready, queueing data")
Task {
// Wait a bit for coordinator to be initialized
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1s
if let coordinator = self.terminalCoordinator as? TerminalHostingView.Coordinator {
coordinator.feedData(data)
}
}
}
// Record output if recording
castRecorder.recordOutput(data)
case .resize(_, let dimensions):
// Parse dimensions like "120x30"
let parts = dimensions.split(separator: "x")
if parts.count == 2,
let cols = Int(parts[0]),
let rows = Int(parts[1])
{
// Update terminal dimensions
terminalCols = cols
terminalRows = rows
logger.info("Terminal resize: \(cols)x\(rows)")
// Record resize event
castRecorder.recordResize(cols: cols, rows: rows)
}
case .exit(let code):
// Session has exited
isConnected = false
if code != 0 {
errorMessage = "Session exited with code \(code)"
}
// Stop recording if active
if castRecorder.isRecording {
stopRecording()
}
// Load final snapshot for exited session
Task { @MainActor in
// Give the server a moment to finalize the snapshot
try? await Task.sleep(nanoseconds: 500_000_000) // 0.5s
await loadSnapshot()
}
case .bufferUpdate(let snapshot):
// Update terminal buffer directly
if let coordinator = terminalCoordinator as? TerminalHostingView.Coordinator {
coordinator.updateBuffer(from: TerminalHostingView.BufferSnapshot(
cols: snapshot.cols,
rows: snapshot.rows,
viewportY: snapshot.viewportY,
cursorX: snapshot.cursorX,
cursorY: snapshot.cursorY,
cells: snapshot.cells.map { row in
row.map { cell in
TerminalHostingView.BufferCell(
char: cell.char,
width: cell.width,
fg: cell.fg,
bg: cell.bg,
attributes: cell.attributes
)
}
}
))
} else {
// Fallback: buffer updates not available yet
logger.warning("Direct buffer update not available")
}
case .bell:
// Terminal bell - play sound and/or haptic feedback
handleTerminalBell()
case .alert(let title, let message):
// Terminal alert - show notification
handleTerminalAlert(title: title, message: message)
}
}
func sendInput(_ text: String) {
Task {
do {
try await SessionService.shared.sendInput(to: session.id, text: text)
} catch {
logger.error("Failed to send input: \(error)")
}
}
}
func resize(cols: Int, rows: Int) {
Task {
do {
try await SessionService.shared.resizeTerminal(sessionId: session.id, cols: cols, rows: rows)
// If resize succeeded, ensure the flag is cleared
isResizeBlockedByServer = false
} catch {
logger.error("Failed to resize terminal: \(error)")
// Check if the error is specifically about resize being disabled
if case APIError.resizeDisabledByServer = error {
isResizeBlockedByServer = true
}
}
}
}
func clearTerminal() {
// Reset the terminal by recreating it
terminalViewId = UUID()
HapticFeedback.impact(.medium)
}
func copyBuffer() {
// Terminal copy is handled by SwiftTerm's built-in functionality
HapticFeedback.notification(.success)
}
func getBufferContent() -> String? {
// Get the current terminal buffer content
if let coordinator = terminalCoordinator as? TerminalHostingView.Coordinator {
return coordinator.getBufferContent()
}
return nil
}
@MainActor
private func handleTerminalBell() {
// Haptic feedback for bell
HapticFeedback.notification(.warning)
// Visual bell - flash the terminal briefly
withAnimation(.easeInOut(duration: 0.1)) {
// SwiftTerm handles visual bell internally
// but we can add additional feedback if needed
}
}
@MainActor
private func handleTerminalAlert(title: String?, message: String) {
// Log the alert
logger.info("Terminal Alert - \(title ?? "Alert"): \(message)")
// Show as a system notification if app is in background
// For now, just provide haptic feedback
HapticFeedback.notification(.error)
}
func scrollToBottom() {
// Signal the terminal to scroll to bottom
isAutoScrollEnabled = true
isAtBottom = true
// The actual scrolling is handled by the terminal coordinator
if let coordinator = terminalCoordinator as? TerminalHostingView.Coordinator {
coordinator.scrollToBottom()
}
}
func updateScrollState(isAtBottom: Bool) {
self.isAtBottom = isAtBottom
self.isAutoScrollEnabled = isAtBottom
}
func toggleFitToWidth() {
fitToWidth.toggle()
HapticFeedback.impact(.light)
if fitToWidth {
// Calculate optimal width to fit the screen
let screenWidth = UIScreen.main.bounds.width
let padding: CGFloat = 32 // Account for UI padding
let charWidth: CGFloat = 9 // Approximate character width
let optimalCols = Int((screenWidth - padding) / charWidth)
// Resize to fit
resize(cols: optimalCols, rows: terminalRows)
}
}
func setMaxWidth(_ maxWidth: Int) {
// Store the max width preference
// When maxWidth is 0, it means unlimited
let targetWidth = maxWidth == 0 ? nil : maxWidth
if let width = targetWidth, width != terminalCols {
// Maintain aspect ratio when changing width
let aspectRatio = Double(terminalRows) / Double(terminalCols)
let newHeight = Int(Double(width) * aspectRatio)
resize(cols: width, rows: newHeight)
}
// Update the terminal coordinator if using constrained width
if let coordinator = terminalCoordinator as? TerminalHostingView.Coordinator {
coordinator.setMaxWidth(maxWidth)
}
}
}