Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,18 @@ import SwiftUI
/// The compact toolbar-principal header: a leading state indicator beside a
/// two-line title (workspace name over tab name).
///
/// State is carried entirely by the indicator (color + motion + a symbol for
/// the two "attention" states), not words, so the narrow nav-bar center can
/// spend its width on the names rather than on "needs input ·". VoiceOver
/// still hears the full state via the accessibility value.
/// State is carried entirely by the indicator (color, motion, and shape), not
/// words, so the narrow nav-bar center can spend its width on the names rather
/// than on "needs input ·". VoiceOver still hears the full state via the
/// accessibility value.
public struct ChatSessionHeaderView: View {
private let descriptor: ChatSessionDescriptor
private let agentState: ChatAgentState
private let isConnected: Bool
private let titleOverride: String?
private let subtitle: String?
private let style: Style
private let toolbarForegroundColor: Color?

/// Creates a session header.
///
Expand All @@ -35,19 +36,26 @@ public struct ChatSessionHeaderView: View {
isConnected: Bool,
titleOverride: String? = nil,
subtitle: String? = nil,
style: Style = .regular
style: Style = .regular,
toolbarForegroundColor: Color? = nil
) {
self.descriptor = descriptor
self.agentState = agentState
self.isConnected = isConnected
self.titleOverride = titleOverride
self.subtitle = subtitle
self.style = style
self.toolbarForegroundColor = toolbarForegroundColor
}

public var body: some View {
HStack(spacing: 6) {
ChatStateIndicatorView(state: agentState, isConnected: isConnected, size: indicatorSize)
ChatStateIndicatorView(
state: agentState,
isConnected: isConnected,
size: indicatorSize,
foregroundColor: compactForegroundColor
)
titleStack
}
.padding(.horizontal, horizontalContentPadding)
Expand Down Expand Up @@ -83,7 +91,18 @@ public struct ChatSessionHeaderView: View {
}
}
case .toolbarCompact:
MobileCompactToolbarTitleStack(title: title, subtitle: subtitleLine)
MobileCompactToolbarTitleStack(
title: title,
subtitle: subtitleLine,
foregroundColor: toolbarForegroundColor
)
}
}

private var compactForegroundColor: Color? {
switch style {
case .regular: nil
case .toolbarCompact: toolbarForegroundColor
}
}

Expand Down Expand Up @@ -148,16 +167,16 @@ public struct ChatSessionHeaderView: View {
}
}

/// The header's state glyph: color + motion for the two ambient states
/// (working pulses green, idle is a filled gray dot), and a distinct SF
/// Symbol shape for the two meaningful ones (needs-input is an orange
/// question mark, ended is a hollow ring) so the four states are
/// distinguishable by shape and motion, not color alone. While
/// reconnecting it desaturates and breathes regardless of state.
/// The header's state glyph uses color and motion in regular headers (working
/// pulses green, idle is a filled gray dot) and static shape in monochrome
/// toolbar headers (working is solid, idle is an inset ring). Needs-input is a
/// question-mark symbol and ended is a full-size ring in either palette. While
/// reconnecting the glyph desaturates and breathes regardless of state.
struct ChatStateIndicatorView: View {
let state: ChatAgentState
let isConnected: Bool
let size: CGFloat
var foregroundColor: Color? = nil

@State private var pulseDimmed = false

Expand Down Expand Up @@ -201,15 +220,27 @@ struct ChatStateIndicatorView: View {
private var glyph: some View {
switch state {
case .working:
Circle().fill(Color.green)
Circle().fill(foregroundColor ?? .green)
case .idle:
Circle().fill(Color.secondary)
if let foregroundColor {
Circle()
.stroke(foregroundColor, lineWidth: 1.3)
.padding(size * 0.22)
} else {
Circle().fill(Color.secondary)
}
Comment thread
cursor[bot] marked this conversation as resolved.
case .needsInput:
Image(systemName: "questionmark.circle.fill")
.font(.system(size: size, weight: .bold))
.foregroundStyle(.white, .orange)
if let foregroundColor {
Image(systemName: "questionmark.circle.fill")
.font(.system(size: size, weight: .bold))
.foregroundStyle(foregroundColor)
} else {
Image(systemName: "questionmark.circle.fill")
.font(.system(size: size, weight: .bold))
.foregroundStyle(.white, .orange)
}
case .ended:
Circle().stroke(Color.secondary, lineWidth: 1.3)
Circle().stroke(foregroundColor ?? .secondary, lineWidth: 1.3)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,43 @@
#if DEBUG
public import CMUXMobileCore
import Foundation

extension MobileShellComposite {
/// Suspends the DEBUG theme fixture until its production output consumer is attached.
///
/// The state is checked both before and while registering the continuation, so
/// registration cannot be missed between observation and suspension.
public func waitForThemeParityPreviewOutputSink(surfaceID: String) async -> Bool {
guard !Task.isCancelled else { return false }
guard !hasTerminalOutputSink(surfaceID: surfaceID) else { return true }

let waiterID = UUID()
return await withTaskCancellationHandler {
await withCheckedContinuation { continuation in
guard !Task.isCancelled else {
continuation.resume(returning: false)
return
}
guard !hasTerminalOutputSink(surfaceID: surfaceID) else {
continuation.resume(returning: true)
return
}
themeParityPreviewOutputSinkWaitersBySurfaceID[
surfaceID,
default: [:]
][waiterID] = continuation
}
} onCancel: {
Task { @MainActor [weak self] in
self?.resolveThemeParityPreviewOutputSinkWaiter(
surfaceID: surfaceID,
waiterID: waiterID,
value: false
)
}
}
}

/// Injects a render-grid frame through the production surface delivery path.
///
/// The theme-parity UI fixture uses this to verify mounted Ghostty surfaces,
Expand All @@ -14,5 +50,31 @@ extension MobileShellComposite {
deliverAuthoritativeTerminalRenderGrid(frame, source: "event")
return true
}

func resolveThemeParityPreviewOutputSinkWaiters(surfaceID: String) {
guard let waiters = themeParityPreviewOutputSinkWaitersBySurfaceID.removeValue(
forKey: surfaceID
) else {
return
}
for continuation in waiters.values {
continuation.resume(returning: true)
}
}

private func resolveThemeParityPreviewOutputSinkWaiter(
surfaceID: String,
waiterID: UUID,
value: Bool
) {
guard let continuation = themeParityPreviewOutputSinkWaitersBySurfaceID[surfaceID]?
.removeValue(forKey: waiterID) else {
return
}
if themeParityPreviewOutputSinkWaitersBySurfaceID[surfaceID]?.isEmpty == true {
themeParityPreviewOutputSinkWaitersBySurfaceID.removeValue(forKey: surfaceID)
}
continuation.resume(returning: value)
}
}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -1312,6 +1312,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
private var rawTerminalInputDrainWaiters: [CheckedContinuation<Void, Never>]
private var isRawTerminalInputDrainLoopRunning: Bool
#if DEBUG
@ObservationIgnored
var themeParityPreviewOutputSinkWaitersBySurfaceID: [
String: [UUID: CheckedContinuation<Bool, Never>]
]
var latencyProbeAutoNavigationTask: Task<Void, Never>?
var latencyProbeTask: Task<Void, Never>?
private var rawTerminalInputLatencyBatchNumber: UInt64
Expand Down Expand Up @@ -1648,6 +1652,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
self.rawTerminalInputDrainWaiters = []
self.isRawTerminalInputDrainLoopRunning = false
#if DEBUG
self.themeParityPreviewOutputSinkWaitersBySurfaceID = [:]
self.latencyProbeAutoNavigationTask = nil
self.latencyProbeTask = nil
self.rawTerminalInputLatencyBatchNumber = 0
Expand Down Expand Up @@ -11595,6 +11600,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
pendingTerminalByteEndSeqBySurfaceID.removeValue(forKey: surfaceID)
pendingTerminalInputDroppedRenderGridSurfaceIDs.remove(surfaceID)
#if DEBUG
resolveThemeParityPreviewOutputSinkWaiters(surfaceID: surfaceID)
mobileShellLog.info("CMUX_REPLAY register sink surface=\(surfaceID, privacy: .public) connected=\(self.connectionState == .connected, privacy: .public) hasClient=\(self.remoteClient != nil, privacy: .public) workspaceCount=\(self.workspaces.count, privacy: .public)")
startLatencyProbeIfReady()
#endif
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import CmuxMobileSupport
import SwiftUI

struct AltScreenNoticeButton: View {
var controlForegroundColor: Color = .orange
let dismissNotice: () -> Void
@State private var isPresentingExplanation = false

Expand All @@ -12,7 +13,7 @@ struct AltScreenNoticeButton: View {
Label(buttonAccessibilityLabel, systemImage: "exclamationmark.triangle.fill")
}
.labelStyle(.iconOnly)
.foregroundStyle(.orange)
.foregroundStyle(controlForegroundColor)
.accessibilityLabel(buttonAccessibilityLabel)
.accessibilityIdentifier("MobileTerminalAltScreenNoticeButton")
.popover(isPresented: $isPresentingExplanation) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,25 @@ import CMUXMobileCore
import Foundation
import SwiftUI

/// One immutable color pair for terminal-owned chrome. The opaque backing and
/// its readable foreground are resolved from the same terminal theme snapshot.
@MainActor
struct MobileTerminalChromeStyle {
let background: Color
let foreground: Color
let colorScheme: ColorScheme

init(theme: TerminalTheme) {
background = theme.terminalBackgroundColor
foreground = theme.terminalChromeForegroundColor
colorScheme = theme.terminalColorScheme
}

var workspaceBackButtonBadgeContrast: WorkspaceBackButtonBadgeContrast {
colorScheme == .light ? .lightBackground : .darkBackground
}
}

@MainActor
extension TerminalTheme {
var terminalBackgroundColor: Color { background.terminalColor }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,25 +18,42 @@ extension View {
@ViewBuilder
func mobileTerminalNavigationChrome(theme: TerminalTheme? = nil) -> some View {
#if os(iOS)
let colorScheme = theme.map { $0.terminalColorScheme } ?? .dark
if let theme {
let style = MobileTerminalChromeStyle(theme: theme)
self
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(theme.terminalBackgroundColor, for: .navigationBar)
.toolbarBackground(style.background, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.toolbarColorScheme(colorScheme, for: .navigationBar)
.toolbarColorScheme(style.colorScheme, for: .navigationBar)
} else {
self
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.ultraThinMaterial, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.toolbarColorScheme(colorScheme, for: .navigationBar)
.toolbarColorScheme(.dark, for: .navigationBar)
}
#else
self
#endif
}

/// Draws a toolbar control on an opaque terminal-owned backing. Both colors
/// come from one theme snapshot, so private adaptive material cannot lag the
/// foreground during a live terminal-theme change.
@ViewBuilder
func mobileTerminalChromeControl(theme: TerminalTheme) -> some View {
#if os(iOS)
let style = MobileTerminalChromeStyle(theme: theme)
self
.foregroundStyle(style.foreground)
.tint(style.foreground)
.background(style.background)
.environment(\.colorScheme, style.colorScheme)
#else
self
#endif
}

/// Keeps the legacy chat top gap on pre-iOS 26 material bars. On iOS 26 the
/// UIKit chat controller handles the top underlap for native scroll-edge
/// blending, so the host should not add an extra spacer.
Expand All @@ -53,3 +70,20 @@ extension View {
#endif
}
}

extension ToolbarContent {
/// Removes iOS 26's shared glass effect while retaining native toolbar item
/// layout, grouping, actions, and accessibility identity.
@ToolbarContentBuilder
func mobileTerminalSharedBackgroundHidden() -> some ToolbarContent {
#if os(iOS)
if #available(iOS 26.0, *) {
self.sharedBackgroundVisibility(.hidden)
} else {
self
}
#else
self
#endif
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import SwiftUI
struct WorkspaceBackButton: View {
let unreadCount: Int
var badgeContrast: WorkspaceBackButtonBadgeContrast = .lightBackground
var foregroundColor: Color = .primary
let action: () -> Void

var body: some View {
Expand All @@ -16,7 +17,7 @@ struct WorkspaceBackButton: View {
Image(systemName: "chevron.backward")
.font(.system(size: 17, weight: .semibold))
.imageScale(.medium)
.foregroundStyle(.primary)
.foregroundStyle(foregroundColor)
.frame(width: 17, height: 22)
if unreadCount > 0 {
Text(countText)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
enum WorkspaceBackButtonBadgeContrast {
enum WorkspaceBackButtonBadgeContrast: Equatable {
/// Use on dark terminal chrome: white circle, black text.
case darkBackground
/// Use on light chrome: black circle, white text.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
import SwiftUI

struct WorkspaceBackButtonConfiguration {
let unreadCount: Int
let badgeContrast: WorkspaceBackButtonBadgeContrast
let action: () -> Void
}
Loading